1use crate::agent::types::AgentMessage;
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8
9pub const CURRENT_SESSION_VERSION: u32 = 3;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct SessionHeader {
19 #[serde(rename = "type")]
20 pub type_: String, #[serde(default)]
22 pub version: Option<u32>,
23 pub id: String,
24 pub timestamp: String,
25 pub cwd: String,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub parent_session: Option<String>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(tag = "type")]
37pub enum SessionEntry {
38 #[serde(rename = "message")]
39 Message(MessageEntry),
40 #[serde(rename = "thinking_level_change")]
41 ThinkingLevelChange(ThinkingLevelChangeEntry),
42 #[serde(rename = "model_change")]
43 ModelChange(ModelChangeEntry),
44 #[serde(rename = "compaction")]
45 Compaction(CompactionEntry),
46 #[serde(rename = "branch_summary")]
47 BranchSummary(BranchSummaryEntry),
48 #[serde(rename = "session_info")]
49 SessionInfo(SessionInfoEntry),
50 #[serde(rename = "label")]
51 Label(LabelEntry),
52 #[serde(rename = "custom")]
53 Custom(CustomEntry),
54 #[serde(rename = "custom_message")]
55 CustomMessage(CustomMessageEntry),
56}
57
58impl SessionEntry {
59 pub fn id(&self) -> &str {
60 match self {
61 SessionEntry::Message(e) => &e.id,
62 SessionEntry::ThinkingLevelChange(e) => &e.id,
63 SessionEntry::ModelChange(e) => &e.id,
64 SessionEntry::Compaction(e) => &e.id,
65 SessionEntry::BranchSummary(e) => &e.id,
66 SessionEntry::SessionInfo(e) => &e.id,
67 SessionEntry::Label(e) => &e.id,
68 SessionEntry::Custom(e) => &e.id,
69 SessionEntry::CustomMessage(e) => &e.id,
70 }
71 }
72
73 pub fn parent_id(&self) -> Option<&str> {
74 match self {
75 SessionEntry::Message(e) => e.parent_id.as_deref(),
76 SessionEntry::ThinkingLevelChange(e) => e.parent_id.as_deref(),
77 SessionEntry::ModelChange(e) => e.parent_id.as_deref(),
78 SessionEntry::Compaction(e) => e.parent_id.as_deref(),
79 SessionEntry::BranchSummary(e) => e.parent_id.as_deref(),
80 SessionEntry::SessionInfo(e) => e.parent_id.as_deref(),
81 SessionEntry::Label(e) => e.parent_id.as_deref(),
82 SessionEntry::Custom(e) => e.parent_id.as_deref(),
83 SessionEntry::CustomMessage(e) => e.parent_id.as_deref(),
84 }
85 }
86
87 pub fn timestamp(&self) -> &str {
88 match self {
89 SessionEntry::Message(e) => &e.timestamp,
90 SessionEntry::ThinkingLevelChange(e) => &e.timestamp,
91 SessionEntry::ModelChange(e) => &e.timestamp,
92 SessionEntry::Compaction(e) => &e.timestamp,
93 SessionEntry::BranchSummary(e) => &e.timestamp,
94 SessionEntry::SessionInfo(e) => &e.timestamp,
95 SessionEntry::Label(e) => &e.timestamp,
96 SessionEntry::Custom(e) => &e.timestamp,
97 SessionEntry::CustomMessage(e) => &e.timestamp,
98 }
99 }
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct MessageEntry {
106 pub id: String,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub parent_id: Option<String>,
109 pub timestamp: String,
110 pub message: AgentMessage,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
114#[serde(rename_all = "camelCase")]
115pub struct ThinkingLevelChangeEntry {
116 pub id: String,
117 #[serde(skip_serializing_if = "Option::is_none")]
118 pub parent_id: Option<String>,
119 pub timestamp: String,
120 pub thinking_level: String,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase")]
125pub struct ModelChangeEntry {
126 pub id: String,
127 #[serde(skip_serializing_if = "Option::is_none")]
128 pub parent_id: Option<String>,
129 pub timestamp: String,
130 pub provider: String,
131 pub model_id: String,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135#[serde(rename_all = "camelCase")]
136pub struct CompactionEntry {
137 pub id: String,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub parent_id: Option<String>,
140 pub timestamp: String,
141 pub summary: String,
142 pub first_kept_entry_id: String,
143 pub tokens_before: u64,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub details: Option<serde_json::Value>,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub from_hook: Option<bool>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151#[serde(rename_all = "camelCase")]
152pub struct BranchSummaryEntry {
153 pub id: String,
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub parent_id: Option<String>,
156 pub timestamp: String,
157 pub from_id: String,
158 pub summary: String,
159 #[serde(skip_serializing_if = "Option::is_none")]
160 pub details: Option<serde_json::Value>,
161 #[serde(skip_serializing_if = "Option::is_none")]
162 pub from_hook: Option<bool>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166#[serde(rename_all = "camelCase")]
167pub struct SessionInfoEntry {
168 pub id: String,
169 #[serde(skip_serializing_if = "Option::is_none")]
170 pub parent_id: Option<String>,
171 pub timestamp: String,
172 pub name: String,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176#[serde(rename_all = "camelCase")]
177pub struct LabelEntry {
178 pub id: String,
179 #[serde(skip_serializing_if = "Option::is_none")]
180 pub parent_id: Option<String>,
181 pub timestamp: String,
182 pub target_id: String,
183 #[serde(skip_serializing_if = "Option::is_none")]
184 pub label: Option<String>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct CustomEntry {
190 pub id: String,
191 #[serde(skip_serializing_if = "Option::is_none")]
192 pub parent_id: Option<String>,
193 pub timestamp: String,
194 pub custom_type: String,
195 pub data: serde_json::Value,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199#[serde(rename_all = "camelCase")]
200pub struct CustomMessageEntry {
201 pub id: String,
202 #[serde(skip_serializing_if = "Option::is_none")]
203 pub parent_id: Option<String>,
204 pub timestamp: String,
205 pub custom_type: String,
206 pub content: serde_json::Value,
207 #[serde(default)]
208 pub display: bool,
209 #[serde(skip_serializing_if = "Option::is_none")]
210 pub details: Option<serde_json::Value>,
211}
212
213#[derive(Debug, Clone)]
217pub struct SessionInfo {
218 pub path: PathBuf,
219 pub id: String,
220 pub cwd: String,
221 pub name: Option<String>,
222 pub parent_session_path: Option<String>,
223 pub created: DateTime<Utc>,
224 pub modified: DateTime<Utc>,
225 pub message_count: usize,
226 pub first_message: String,
227 pub all_messages_text: String,
229}
230
231#[derive(Debug, Clone)]
235pub struct SessionContext {
236 pub messages: Vec<AgentMessage>,
237}
238
239pub fn parse_session_entry_line(line: &str) -> Option<SessionEntry> {
243 let line = line.trim();
244 if line.is_empty() {
245 return None;
246 }
247 serde_json::from_str(line).ok()
248}
249
250pub fn parse_session_header_line(line: &str) -> Option<SessionHeader> {
252 let line = line.trim();
253 if line.is_empty() {
254 return None;
255 }
256 let header: SessionHeader = serde_json::from_str(line).ok()?;
257 if header.type_ != "session" {
258 return None;
259 }
260 Some(header)
261}
262
263pub fn read_session_header(path: &Path) -> Option<SessionHeader> {
265 let content = fs::read_to_string(path).ok()?;
266 let first_line = content.lines().next()?;
267 parse_session_header_line(first_line)
268}
269
270pub fn load_entries_from_file(path: &Path) -> Vec<SessionEntry> {
273 let content = match fs::read_to_string(path) {
274 Ok(c) => c,
275 Err(_) => return vec![],
276 };
277
278 let entries: Vec<SessionEntry> = content
279 .lines()
280 .filter_map(parse_session_entry_line)
281 .collect();
282
283 entries
291}
292
293pub fn write_entries_to_file(
296 path: &Path,
297 header: &SessionHeader,
298 entries: &[SessionEntry],
299) -> std::io::Result<()> {
300 if let Some(parent) = path.parent() {
301 fs::create_dir_all(parent)?;
302 }
303 let mut content = serde_json::to_string(header).map_err(std::io::Error::from)?;
304 content.push('\n');
305 for entry in entries {
306 let line = serde_json::to_string(entry).map_err(std::io::Error::from)?;
307 content.push_str(&line);
308 content.push('\n');
309 }
310 fs::write(path, &content)
311}
312
313pub fn append_entry_to_file(path: &Path, entry: &SessionEntry) -> std::io::Result<()> {
315 let line = serde_json::to_string(entry).map_err(std::io::Error::from)?;
316 let content = format!("{}\n", line);
317 std::fs::OpenOptions::new()
318 .create(true)
319 .append(true)
320 .open(path)?
321 .write_all(content.as_bytes())
322}
323
324pub fn encode_cwd_for_dir(cwd: &Path) -> String {
329 let s = cwd.to_string_lossy();
330 let cleaned = s
331 .trim_start_matches('/')
332 .trim_start_matches('\\')
333 .replace(['/', '\\', ':'], "-");
334 format!("--{}--", cleaned)
335}
336
337pub fn get_default_session_dir(cwd: &Path) -> PathBuf {
339 let rab_dir = directories::BaseDirs::new()
340 .expect("Could not determine home directory")
341 .home_dir()
342 .join(".rab");
343 rab_dir.join("sessions").join(encode_cwd_for_dir(cwd))
344}
345
346pub fn generate_entry_id(by_id: &HashMap<String, SessionEntry>) -> String {
348 for _ in 0..100 {
349 let id = uuid::Uuid::new_v4().to_string()[..8].to_string();
350 if !by_id.contains_key(&id) {
351 return id;
352 }
353 }
354 uuid::Uuid::new_v4().to_string()
356}
357
358pub struct SessionManager {
366 session_id: String,
367 session_file: Option<PathBuf>,
368 session_dir: PathBuf,
369 cwd: PathBuf,
370 persist: bool,
371 flushed: bool,
372 file_entries: Vec<SessionEntry>,
373 by_id: HashMap<String, SessionEntry>,
374 labels_by_id: HashMap<String, String>,
375 leaf_id: Option<String>,
376}
377
378impl SessionManager {
379 fn new(
382 cwd: &Path,
383 session_dir: &Path,
384 session_file: Option<PathBuf>,
385 persist: bool,
386 create_new: bool,
387 ) -> Self {
388 let cwd = cwd.to_path_buf();
389 let session_dir = session_dir.to_path_buf();
390
391 let mut sm = Self {
392 session_id: String::new(),
393 session_file: None,
394 session_dir,
395 cwd,
396 persist,
397 flushed: false,
398 file_entries: Vec::new(),
399 by_id: HashMap::new(),
400 labels_by_id: HashMap::new(),
401 leaf_id: None,
402 };
403
404 if let Some(path) = session_file {
405 sm.set_session_file(&path);
406 if create_new {
407 sm.new_session(None);
409 sm.session_file = Some(path);
410 }
411 } else if create_new {
412 sm.new_session(None);
413 }
414
415 sm
416 }
417
418 fn set_session_file(&mut self, session_file: &Path) {
420 self.session_file = Some(session_file.to_path_buf());
421 if session_file.exists() {
422 self.file_entries = load_entries_from_file(session_file);
423 let header = read_session_header(session_file);
424
425 if self.file_entries.is_empty() && header.is_none() {
428 let explicit_path = self.session_file.clone();
429 self.new_session(None);
430 self.session_file = explicit_path;
431 self._rewrite_file();
432 self.flushed = true;
433 return;
434 }
435
436 self.session_id = header
438 .map(|h| h.id)
439 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
440 self.migrate_to_current();
441 self._build_index();
442 self.flushed = true;
443 } else {
444 let explicit_path = self.session_file.clone();
446 self.new_session(None);
447 self.session_file = explicit_path;
448 }
449 }
450
451 fn new_session(&mut self, id: Option<&str>) {
453 self.session_id = id
454 .map(|s| s.to_string())
455 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
456 let timestamp = chrono::Utc::now().to_rfc3339();
457 let header = SessionHeader {
458 type_: "session".to_string(),
459 version: Some(CURRENT_SESSION_VERSION),
460 id: self.session_id.clone(),
461 timestamp,
462 cwd: self.cwd.to_string_lossy().to_string(),
463 parent_session: None,
464 };
465 self.file_entries = Vec::new();
468 self.by_id.clear();
469 self.labels_by_id.clear();
470 self.leaf_id = None;
471 self.flushed = false;
472
473 if self.persist {
474 let file_ts = header.timestamp.replace([':', '.'], "-");
475 self.session_file = Some(
476 self.session_dir
477 .join(format!("{}_{}.jsonl", file_ts, self.session_id)),
478 );
479 }
480
481 }
484
485 fn _build_index(&mut self) {
486 self.by_id.clear();
487 self.labels_by_id.clear();
488 self.leaf_id = None;
489 for entry in &self.file_entries {
490 self.by_id.insert(entry.id().to_string(), entry.clone());
491 self.leaf_id = Some(entry.id().to_string());
492 if let SessionEntry::Label(e) = entry {
493 if let Some(label) = &e.label {
494 self.labels_by_id.insert(e.target_id.clone(), label.clone());
495 } else {
496 self.labels_by_id.remove(&e.target_id);
497 }
498 }
499 }
500 }
501
502 fn _rewrite_file(&self) {
503 if !self.persist {
504 return;
505 }
506 if let Some(ref path) = self.session_file {
507 let header = self._make_header();
508 let _ = write_entries_to_file(path, &header, &self.file_entries);
509 }
510 }
511
512 fn _make_header(&self) -> SessionHeader {
513 SessionHeader {
514 type_: "session".to_string(),
515 version: Some(CURRENT_SESSION_VERSION),
516 id: self.session_id.clone(),
517 timestamp: chrono::Utc::now().to_rfc3339(),
518 cwd: self.cwd.to_string_lossy().to_string(),
519 parent_session: None,
520 }
521 }
522
523 fn _persist(&mut self) {
524 if !self.persist {
525 return;
526 }
527 let has_assistant = self
528 .file_entries
529 .iter()
530 .any(|e| matches!(e, SessionEntry::Message(m) if m.message.role == crate::agent::types::Role::Assistant));
531
532 if !has_assistant {
533 self.flushed = false;
535 return;
536 }
537
538 if !self.flushed {
539 if let Some(ref path) = self.session_file {
540 let header = self._make_header();
541 let _ = write_entries_to_file(path, &header, &self.file_entries);
542 self.flushed = true;
543 }
544 } else if let Some(ref path) = self.session_file
545 && let Some(entry) = self.file_entries.last()
546 {
547 let _ = append_entry_to_file(path, entry);
548 }
549 }
550
551 fn _append_entry(&mut self, entry: SessionEntry) -> String {
552 let id = entry.id().to_string();
553 self.file_entries.push(entry.clone());
554 self.by_id.insert(id.clone(), entry);
555 self.leaf_id = Some(id.clone());
556 self._persist();
557 id
558 }
559
560 fn migrate_to_current(&mut self) {
563 }
566
567 pub fn is_persisted(&self) -> bool {
570 self.persist
571 }
572
573 pub fn cwd(&self) -> &Path {
574 &self.cwd
575 }
576
577 pub fn session_dir(&self) -> &Path {
578 &self.session_dir
579 }
580
581 pub fn uses_default_session_dir(&self) -> bool {
583 self.session_dir == get_default_session_dir(&self.cwd)
584 }
585
586 pub fn session_id(&self) -> &str {
587 &self.session_id
588 }
589
590 pub fn session_file(&self) -> Option<&Path> {
591 self.session_file.as_deref()
592 }
593
594 pub fn leaf_id(&self) -> Option<&str> {
595 self.leaf_id.as_deref()
596 }
597
598 pub fn session_name(&self) -> Option<&str> {
600 for entry in self.file_entries.iter().rev() {
601 if let SessionEntry::SessionInfo(e) = entry {
602 let name = e.name.trim();
603 if name.is_empty() {
604 return None;
605 }
606 return Some(name);
607 }
608 }
609 None
610 }
611
612 pub fn append_message(&mut self, message: &AgentMessage) -> String {
617 let entry = SessionEntry::Message(MessageEntry {
618 id: generate_entry_id(&self.by_id),
619 parent_id: self.leaf_id.clone(),
620 timestamp: chrono::Utc::now().to_rfc3339(),
621 message: message.clone(),
622 });
623 self._append_entry(entry)
624 }
625
626 pub fn append_thinking_level_change(&mut self, thinking_level: &str) -> String {
628 let entry = SessionEntry::ThinkingLevelChange(ThinkingLevelChangeEntry {
629 id: generate_entry_id(&self.by_id),
630 parent_id: self.leaf_id.clone(),
631 timestamp: chrono::Utc::now().to_rfc3339(),
632 thinking_level: thinking_level.to_string(),
633 });
634 self._append_entry(entry)
635 }
636
637 pub fn append_model_change(&mut self, provider: &str, model_id: &str) -> String {
639 let entry = SessionEntry::ModelChange(ModelChangeEntry {
640 id: generate_entry_id(&self.by_id),
641 parent_id: self.leaf_id.clone(),
642 timestamp: chrono::Utc::now().to_rfc3339(),
643 provider: provider.to_string(),
644 model_id: model_id.to_string(),
645 });
646 self._append_entry(entry)
647 }
648
649 pub fn append_session_info(&mut self, name: &str) -> String {
651 let entry = SessionEntry::SessionInfo(SessionInfoEntry {
652 id: generate_entry_id(&self.by_id),
653 parent_id: self.leaf_id.clone(),
654 timestamp: chrono::Utc::now().to_rfc3339(),
655 name: name.trim().to_string(),
656 });
657 self._append_entry(entry)
658 }
659
660 pub fn append_compaction(
662 &mut self,
663 summary: &str,
664 first_kept_entry_id: &str,
665 tokens_before: u64,
666 ) -> String {
667 let entry = SessionEntry::Compaction(CompactionEntry {
668 id: generate_entry_id(&self.by_id),
669 parent_id: self.leaf_id.clone(),
670 timestamp: chrono::Utc::now().to_rfc3339(),
671 summary: summary.to_string(),
672 first_kept_entry_id: first_kept_entry_id.to_string(),
673 tokens_before,
674 details: None,
675 from_hook: None,
676 });
677 self._append_entry(entry)
678 }
679
680 pub fn append_branch_summary(&mut self, from_id: &str, summary: &str) -> String {
682 let entry = SessionEntry::BranchSummary(BranchSummaryEntry {
683 id: generate_entry_id(&self.by_id),
684 parent_id: self.leaf_id.clone(),
685 timestamp: chrono::Utc::now().to_rfc3339(),
686 from_id: from_id.to_string(),
687 summary: summary.to_string(),
688 details: None,
689 from_hook: None,
690 });
691 self._append_entry(entry)
692 }
693
694 pub fn append_label_change(&mut self, target_id: &str, label: Option<&str>) -> String {
696 let entry = SessionEntry::Label(LabelEntry {
697 id: generate_entry_id(&self.by_id),
698 parent_id: self.leaf_id.clone(),
699 timestamp: chrono::Utc::now().to_rfc3339(),
700 target_id: target_id.to_string(),
701 label: label.map(|s| s.to_string()),
702 });
703 let id = self._append_entry(entry);
704
705 if let Some(l) = label {
707 self.labels_by_id
708 .insert(target_id.to_string(), l.to_string());
709 } else {
710 self.labels_by_id.remove(target_id);
711 }
712 id
713 }
714
715 pub fn append_custom_entry(&mut self, custom_type: &str, data: serde_json::Value) -> String {
717 let entry = SessionEntry::Custom(CustomEntry {
718 id: generate_entry_id(&self.by_id),
719 parent_id: self.leaf_id.clone(),
720 timestamp: chrono::Utc::now().to_rfc3339(),
721 custom_type: custom_type.to_string(),
722 data,
723 });
724 self._append_entry(entry)
725 }
726
727 pub fn entries(&self) -> &[SessionEntry] {
731 &self.file_entries
732 }
733
734 pub fn entry(&self, id: &str) -> Option<&SessionEntry> {
736 self.by_id.get(id)
737 }
738
739 pub fn children(&self, parent_id: &str) -> Vec<&SessionEntry> {
741 self.file_entries
742 .iter()
743 .filter(|e| e.parent_id() == Some(parent_id))
744 .collect()
745 }
746
747 pub fn branch(&self, from_id: Option<&str>) -> Vec<&SessionEntry> {
749 let start_id = from_id.or(self.leaf_id.as_deref());
750 let mut path = Vec::new();
751 let mut current = start_id.and_then(|id| self.by_id.get(id));
752 while let Some(entry) = current {
753 path.push(entry);
754 current = entry.parent_id().and_then(|pid| self.by_id.get(pid));
755 }
756 path.reverse();
757 path
758 }
759
760 pub fn build_session_context(&self) -> SessionContext {
762 let path = self.branch(None);
763 let messages: Vec<AgentMessage> = path
764 .iter()
765 .filter_map(|entry| {
766 if let SessionEntry::Message(e) = entry {
767 Some(e.message.clone())
768 } else {
769 None
770 }
771 })
772 .collect();
773 SessionContext { messages }
774 }
775
776 pub fn label(&self, id: &str) -> Option<&str> {
778 self.labels_by_id.get(id).map(|s| s.as_str())
779 }
780
781 pub fn set_branch(&mut self, branch_from_id: &str) -> Result<(), String> {
785 if !self.by_id.contains_key(branch_from_id) {
786 return Err(format!("Entry {} not found", branch_from_id));
787 }
788 self.leaf_id = Some(branch_from_id.to_string());
789 Ok(())
790 }
791
792 pub fn reset_leaf(&mut self) {
794 self.leaf_id = None;
795 }
796
797 pub fn create(cwd: &Path, session_dir: Option<&Path>) -> Self {
801 let dir = session_dir
802 .map(|p| p.to_path_buf())
803 .unwrap_or_else(|| get_default_session_dir(cwd));
804 Self::new(cwd, &dir, None, true, true)
805 }
806
807 pub fn open(path: &Path, session_dir: Option<&Path>, cwd_override: Option<&Path>) -> Self {
809 let cwd = if let Some(cwd_path) = cwd_override {
810 cwd_path.to_path_buf()
811 } else {
812 read_session_header(path)
814 .map(|h| PathBuf::from(h.cwd))
815 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")))
816 };
817 let dir = session_dir.map(|p| p.to_path_buf()).unwrap_or_else(|| {
818 path.parent()
819 .map(|p| p.to_path_buf())
820 .unwrap_or_else(|| get_default_session_dir(&cwd))
821 });
822 Self::new(&cwd, &dir, Some(path.to_path_buf()), true, false)
823 }
824
825 pub fn in_memory(cwd: &Path) -> Self {
827 let dir = get_default_session_dir(cwd);
828 Self::new(cwd, &dir, None, false, true)
829 }
830
831 pub fn continue_recent(cwd: &Path, session_dir: Option<&Path>) -> Self {
833 let dir = session_dir
834 .map(|p| p.to_path_buf())
835 .unwrap_or_else(|| get_default_session_dir(cwd));
836 let filter_cwd = session_dir.is_some_and(|sd| sd != get_default_session_dir(cwd));
837 let most_recent = find_most_recent_session(&dir, if filter_cwd { Some(cwd) } else { None });
838 if let Some(path) = most_recent {
839 Self::new(cwd, &dir, Some(path), true, false)
840 } else {
841 Self::new(cwd, &dir, None, true, true)
842 }
843 }
844}
845
846pub fn find_most_recent_session(session_dir: &Path, filter_cwd: Option<&Path>) -> Option<PathBuf> {
848 let resolved_cwd = filter_cwd.map(|c| c.to_path_buf());
849 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
850
851 let entries = std::fs::read_dir(session_dir).ok()?;
852 for entry in entries.flatten() {
853 let path = entry.path();
854 if path.extension().is_some_and(|ext| ext == "jsonl") {
855 let header = read_session_header(&path);
856 if let Some(ref h) = header {
857 if let Some(ref rcwd) = resolved_cwd
858 && h.cwd != rcwd.to_string_lossy().as_ref()
859 {
860 continue;
861 }
862 } else {
863 continue;
864 }
865 if let Ok(meta) = path.metadata()
866 && let Ok(mtime) = meta.modified()
867 {
868 files.push((path, mtime));
869 }
870 }
871 }
872
873 files.sort_by_key(|b| std::cmp::Reverse(b.1));
874 files.into_iter().next().map(|(path, _)| path)
875}
876
877#[cfg(test)]
880mod tests {
881 use super::*;
882 use crate::agent::types::{AgentMessage, Role, Usage};
883 use tempfile::TempDir;
884
885 fn make_message(role: Role, content: &str) -> AgentMessage {
886 AgentMessage {
887 id: uuid::Uuid::new_v4().to_string(),
888 parent_id: None,
889 role,
890 content: content.to_string(),
891 tool_calls: vec![],
892 tool_call_id: None,
893 usage: None,
894 is_error: false,
895 timestamp: Utc::now().timestamp_millis(),
896 }
897 }
898
899 #[test]
902 fn test_message_entry_roundtrip() {
903 let msg = make_message(Role::User, "hello world");
904 let entry = SessionEntry::Message(MessageEntry {
905 id: "abc12345".to_string(),
906 parent_id: None,
907 timestamp: "2026-06-19T12:00:00Z".to_string(),
908 message: msg.clone(),
909 });
910
911 let json = serde_json::to_string(&entry).unwrap();
912 let parsed: SessionEntry = serde_json::from_str(&json).unwrap();
913
914 match parsed {
915 SessionEntry::Message(e) => {
916 assert_eq!(e.id, "abc12345");
917 assert_eq!(e.parent_id, None);
918 assert_eq!(e.message.role, Role::User);
919 assert_eq!(e.message.content, "hello world");
920 }
921 _ => panic!("Expected Message variant"),
922 }
923 }
924
925 #[test]
926 fn test_thinking_level_change_roundtrip() {
927 let entry = SessionEntry::ThinkingLevelChange(ThinkingLevelChangeEntry {
928 id: "abc12345".to_string(),
929 parent_id: Some("parent1".to_string()),
930 timestamp: "2026-06-19T12:00:00Z".to_string(),
931 thinking_level: "high".to_string(),
932 });
933
934 let json = serde_json::to_string(&entry).unwrap();
935 let parsed: SessionEntry = serde_json::from_str(&json).unwrap();
936
937 match parsed {
938 SessionEntry::ThinkingLevelChange(e) => {
939 assert_eq!(e.thinking_level, "high");
940 assert_eq!(e.parent_id.as_deref(), Some("parent1"));
941 }
942 _ => panic!("Expected ThinkingLevelChange variant"),
943 }
944 }
945
946 #[test]
947 fn test_model_change_roundtrip() {
948 let entry = SessionEntry::ModelChange(ModelChangeEntry {
949 id: "abc12345".to_string(),
950 parent_id: Some("parent1".to_string()),
951 timestamp: "2026-06-19T12:00:00Z".to_string(),
952 provider: "opencode_go".to_string(),
953 model_id: "deepseek-v4-pro".to_string(),
954 });
955
956 let json = serde_json::to_string(&entry).unwrap();
957 let parsed: SessionEntry = serde_json::from_str(&json).unwrap();
958
959 match parsed {
960 SessionEntry::ModelChange(e) => {
961 assert_eq!(e.provider, "opencode_go");
962 assert_eq!(e.model_id, "deepseek-v4-pro");
963 }
964 _ => panic!("Expected ModelChange variant"),
965 }
966 }
967
968 #[test]
969 fn test_compaction_entry_roundtrip() {
970 let entry = SessionEntry::Compaction(CompactionEntry {
971 id: "abc12345".to_string(),
972 parent_id: Some("parent1".to_string()),
973 timestamp: "2026-06-19T12:00:00Z".to_string(),
974 summary: "Earlier conversation summarized...".to_string(),
975 first_kept_entry_id: "entry123".to_string(),
976 tokens_before: 5000,
977 details: None,
978 from_hook: None,
979 });
980
981 let json = serde_json::to_string(&entry).unwrap();
982 let parsed: SessionEntry = serde_json::from_str(&json).unwrap();
983
984 match parsed {
985 SessionEntry::Compaction(e) => {
986 assert_eq!(e.summary, "Earlier conversation summarized...");
987 assert_eq!(e.first_kept_entry_id, "entry123");
988 assert_eq!(e.tokens_before, 5000);
989 }
990 _ => panic!("Expected Compaction variant"),
991 }
992 }
993
994 #[test]
995 fn test_branch_summary_roundtrip() {
996 let entry = SessionEntry::BranchSummary(BranchSummaryEntry {
997 id: "abc12345".to_string(),
998 parent_id: Some("parent1".to_string()),
999 timestamp: "2026-06-19T12:00:00Z".to_string(),
1000 from_id: "branch_point".to_string(),
1001 summary: "Abandoned work on feature X".to_string(),
1002 details: None,
1003 from_hook: None,
1004 });
1005
1006 let json = serde_json::to_string(&entry).unwrap();
1007 let parsed: SessionEntry = serde_json::from_str(&json).unwrap();
1008
1009 match parsed {
1010 SessionEntry::BranchSummary(e) => {
1011 assert_eq!(e.summary, "Abandoned work on feature X");
1012 assert_eq!(e.from_id, "branch_point");
1013 }
1014 _ => panic!("Expected BranchSummary variant"),
1015 }
1016 }
1017
1018 #[test]
1019 fn test_session_info_roundtrip() {
1020 let entry = SessionEntry::SessionInfo(SessionInfoEntry {
1021 id: "abc12345".to_string(),
1022 parent_id: Some("parent1".to_string()),
1023 timestamp: "2026-06-19T12:00:00Z".to_string(),
1024 name: "Refactor auth module".to_string(),
1025 });
1026
1027 let json = serde_json::to_string(&entry).unwrap();
1028 let parsed: SessionEntry = serde_json::from_str(&json).unwrap();
1029
1030 match parsed {
1031 SessionEntry::SessionInfo(e) => {
1032 assert_eq!(e.name, "Refactor auth module");
1033 }
1034 _ => panic!("Expected SessionInfo variant"),
1035 }
1036 }
1037
1038 #[test]
1039 fn test_label_entry_roundtrip() {
1040 let entry = SessionEntry::Label(LabelEntry {
1042 id: "abc12345".to_string(),
1043 parent_id: Some("parent1".to_string()),
1044 timestamp: "2026-06-19T12:00:00Z".to_string(),
1045 target_id: "target_entry".to_string(),
1046 label: Some("important".to_string()),
1047 });
1048
1049 let json = serde_json::to_string(&entry).unwrap();
1050 let parsed: SessionEntry = serde_json::from_str(&json).unwrap();
1051
1052 match parsed {
1053 SessionEntry::Label(e) => {
1054 assert_eq!(e.label.as_deref(), Some("important"));
1055 assert_eq!(e.target_id, "target_entry");
1056 }
1057 _ => panic!("Expected Label variant"),
1058 }
1059
1060 let entry = SessionEntry::Label(LabelEntry {
1062 id: "abc12346".to_string(),
1063 parent_id: Some("parent1".to_string()),
1064 timestamp: "2026-06-19T12:01:00Z".to_string(),
1065 target_id: "target_entry".to_string(),
1066 label: None,
1067 });
1068
1069 let json = serde_json::to_string(&entry).unwrap();
1070 assert!(!json.contains(r#""label":"#));
1072 }
1073
1074 #[test]
1075 fn test_custom_entry_roundtrip() {
1076 let entry = SessionEntry::Custom(CustomEntry {
1077 id: "abc12345".to_string(),
1078 parent_id: Some("parent1".to_string()),
1079 timestamp: "2026-06-19T12:00:00Z".to_string(),
1080 custom_type: "my_extension".to_string(),
1081 data: serde_json::json!({"key": "value"}),
1082 });
1083
1084 let json = serde_json::to_string(&entry).unwrap();
1085 let parsed: SessionEntry = serde_json::from_str(&json).unwrap();
1086
1087 match parsed {
1088 SessionEntry::Custom(e) => {
1089 assert_eq!(e.custom_type, "my_extension");
1090 assert_eq!(e.data["key"], "value");
1091 }
1092 _ => panic!("Expected Custom variant"),
1093 }
1094 }
1095
1096 #[test]
1097 fn test_custom_message_entry_roundtrip() {
1098 let entry = SessionEntry::CustomMessage(CustomMessageEntry {
1099 id: "abc12345".to_string(),
1100 parent_id: Some("parent1".to_string()),
1101 timestamp: "2026-06-19T12:00:00Z".to_string(),
1102 custom_type: "my_extension".to_string(),
1103 content: serde_json::json!({"text": "Hello from extension"}),
1104 display: true,
1105 details: Some(serde_json::json!({"source": "plugin"})),
1106 });
1107
1108 let json = serde_json::to_string(&entry).unwrap();
1109 let parsed: SessionEntry = serde_json::from_str(&json).unwrap();
1110
1111 match parsed {
1112 SessionEntry::CustomMessage(e) => {
1113 assert_eq!(e.custom_type, "my_extension");
1114 assert!(e.display);
1115 }
1116 _ => panic!("Expected CustomMessage variant"),
1117 }
1118 }
1119
1120 #[test]
1123 fn test_parse_session_entry_line() {
1124 let entry = SessionEntry::SessionInfo(SessionInfoEntry {
1125 id: "abc12345".to_string(),
1126 parent_id: None,
1127 timestamp: "2026-06-19T12:00:00Z".to_string(),
1128 name: "Test session".to_string(),
1129 });
1130 let json = serde_json::to_string(&entry).unwrap();
1131 let parsed = parse_session_entry_line(&json);
1132 assert!(parsed.is_some());
1133 }
1134
1135 #[test]
1136 fn test_parse_session_entry_line_empty() {
1137 assert!(parse_session_entry_line("").is_none());
1138 assert!(parse_session_entry_line(" ").is_none());
1139 }
1140
1141 #[test]
1142 fn test_parse_session_entry_line_malformed() {
1143 assert!(parse_session_entry_line("not valid json").is_none());
1144 }
1145
1146 #[test]
1147 fn test_parse_session_header_line() {
1148 let header = SessionHeader {
1149 type_: "session".to_string(),
1150 version: Some(3),
1151 id: "session123".to_string(),
1152 timestamp: "2026-06-19T12:00:00Z".to_string(),
1153 cwd: "/home/user/project".to_string(),
1154 parent_session: None,
1155 };
1156 let json = serde_json::to_string(&header).unwrap();
1157 let parsed = parse_session_header_line(&json);
1158 assert!(parsed.is_some());
1159 assert_eq!(parsed.unwrap().id, "session123");
1160 }
1161
1162 #[test]
1163 fn test_parse_session_header_line_wrong_type() {
1164 let json =
1166 r#"{"type":"message","id":"abc","timestamp":"2026-06-19T12:00:00Z","cwd":"/home"}"#;
1167 let result = parse_session_header_line(json);
1168 assert!(result.is_none());
1169 }
1170
1171 #[test]
1172 fn test_write_and_read_entries() {
1173 let tmp = TempDir::new().unwrap();
1174 let file_path = tmp.path().join("test.jsonl");
1175
1176 let header = SessionHeader {
1177 type_: "session".to_string(),
1178 version: Some(3),
1179 id: "session1".to_string(),
1180 timestamp: "2026-06-19T12:00:00Z".to_string(),
1181 cwd: "/home/user/project".to_string(),
1182 parent_session: None,
1183 };
1184
1185 let entries: Vec<SessionEntry> = vec![
1186 SessionEntry::Message(MessageEntry {
1187 id: "msg1".to_string(),
1188 parent_id: None,
1189 timestamp: "2026-06-19T12:00:01Z".to_string(),
1190 message: make_message(Role::User, "hello"),
1191 }),
1192 SessionEntry::Message(MessageEntry {
1193 id: "msg2".to_string(),
1194 parent_id: Some("msg1".to_string()),
1195 timestamp: "2026-06-19T12:00:02Z".to_string(),
1196 message: {
1197 let mut m = make_message(Role::Assistant, "hi there");
1198 m.usage = Some(Usage {
1199 input_tokens: Some(10),
1200 output_tokens: Some(5),
1201 cache_tokens: None,
1202 });
1203 m
1204 },
1205 }),
1206 ];
1207
1208 write_entries_to_file(&file_path, &header, &entries).unwrap();
1209
1210 let read_header = read_session_header(&file_path).unwrap();
1212 assert_eq!(read_header.id, "session1");
1213
1214 let read_entries = load_entries_from_file(&file_path);
1216 assert_eq!(read_entries.len(), 2);
1217
1218 match &read_entries[0] {
1219 SessionEntry::Message(e) => {
1220 assert_eq!(e.id, "msg1");
1221 assert_eq!(e.message.role, Role::User);
1222 assert_eq!(e.message.content, "hello");
1223 }
1224 _ => panic!("Expected Message"),
1225 }
1226
1227 match &read_entries[1] {
1228 SessionEntry::Message(e) => {
1229 assert_eq!(e.id, "msg2");
1230 assert_eq!(e.message.role, Role::Assistant);
1231 assert_eq!(e.message.content, "hi there");
1232 assert!(e.message.usage.is_some());
1233 }
1234 _ => panic!("Expected Message"),
1235 }
1236 }
1237
1238 #[test]
1239 fn test_append_entry_to_file() {
1240 let tmp = TempDir::new().unwrap();
1241 let file_path = tmp.path().join("append_test.jsonl");
1242
1243 let entry = SessionEntry::SessionInfo(SessionInfoEntry {
1244 id: "abc12345".to_string(),
1245 parent_id: None,
1246 timestamp: "2026-06-19T12:00:00Z".to_string(),
1247 name: "Test".to_string(),
1248 });
1249
1250 append_entry_to_file(&file_path, &entry).unwrap();
1251
1252 let content = fs::read_to_string(&file_path).unwrap();
1253 assert!(content.contains("Test"));
1254 assert!(content.contains("abc12345"));
1255 }
1256
1257 #[test]
1258 fn test_load_entries_missing_file() {
1259 let entries = load_entries_from_file(Path::new("/nonexistent/file.jsonl"));
1260 assert!(entries.is_empty());
1261 }
1262
1263 #[test]
1264 fn test_read_session_header_missing_file() {
1265 let header = read_session_header(Path::new("/nonexistent/file.jsonl"));
1266 assert!(header.is_none());
1267 }
1268
1269 #[test]
1272 fn test_encode_cwd() {
1273 assert_eq!(
1274 encode_cwd_for_dir(Path::new("/home/user/project")),
1275 "--home-user-project--"
1276 );
1277 }
1278
1279 #[test]
1280 fn test_encode_cwd_windows_style() {
1281 assert_eq!(
1282 encode_cwd_for_dir(Path::new("C:\\Users\\user\\project")),
1283 "--C--Users-user-project--"
1284 );
1285 }
1286
1287 #[test]
1288 fn test_encode_cwd_no_leading_slash() {
1289 assert_eq!(
1290 encode_cwd_for_dir(Path::new("home/user/project")),
1291 "--home-user-project--"
1292 );
1293 }
1294
1295 #[test]
1296 fn test_encode_cwd_special_chars() {
1297 assert_eq!(
1298 encode_cwd_for_dir(Path::new("/home/user/my:project")),
1299 "--home-user-my-project--"
1300 );
1301 }
1302
1303 #[test]
1306 fn test_entry_id_accessor() {
1307 let entry = SessionEntry::Message(MessageEntry {
1308 id: "myid".to_string(),
1309 parent_id: None,
1310 timestamp: "2026-06-19T12:00:00Z".to_string(),
1311 message: make_message(Role::User, "hello"),
1312 });
1313 assert_eq!(entry.id(), "myid");
1314 }
1315
1316 #[test]
1317 fn test_entry_parent_id_accessor() {
1318 let entry = SessionEntry::Message(MessageEntry {
1319 id: "myid".to_string(),
1320 parent_id: Some("parent".to_string()),
1321 timestamp: "2026-06-19T12:00:00Z".to_string(),
1322 message: make_message(Role::User, "hello"),
1323 });
1324 assert_eq!(entry.parent_id(), Some("parent"));
1325 }
1326
1327 #[test]
1328 fn test_entry_timestamp_accessor() {
1329 let entry = SessionEntry::Message(MessageEntry {
1330 id: "myid".to_string(),
1331 parent_id: None,
1332 timestamp: "2026-06-19T12:00:00Z".to_string(),
1333 message: make_message(Role::User, "hello"),
1334 });
1335 assert_eq!(entry.timestamp(), "2026-06-19T12:00:00Z");
1336 }
1337
1338 #[test]
1341 fn test_generate_entry_id_length() {
1342 let map = HashMap::new();
1343 let id = generate_entry_id(&map);
1344 assert_eq!(id.len(), 8);
1345 }
1346
1347 #[test]
1348 fn test_generate_entry_id_hex() {
1349 let map = HashMap::new();
1350 let id = generate_entry_id(&map);
1351 assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
1352 }
1353
1354 #[test]
1355 fn test_generate_entry_id_collision_fallback() {
1356 let map = HashMap::new();
1361 let id1 = generate_entry_id(&map);
1362 assert!(!id1.is_empty());
1363 }
1364
1365 #[test]
1368 fn test_deserialize_pi_format_message() {
1369 let json = r#"{"type":"message","id":"abc12345","parentId":null,"timestamp":"2026-06-19T12:00:00Z","message":{"id":"msg1","parentId":null,"role":"user","content":"hello","toolCalls":[],"isError":false,"timestamp":1718800000000}}"#;
1371 let entry: SessionEntry = serde_json::from_str(json).unwrap();
1372 match entry {
1373 SessionEntry::Message(e) => {
1374 assert_eq!(e.id, "abc12345");
1375 assert_eq!(e.message.content, "hello");
1376 }
1377 _ => panic!("Expected Message"),
1378 }
1379 }
1380
1381 #[test]
1382 fn test_deserialize_pi_format_thinking_level() {
1383 let json = r#"{"type":"thinking_level_change","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","thinkingLevel":"high"}"#;
1384 let entry: SessionEntry = serde_json::from_str(json).unwrap();
1385 match entry {
1386 SessionEntry::ThinkingLevelChange(e) => {
1387 assert_eq!(e.thinking_level, "high");
1388 }
1389 _ => panic!("Expected ThinkingLevelChange"),
1390 }
1391 }
1392
1393 #[test]
1394 fn test_deserialize_pi_format_model_change() {
1395 let json = r#"{"type":"model_change","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","provider":"opencode_go","modelId":"deepseek-v4-pro"}"#;
1396 let entry: SessionEntry = serde_json::from_str(json).unwrap();
1397 match entry {
1398 SessionEntry::ModelChange(e) => {
1399 assert_eq!(e.provider, "opencode_go");
1400 assert_eq!(e.model_id, "deepseek-v4-pro");
1401 }
1402 _ => panic!("Expected ModelChange"),
1403 }
1404 }
1405
1406 #[test]
1407 fn test_deserialize_pi_format_compaction() {
1408 let json = r#"{"type":"compaction","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","summary":"Earlier conversation summarized","firstKeptEntryId":"entry123","tokensBefore":5000}"#;
1409 let entry: SessionEntry = serde_json::from_str(json).unwrap();
1410 match entry {
1411 SessionEntry::Compaction(e) => {
1412 assert_eq!(e.summary, "Earlier conversation summarized");
1413 assert_eq!(e.first_kept_entry_id, "entry123");
1414 assert_eq!(e.tokens_before, 5000);
1415 }
1416 _ => panic!("Expected Compaction"),
1417 }
1418 }
1419
1420 #[test]
1421 fn test_deserialize_pi_format_session_info() {
1422 let json = r#"{"type":"session_info","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","name":"My session"}"#;
1423 let entry: SessionEntry = serde_json::from_str(json).unwrap();
1424 match entry {
1425 SessionEntry::SessionInfo(e) => {
1426 assert_eq!(e.name, "My session");
1427 }
1428 _ => panic!("Expected SessionInfo"),
1429 }
1430 }
1431
1432 fn make_agent_message(role: Role, content: &str) -> AgentMessage {
1435 AgentMessage {
1436 id: uuid::Uuid::new_v4().to_string(),
1437 parent_id: None,
1438 role,
1439 content: content.to_string(),
1440 tool_calls: vec![],
1441 tool_call_id: None,
1442 usage: None,
1443 is_error: false,
1444 timestamp: Utc::now().timestamp_millis(),
1445 }
1446 }
1447
1448 #[test]
1449 fn test_session_create_in_memory() {
1450 let cwd = Path::new("/tmp/test-project");
1451 let sm = SessionManager::in_memory(cwd);
1452 assert!(!sm.is_persisted());
1453 assert!(!sm.session_id().is_empty());
1454 assert_eq!(sm.cwd(), cwd);
1455 assert!(sm.leaf_id().is_none());
1456 assert!(sm.entries().is_empty());
1457 }
1458
1459 #[test]
1460 fn test_session_create_persisted() {
1461 let tmp = TempDir::new().unwrap();
1462 let sessions_dir = tmp.path().join("sessions");
1463 let cwd = tmp.path().join("project");
1464 std::fs::create_dir_all(&cwd).unwrap();
1465
1466 let sm = SessionManager::create(&cwd, Some(&sessions_dir));
1467 assert!(sm.is_persisted());
1468 assert!(!sm.session_id().is_empty());
1469 assert!(sm.session_file().is_some());
1471 }
1472
1473 #[test]
1474 fn test_session_append_and_build_context() {
1475 let tmp = TempDir::new().unwrap();
1476 let sessions_dir = tmp.path().join("sessions");
1477 let cwd = tmp.path().join("project");
1478 std::fs::create_dir_all(&cwd).unwrap();
1479
1480 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1481
1482 let user_msg = make_agent_message(Role::User, "hello");
1483 let user_id = sm.append_message(&user_msg);
1484 assert!(sm.leaf_id() == Some(&user_id));
1485
1486 assert_eq!(sm.entries().len(), 1);
1488
1489 let assistant_msg = make_agent_message(Role::Assistant, "hi there");
1490 sm.append_message(&assistant_msg);
1491 assert_eq!(sm.entries().len(), 2);
1492
1493 let context = sm.build_session_context();
1495 assert_eq!(context.messages.len(), 2);
1496 assert_eq!(context.messages[0].content, "hello");
1497 assert_eq!(context.messages[1].content, "hi there");
1498 }
1499
1500 #[test]
1501 fn test_session_open_existing() {
1502 let tmp = TempDir::new().unwrap();
1503 let sessions_dir = tmp.path().join("sessions");
1504 let cwd = tmp.path().join("project");
1505 std::fs::create_dir_all(&cwd).unwrap();
1506
1507 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1509 sm.append_message(&make_agent_message(Role::User, "first"));
1510 sm.append_message(&make_agent_message(Role::Assistant, "response"));
1511
1512 let file_path = sm.session_file().unwrap().to_path_buf();
1513 let session_id = sm.session_id().to_string();
1514 drop(sm);
1515
1516 let sm2 = SessionManager::open(&file_path, Some(&sessions_dir), None);
1518 assert_eq!(sm2.session_id(), &session_id);
1519 let context = sm2.build_session_context();
1520 assert_eq!(context.messages.len(), 2);
1521 assert_eq!(context.messages[0].content, "first");
1522 assert_eq!(context.messages[1].content, "response");
1523 }
1524
1525 #[test]
1526 fn test_session_continue_recent() {
1527 let tmp = TempDir::new().unwrap();
1528 let sessions_dir = tmp.path().join("sessions");
1529 let cwd = tmp.path().join("project");
1530 std::fs::create_dir_all(&cwd).unwrap();
1531
1532 let mut sm1 = SessionManager::create(&cwd, Some(&sessions_dir));
1534 sm1.append_message(&make_agent_message(Role::User, "old session"));
1535 sm1.append_message(&make_agent_message(Role::Assistant, "old response"));
1536 let _old_id = sm1.session_id().to_string();
1537 drop(sm1);
1538
1539 std::thread::sleep(std::time::Duration::from_millis(10));
1541
1542 let mut sm2 = SessionManager::create(&cwd, Some(&sessions_dir));
1544 sm2.append_message(&make_agent_message(Role::User, "new session"));
1545 sm2.append_message(&make_agent_message(Role::Assistant, "new response"));
1546 let new_id = sm2.session_id().to_string();
1547 drop(sm2);
1548
1549 let sm3 = SessionManager::continue_recent(&cwd, Some(&sessions_dir));
1551 assert_eq!(sm3.session_id(), &new_id);
1552 let context = sm3.build_session_context();
1553 assert_eq!(context.messages[0].content, "new session");
1554 }
1555
1556 #[test]
1557 fn test_session_continue_recent_none_exist() {
1558 let tmp = TempDir::new().unwrap();
1559 let sessions_dir = tmp.path().join("sessions");
1560 let cwd = tmp.path().join("project");
1561 std::fs::create_dir_all(&sessions_dir).unwrap();
1562 std::fs::create_dir_all(&cwd).unwrap();
1563
1564 let sm = SessionManager::continue_recent(&cwd, Some(&sessions_dir));
1566 assert!(!sm.session_id().is_empty());
1567 assert!(sm.entries().is_empty());
1568 }
1569
1570 #[test]
1571 fn test_session_name() {
1572 let tmp = TempDir::new().unwrap();
1573 let sessions_dir = tmp.path().join("sessions");
1574 let cwd = tmp.path().join("project");
1575 std::fs::create_dir_all(&cwd).unwrap();
1576
1577 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1578 assert!(sm.session_name().is_none());
1579
1580 sm.append_session_info("My Task");
1581 sm.append_message(&make_agent_message(Role::User, "hello"));
1582 sm.append_message(&make_agent_message(Role::Assistant, "hi"));
1583 assert_eq!(sm.session_name(), Some("My Task"));
1584
1585 sm.append_session_info("");
1587 assert!(sm.session_name().is_none());
1588 }
1589
1590 #[test]
1591 fn test_session_thinking_level_change() {
1592 let tmp = TempDir::new().unwrap();
1593 let sessions_dir = tmp.path().join("sessions");
1594 let cwd = tmp.path().join("project");
1595 std::fs::create_dir_all(&cwd).unwrap();
1596
1597 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1598 sm.append_thinking_level_change("high");
1599
1600 assert_eq!(sm.entries().len(), 1);
1601 match &sm.entries()[0] {
1602 SessionEntry::ThinkingLevelChange(e) => {
1603 assert_eq!(e.thinking_level, "high");
1604 }
1605 _ => panic!("Expected ThinkingLevelChange"),
1606 }
1607 }
1608
1609 #[test]
1610 fn test_session_model_change() {
1611 let tmp = TempDir::new().unwrap();
1612 let sessions_dir = tmp.path().join("sessions");
1613 let cwd = tmp.path().join("project");
1614 std::fs::create_dir_all(&cwd).unwrap();
1615
1616 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1617 sm.append_model_change("opencode_go", "deepseek-v4-pro");
1618
1619 assert_eq!(sm.entries().len(), 1);
1620 match &sm.entries()[0] {
1621 SessionEntry::ModelChange(e) => {
1622 assert_eq!(e.provider, "opencode_go");
1623 assert_eq!(e.model_id, "deepseek-v4-pro");
1624 }
1625 _ => panic!("Expected ModelChange"),
1626 }
1627 }
1628
1629 #[test]
1630 fn test_session_compaction() {
1631 let tmp = TempDir::new().unwrap();
1632 let sessions_dir = tmp.path().join("sessions");
1633 let cwd = tmp.path().join("project");
1634 std::fs::create_dir_all(&cwd).unwrap();
1635
1636 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1637 sm.append_compaction("Earlier work summarized", "entry_kept", 5000);
1638
1639 match &sm.entries()[0] {
1640 SessionEntry::Compaction(e) => {
1641 assert_eq!(e.summary, "Earlier work summarized");
1642 assert_eq!(e.first_kept_entry_id, "entry_kept");
1643 assert_eq!(e.tokens_before, 5000);
1644 }
1645 _ => panic!("Expected Compaction"),
1646 }
1647 }
1648
1649 #[test]
1650 fn test_session_label() {
1651 let tmp = TempDir::new().unwrap();
1652 let sessions_dir = tmp.path().join("sessions");
1653 let cwd = tmp.path().join("project");
1654 std::fs::create_dir_all(&cwd).unwrap();
1655
1656 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1657 let msg_id = sm.append_message(&make_agent_message(Role::User, "important message"));
1658 sm.append_message(&make_agent_message(Role::Assistant, "ok"));
1659
1660 sm.append_label_change(&msg_id, Some("important"));
1662 assert_eq!(sm.label(&msg_id), Some("important"));
1663
1664 sm.append_label_change(&msg_id, None);
1666 assert_eq!(sm.label(&msg_id), None);
1667 }
1668
1669 #[test]
1670 fn test_session_branch_navigation() {
1671 let tmp = TempDir::new().unwrap();
1672 let sessions_dir = tmp.path().join("sessions");
1673 let cwd = tmp.path().join("project");
1674 std::fs::create_dir_all(&cwd).unwrap();
1675
1676 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1677 let m1 = sm.append_message(&make_agent_message(Role::User, "one"));
1678 sm.append_message(&make_agent_message(Role::Assistant, "response one"));
1679 let _m2 = sm.append_message(&make_agent_message(Role::User, "two"));
1680 sm.append_message(&make_agent_message(Role::Assistant, "response two"));
1681
1682 assert_eq!(sm.entries().len(), 4);
1684
1685 sm.set_branch(&m1).unwrap();
1687
1688 sm.append_message(&make_agent_message(Role::Assistant, "alternate response"));
1690
1691 assert_eq!(sm.entries().len(), 5);
1693
1694 let context = sm.build_session_context();
1696 assert_eq!(context.messages.len(), 2); }
1698
1699 #[test]
1700 fn test_session_reset_leaf() {
1701 let tmp = TempDir::new().unwrap();
1702 let sessions_dir = tmp.path().join("sessions");
1703 let cwd = tmp.path().join("project");
1704 std::fs::create_dir_all(&cwd).unwrap();
1705
1706 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1707 sm.append_message(&make_agent_message(Role::User, "one"));
1708 sm.append_message(&make_agent_message(Role::Assistant, "response"));
1709
1710 sm.reset_leaf();
1711 assert!(sm.leaf_id().is_none());
1712
1713 sm.append_message(&make_agent_message(Role::User, "fresh start"));
1715 assert_eq!(sm.entries().len(), 3);
1716 }
1717
1718 #[test]
1719 fn test_session_branch_summary() {
1720 let tmp = TempDir::new().unwrap();
1721 let sessions_dir = tmp.path().join("sessions");
1722 let cwd = tmp.path().join("project");
1723 std::fs::create_dir_all(&cwd).unwrap();
1724
1725 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1726 sm.append_message(&make_agent_message(Role::User, "one"));
1727 sm.append_message(&make_agent_message(Role::Assistant, "response"));
1728
1729 sm.append_branch_summary("root", "Abandoned path summary");
1730
1731 match &sm.entries()[2] {
1732 SessionEntry::BranchSummary(e) => {
1733 assert_eq!(e.summary, "Abandoned path summary");
1734 assert_eq!(e.from_id, "root");
1735 }
1736 _ => panic!("Expected BranchSummary"),
1737 }
1738 }
1739
1740 #[test]
1741 fn test_session_children() {
1742 let tmp = TempDir::new().unwrap();
1743 let sessions_dir = tmp.path().join("sessions");
1744 let cwd = tmp.path().join("project");
1745 std::fs::create_dir_all(&cwd).unwrap();
1746
1747 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1748 let m1 = sm.append_message(&make_agent_message(Role::User, "one"));
1749 sm.append_message(&make_agent_message(Role::Assistant, "response"));
1750
1751 let children = sm.children(&m1);
1753 assert_eq!(children.len(), 1);
1754 }
1755
1756 #[test]
1757 fn test_session_custom_entry() {
1758 let tmp = TempDir::new().unwrap();
1759 let sessions_dir = tmp.path().join("sessions");
1760 let cwd = tmp.path().join("project");
1761 std::fs::create_dir_all(&cwd).unwrap();
1762
1763 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1764 sm.append_message(&make_agent_message(Role::User, "one"));
1765 sm.append_message(&make_agent_message(Role::Assistant, "ok"));
1766 sm.append_custom_entry("my_ext", serde_json::json!({"key": "value"}));
1767
1768 match &sm.entries()[2] {
1769 SessionEntry::Custom(e) => {
1770 assert_eq!(e.custom_type, "my_ext");
1771 assert_eq!(e.data["key"], "value");
1772 }
1773 _ => panic!("Expected Custom"),
1774 }
1775 }
1776
1777 #[test]
1778 fn test_find_most_recent_session() {
1779 let tmp = TempDir::new().unwrap();
1780 let sessions_dir = tmp.path().join("sessions");
1781 let cwd = tmp.path().join("project");
1782 std::fs::create_dir_all(&sessions_dir).unwrap();
1783 std::fs::create_dir_all(&cwd).unwrap();
1784
1785 let mut sm1 = SessionManager::create(&cwd, Some(&sessions_dir));
1787 sm1.append_message(&make_agent_message(Role::User, "old"));
1788 sm1.append_message(&make_agent_message(Role::Assistant, "old"));
1789 let _path1 = sm1.session_file().unwrap().to_path_buf();
1790 drop(sm1);
1791
1792 std::thread::sleep(std::time::Duration::from_millis(10));
1793
1794 let mut sm2 = SessionManager::create(&cwd, Some(&sessions_dir));
1796 sm2.append_message(&make_agent_message(Role::User, "new"));
1797 sm2.append_message(&make_agent_message(Role::Assistant, "new"));
1798 let path2 = sm2.session_file().unwrap().to_path_buf();
1799 drop(sm2);
1800
1801 let most_recent = find_most_recent_session(&sessions_dir, None).unwrap();
1802 assert_eq!(most_recent, path2);
1803 }
1804
1805 #[test]
1808 fn test_corrupt_empty_file_is_recovered() {
1809 let tmp = TempDir::new().unwrap();
1810 let sessions_dir = tmp.path().join("sessions");
1811 let cwd = tmp.path().join("project");
1812 std::fs::create_dir_all(&sessions_dir).unwrap();
1813 std::fs::create_dir_all(&cwd).unwrap();
1814
1815 let file_path = sessions_dir.join("empty.jsonl");
1817 std::fs::write(&file_path, "").unwrap();
1818
1819 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
1821 assert!(!sm.session_id().is_empty());
1822 assert!(sm.entries().is_empty());
1823 assert_eq!(sm.session_file().unwrap(), file_path);
1824 }
1825
1826 #[test]
1827 fn test_corrupt_garbage_file_is_recovered() {
1828 let tmp = TempDir::new().unwrap();
1829 let sessions_dir = tmp.path().join("sessions");
1830 let cwd = tmp.path().join("project");
1831 std::fs::create_dir_all(&sessions_dir).unwrap();
1832 std::fs::create_dir_all(&cwd).unwrap();
1833
1834 let file_path = sessions_dir.join("garbage.jsonl");
1836 std::fs::write(
1837 &file_path,
1838 "this is not json\nneither is this\n{half-json\n",
1839 )
1840 .unwrap();
1841
1842 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
1844 assert!(!sm.session_id().is_empty());
1845 assert!(sm.entries().is_empty());
1846 }
1847
1848 #[test]
1849 fn test_corrupt_header_only_file_is_kept() {
1850 let tmp = TempDir::new().unwrap();
1851 let sessions_dir = tmp.path().join("sessions");
1852 let cwd = tmp.path().join("project");
1853 std::fs::create_dir_all(&sessions_dir).unwrap();
1854 std::fs::create_dir_all(&cwd).unwrap();
1855
1856 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1858 sm.append_message(&make_agent_message(Role::User, "test"));
1859 sm.append_message(&make_agent_message(Role::Assistant, "ok"));
1860 let original_id = sm.session_id().to_string();
1861 let file_path = sm.session_file().unwrap().to_path_buf();
1862 drop(sm);
1863
1864 let content = std::fs::read_to_string(&file_path).unwrap();
1866 let header_line = content.lines().next().unwrap();
1867 std::fs::write(&file_path, format!("{}\n", header_line)).unwrap();
1868
1869 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
1871 assert_eq!(sm.session_id(), &original_id);
1872 assert!(sm.entries().is_empty());
1873 }
1874
1875 #[test]
1876 fn test_corrupt_malformed_lines_are_skipped() {
1877 let tmp = TempDir::new().unwrap();
1878 let sessions_dir = tmp.path().join("sessions");
1879 let cwd = tmp.path().join("project");
1880 std::fs::create_dir_all(&sessions_dir).unwrap();
1881 std::fs::create_dir_all(&cwd).unwrap();
1882
1883 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1885 sm.append_message(&make_agent_message(Role::User, "valid message"));
1886 sm.append_message(&make_agent_message(Role::Assistant, "valid response"));
1887 let file_path = sm.session_file().unwrap().to_path_buf();
1888 drop(sm);
1889
1890 let mut content = std::fs::read_to_string(&file_path).unwrap();
1892 content.push_str("this is garbage\n");
1893 content.push_str("{incomplete json\n");
1894 content.push('\n'); std::fs::write(&file_path, &content).unwrap();
1896
1897 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
1899 let ctx = sm.build_session_context();
1900 assert_eq!(ctx.messages.len(), 2);
1901 assert_eq!(ctx.messages[0].content, "valid message");
1902 assert_eq!(ctx.messages[1].content, "valid response");
1903 }
1904
1905 #[test]
1906 fn test_corrupt_missing_header_uses_new_id() {
1907 let tmp = TempDir::new().unwrap();
1908 let sessions_dir = tmp.path().join("sessions");
1909 let cwd = tmp.path().join("project");
1910 std::fs::create_dir_all(&sessions_dir).unwrap();
1911 std::fs::create_dir_all(&cwd).unwrap();
1912
1913 let entry = SessionEntry::Message(MessageEntry {
1915 id: "msg1".to_string(),
1916 parent_id: None,
1917 timestamp: "2026-01-01T00:00:00Z".to_string(),
1918 message: make_agent_message(Role::User, "orphan message"),
1919 });
1920 let json = serde_json::to_string(&entry).unwrap();
1921 let file_path = sessions_dir.join("no_header.jsonl");
1922 std::fs::write(&file_path, format!("{}\n", json)).unwrap();
1923
1924 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
1926 assert!(!sm.session_id().is_empty());
1927 assert_eq!(sm.entries().len(), 1);
1928 }
1929
1930 #[test]
1931 fn test_corrupt_file_then_append_works() {
1932 let tmp = TempDir::new().unwrap();
1933 let sessions_dir = tmp.path().join("sessions");
1934 let cwd = tmp.path().join("project");
1935 std::fs::create_dir_all(&sessions_dir).unwrap();
1936 std::fs::create_dir_all(&cwd).unwrap();
1937
1938 let file_path = sessions_dir.join("recovered.jsonl");
1940 std::fs::write(&file_path, "garbage\nmore garbage\n").unwrap();
1941
1942 let mut sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
1944 assert!(sm.entries().is_empty());
1945
1946 sm.append_message(&make_agent_message(Role::User, "fresh start"));
1948 sm.append_message(&make_agent_message(Role::Assistant, "fresh response"));
1949
1950 let ctx = sm.build_session_context();
1951 assert_eq!(ctx.messages.len(), 2);
1952 assert_eq!(ctx.messages[0].content, "fresh start");
1953
1954 let content = std::fs::read_to_string(&file_path).unwrap();
1956 assert!(content.contains("fresh start"));
1957 assert!(!content.contains("garbage"));
1958 }
1959
1960 #[test]
1961 fn test_corrupt_all_lines_malformed_is_empty() {
1962 let entries = load_entries_from_file(Path::new("/nonexistent/file.jsonl"));
1963 assert!(entries.is_empty());
1964 }
1965
1966 #[test]
1967 fn test_corrupt_malformed_line_returns_none() {
1968 let result = parse_session_entry_line("not valid json");
1969 assert!(result.is_none());
1970 }
1971
1972 #[test]
1973 fn test_corrupt_blank_lines_are_skipped() {
1974 let result = parse_session_entry_line("");
1975 assert!(result.is_none());
1976 let result = parse_session_entry_line(" ");
1977 assert!(result.is_none());
1978 }
1979
1980 #[test]
1981 fn test_corrupt_header_line_malformed_returns_none() {
1982 let result = read_session_header(Path::new("/nonexistent"));
1983 assert!(result.is_none());
1984 }
1985}