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 pub fn set_parent_id(&mut self, parent_id: Option<String>) {
163 match self {
164 SessionEntry::Message(m) => m.parent_id = parent_id,
165 SessionEntry::ThinkingLevelChange(m) => m.parent_id = parent_id,
166 SessionEntry::ModelChange(m) => m.parent_id = parent_id,
167 SessionEntry::ActiveToolsChange(m) => m.parent_id = parent_id,
168 SessionEntry::Compaction(m) => m.parent_id = parent_id,
169 SessionEntry::BranchSummary(m) => m.parent_id = parent_id,
170 SessionEntry::SessionInfo(m) => m.parent_id = parent_id,
171 SessionEntry::Label(m) => m.parent_id = parent_id,
172 SessionEntry::Custom(m) => m.parent_id = parent_id,
173 SessionEntry::CustomMessage(m) => m.parent_id = parent_id,
174 SessionEntry::Leaf(m) => m.parent_id = parent_id,
175 }
176 }
177}
178
179#[derive(Debug, Clone, Copy, Default)]
185pub struct MessageCost {
186 pub input: f64,
187 pub output: f64,
188 pub cache_read: f64,
189 pub cache_write: f64,
190 pub total: f64,
191}
192
193impl MessageCost {
194 pub const ZERO: Self = Self {
196 input: 0.0,
197 output: 0.0,
198 cache_read: 0.0,
199 cache_write: 0.0,
200 total: 0.0,
201 };
202
203 pub fn new(input: f64, output: f64, cache_read: f64, cache_write: f64) -> Self {
206 let total = input + output + cache_read + cache_write;
207 Self {
208 input,
209 output,
210 cache_read,
211 cache_write,
212 total,
213 }
214 }
215
216 pub fn total(&self) -> f64 {
218 self.total
219 }
220}
221
222impl Serialize for MessageCost {
224 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
225 use serde::ser::SerializeStruct;
226 let mut s = serializer.serialize_struct("MessageCost", 5)?;
227 s.serialize_field("input", &self.input)?;
228 s.serialize_field("output", &self.output)?;
229 s.serialize_field("cacheRead", &self.cache_read)?;
230 s.serialize_field("cacheWrite", &self.cache_write)?;
231 s.serialize_field("total", &self.total)?;
232 s.end()
233 }
234}
235
236impl<'de> Deserialize<'de> for MessageCost {
239 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
240 use serde::de;
241
242 struct MessageCostVisitor;
243
244 impl<'de> de::Visitor<'de> for MessageCostVisitor {
245 type Value = MessageCost;
246
247 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
248 formatter.write_str("a number (old format) or an object with cost breakdown")
249 }
250
251 fn visit_f64<E: de::Error>(self, value: f64) -> Result<MessageCost, E> {
252 Ok(MessageCost {
253 total: value,
254 ..MessageCost::ZERO
255 })
256 }
257
258 fn visit_i64<E: de::Error>(self, value: i64) -> Result<MessageCost, E> {
259 self.visit_f64(value as f64)
260 }
261
262 fn visit_u64<E: de::Error>(self, value: u64) -> Result<MessageCost, E> {
263 self.visit_f64(value as f64)
264 }
265
266 fn visit_map<M: de::MapAccess<'de>>(self, mut map: M) -> Result<MessageCost, M::Error> {
267 let mut input = None;
268 let mut output = None;
269 let mut cache_read = None;
270 let mut cache_write = None;
271 let mut total = None;
272
273 while let Some(key) = map.next_key::<String>()? {
274 match key.as_str() {
275 "input" => input = Some(map.next_value()?),
276 "output" => output = Some(map.next_value()?),
277 "cacheRead" => cache_read = Some(map.next_value()?),
278 "cacheWrite" => cache_write = Some(map.next_value()?),
279 "total" => total = Some(map.next_value()?),
280 _ => {
281 let _: serde::de::IgnoredAny = map.next_value()?;
282 }
283 }
284 }
285
286 let input = input.unwrap_or(0.0);
287 let output = output.unwrap_or(0.0);
288 let cache_read = cache_read.unwrap_or(0.0);
289 let cache_write = cache_write.unwrap_or(0.0);
290 let total = total.unwrap_or(input + output + cache_read + cache_write);
291
292 Ok(MessageCost {
293 input,
294 output,
295 cache_read,
296 cache_write,
297 total,
298 })
299 }
300 }
301
302 deserializer.deserialize_any(MessageCostVisitor)
303 }
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
308#[serde(rename_all = "camelCase")]
309pub struct MessageEntry {
310 pub id: String,
311 #[serde(skip_serializing_if = "Option::is_none")]
312 pub parent_id: Option<String>,
313 pub timestamp: String,
314 pub message: AgentMessage,
315 #[serde(default)]
319 pub cost: MessageCost,
320}
321
322impl MessageEntry {
323 pub fn new(
325 id: String,
326 parent_id: Option<String>,
327 timestamp: String,
328 message: AgentMessage,
329 cost: MessageCost,
330 ) -> Self {
331 Self {
332 id,
333 parent_id,
334 timestamp,
335 message,
336 cost,
337 }
338 }
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
342#[serde(rename_all = "camelCase")]
343pub struct ThinkingLevelChangeEntry {
344 pub id: String,
345 #[serde(skip_serializing_if = "Option::is_none")]
346 pub parent_id: Option<String>,
347 pub timestamp: String,
348 pub thinking_level: String,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
352#[serde(rename_all = "camelCase")]
353pub struct ModelChangeEntry {
354 pub id: String,
355 #[serde(skip_serializing_if = "Option::is_none")]
356 pub parent_id: Option<String>,
357 pub timestamp: String,
358 pub provider: String,
359 pub model_id: String,
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
363#[serde(rename_all = "camelCase")]
364pub struct ActiveToolsChangeEntry {
365 pub id: String,
366 #[serde(skip_serializing_if = "Option::is_none")]
367 pub parent_id: Option<String>,
368 pub timestamp: String,
369 pub active_tool_names: Vec<String>,
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize)]
373#[serde(rename_all = "camelCase")]
374pub struct CompactionEntry {
375 pub id: String,
376 #[serde(skip_serializing_if = "Option::is_none")]
377 pub parent_id: Option<String>,
378 pub timestamp: String,
379 pub summary: String,
380 pub first_kept_entry_id: String,
381 pub tokens_before: u64,
382 #[serde(skip_serializing_if = "Option::is_none")]
383 pub details: Option<serde_json::Value>,
384 #[serde(skip_serializing_if = "Option::is_none")]
385 pub from_hook: Option<bool>,
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize)]
389#[serde(rename_all = "camelCase")]
390pub struct BranchSummaryEntry {
391 pub id: String,
392 #[serde(skip_serializing_if = "Option::is_none")]
393 pub parent_id: Option<String>,
394 pub timestamp: String,
395 pub from_id: String,
396 pub summary: String,
397 #[serde(skip_serializing_if = "Option::is_none")]
398 pub details: Option<serde_json::Value>,
399 #[serde(skip_serializing_if = "Option::is_none")]
400 pub from_hook: Option<bool>,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
404#[serde(rename_all = "camelCase")]
405pub struct SessionInfoEntry {
406 pub id: String,
407 #[serde(skip_serializing_if = "Option::is_none")]
408 pub parent_id: Option<String>,
409 pub timestamp: String,
410 pub name: String,
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize)]
414#[serde(rename_all = "camelCase")]
415pub struct LabelEntry {
416 pub id: String,
417 #[serde(skip_serializing_if = "Option::is_none")]
418 pub parent_id: Option<String>,
419 pub timestamp: String,
420 pub target_id: String,
421 #[serde(skip_serializing_if = "Option::is_none")]
422 pub label: Option<String>,
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize)]
426#[serde(rename_all = "camelCase")]
427pub struct CustomEntry {
428 pub id: String,
429 #[serde(skip_serializing_if = "Option::is_none")]
430 pub parent_id: Option<String>,
431 pub timestamp: String,
432 pub custom_type: String,
433 pub data: serde_json::Value,
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize)]
437#[serde(rename_all = "camelCase")]
438pub struct CustomMessageEntry {
439 pub id: String,
440 #[serde(skip_serializing_if = "Option::is_none")]
441 pub parent_id: Option<String>,
442 pub timestamp: String,
443 pub custom_type: String,
444 pub content: serde_json::Value,
445 #[serde(default)]
446 pub display: bool,
447 #[serde(skip_serializing_if = "Option::is_none")]
448 pub details: Option<serde_json::Value>,
449}
450
451#[derive(Debug, Clone, Serialize, Deserialize)]
452#[serde(rename_all = "camelCase")]
453pub struct LeafEntry {
454 pub id: String,
455 #[serde(skip_serializing_if = "Option::is_none")]
456 pub parent_id: Option<String>,
457 pub timestamp: String,
458 #[serde(skip_serializing_if = "Option::is_none")]
459 pub target_id: Option<String>,
460}
461
462#[derive(Debug, Clone)]
466pub struct SessionInfo {
467 pub path: PathBuf,
468 pub id: String,
469 pub cwd: String,
470 pub name: Option<String>,
471 pub parent_session_path: Option<String>,
472 pub created: DateTime<Utc>,
473 pub modified: DateTime<Utc>,
474 pub message_count: usize,
475 pub first_message: String,
476 pub all_messages_text: String,
478}
479
480#[derive(Debug, Clone)]
484pub struct SessionTreeNode {
485 pub entry: SessionEntry,
486 pub children: Vec<SessionTreeNode>,
487 pub label: Option<String>,
488 pub label_timestamp: Option<String>,
489}
490
491#[derive(Debug, Clone, Default)]
495pub struct NewSessionOptions {
496 pub id: Option<String>,
497 pub parent_session: Option<String>,
498}
499
500#[derive(Debug, Clone)]
505pub struct SessionContext {
506 pub messages: Vec<AgentMessage>,
507 pub thinking_level: String,
508 pub model: Option<(String, String)>,
509 pub active_tool_names: Option<Vec<String>>,
510}
511
512pub fn parse_session_entry_line(line: &str) -> Option<SessionEntry> {
516 let line = line.trim();
517 if line.is_empty() {
518 return None;
519 }
520 serde_json::from_str(line).ok()
521}
522
523pub fn parse_session_header_line(line: &str) -> Option<SessionHeader> {
527 let line = line.trim();
528 if line.is_empty() {
529 return None;
530 }
531 let header: SessionHeader = serde_json::from_str(line).ok()?;
532 if header.type_ != "session" {
533 return None;
534 }
535 if header.version != Some(CURRENT_SESSION_VERSION) {
537 return None;
538 }
539 if header.id.is_empty() || header.timestamp.is_empty() || header.cwd.is_empty() {
541 return None;
542 }
543 Some(header)
545}
546
547pub fn read_session_header(path: &Path) -> Option<SessionHeader> {
549 let content = fs::read_to_string(path).ok()?;
550 let first_line = content.lines().next()?;
551 parse_session_header_line(first_line)
552}
553
554const SESSION_READ_BUFFER_SIZE: usize = 1024 * 1024; pub fn load_session_from_file(path: &Path) -> (Option<SessionHeader>, Vec<SessionEntry>) {
566 let file = match std::fs::File::open(path) {
567 Ok(f) => f,
568 Err(_) => return (None, vec![]),
569 };
570
571 use std::io::Read;
572 let mut reader = std::io::BufReader::with_capacity(SESSION_READ_BUFFER_SIZE, file);
573 let mut content = String::new();
574 if reader.read_to_string(&mut content).is_err() {
575 return (None, vec![]);
576 }
577
578 let mut header: Option<SessionHeader> = None;
579 let mut entries: Vec<SessionEntry> = Vec::new();
580
581 for (i, line_str) in content.lines().enumerate() {
582 let line = line_str.trim();
583 if line.is_empty() {
584 continue;
585 }
586
587 if i == 0 {
588 header = parse_session_header_line(line);
590 if header.is_none() {
591 return (None, vec![]);
593 }
594 continue;
595 }
596
597 if let Some(entry) = parse_session_entry_line(line) {
598 entries.push(entry);
599 }
600 }
602
603 (header, entries)
604}
605
606pub fn load_entries_from_file(path: &Path) -> Vec<SessionEntry> {
608 load_session_from_file(path).1
609}
610
611pub fn write_entries_to_file(
614 path: &Path,
615 header: &SessionHeader,
616 entries: &[SessionEntry],
617) -> std::io::Result<()> {
618 if let Some(parent) = path.parent() {
619 fs::create_dir_all(parent)?;
620 }
621 let mut content = serde_json::to_string(header).map_err(std::io::Error::from)?;
622 content.push('\n');
623 for entry in entries {
624 let line = serde_json::to_string(entry).map_err(std::io::Error::from)?;
625 content.push_str(&line);
626 content.push('\n');
627 }
628 fs::write(path, &content)
629}
630
631pub fn append_entry_to_file(path: &Path, entry: &SessionEntry) -> std::io::Result<()> {
633 let line = serde_json::to_string(entry).map_err(std::io::Error::from)?;
634 let content = format!("{}\n", line);
635 std::fs::OpenOptions::new()
636 .create(true)
637 .append(true)
638 .open(path)?
639 .write_all(content.as_bytes())
640}
641
642pub fn encode_cwd_for_dir(cwd: &Path) -> String {
647 let s = cwd.to_string_lossy();
648 let cleaned = s
649 .trim_start_matches('/')
650 .trim_start_matches('\\')
651 .replace(['/', '\\', ':'], "-");
652 format!("--{}--", cleaned)
653}
654
655pub fn get_default_session_dir(cwd: &Path) -> PathBuf {
657 let rab_dir = directories::BaseDirs::new()
658 .expect("Could not determine home directory")
659 .home_dir()
660 .join(".rab");
661 rab_dir.join("sessions").join(encode_cwd_for_dir(cwd))
662}
663
664pub fn generate_entry_id(by_id: &HashMap<String, SessionEntry>) -> String {
666 for _ in 0..100 {
667 let id = uuid::Uuid::new_v4().to_string()[..8].to_string();
668 if !by_id.contains_key(&id) {
669 return id;
670 }
671 }
672 uuid::Uuid::new_v4().to_string()
674}
675
676use super::storage::SessionMetadata;
679
680pub struct Session {
686 storage: Box<dyn SessionStorage>,
687}
688
689impl Session {
690 pub fn new(storage: Box<dyn SessionStorage>) -> Self {
692 Self { storage }
693 }
694
695 pub fn get_storage(&self) -> &dyn SessionStorage {
697 self.storage.as_ref()
698 }
699
700 pub fn get_storage_mut(&mut self) -> &mut dyn SessionStorage {
702 self.storage.as_mut()
703 }
704
705 pub fn into_storage(self) -> Box<dyn SessionStorage> {
707 self.storage
708 }
709
710 pub fn metadata(&self) -> SessionMetadata {
713 self.storage.metadata()
714 }
715
716 pub fn get_leaf_id(&self) -> Option<String> {
717 self.storage.get_leaf_id()
718 }
719
720 pub fn get_entry(&self, id: &str) -> Option<SessionEntry> {
721 self.storage.get_entry(id)
722 }
723
724 pub fn get_entries(&self) -> Vec<SessionEntry> {
725 self.storage.get_entries()
726 }
727
728 pub fn find_entries(&self, type_name: &str) -> Vec<SessionEntry> {
729 self.storage.find_entries(type_name)
730 }
731
732 pub fn get_label(&self, id: &str) -> Option<String> {
733 self.storage.get_label(id)
734 }
735
736 pub fn get_label_timestamp(&self, id: &str) -> Option<String> {
739 self.storage.get_label_timestamp(id)
740 }
741
742 pub fn get_branch(&self, from_id: Option<&str>) -> Result<Vec<SessionEntry>, String> {
745 self.storage.get_path_to_root(from_id)
746 }
747
748 pub fn build_context(&self) -> SessionContext {
751 let path = self.get_branch(None).unwrap_or_default();
752 build_session_context(&path)
753 }
754
755 pub fn build_session_context(&self) -> SessionContext {
757 self.build_context()
758 }
759
760 pub fn session_id(&self) -> String {
762 self.metadata().id
763 }
764
765 pub fn session_file(&self) -> Option<PathBuf> {
767 self.metadata().path
768 }
769
770 pub fn session_name(&self) -> Option<String> {
772 self.get_session_name()
773 }
774
775 pub fn get_session_name(&self) -> Option<String> {
777 let entries = self.find_entries("session_info");
778 let last = entries.last()?;
779 if let SessionEntry::SessionInfo(e) = last {
780 let name = e.name.trim();
781 if name.is_empty() {
782 None
783 } else {
784 Some(name.to_string())
785 }
786 } else {
787 None
788 }
789 }
790
791 fn append_entry(&mut self, entry: SessionEntry, kind: &str) -> String {
795 let id = entry.id().to_string();
796 self.storage.append_entry(entry).unwrap_or_else(|e| {
797 eprintln!("Warning: failed to append {}: {}", kind, e);
798 });
799 id
800 }
801
802 pub fn append_message(&mut self, message: &yoagent::types::AgentMessage) -> String {
804 self.append_message_with_cost(message, MessageCost::ZERO)
805 }
806
807 pub fn append_message_with_cost(
810 &mut self,
811 message: &yoagent::types::AgentMessage,
812 cost: MessageCost,
813 ) -> String {
814 let entry = SessionEntry::Message(MessageEntry::new(
815 self.storage.create_entry_id(),
816 self.storage.get_leaf_id(),
817 chrono::Utc::now().to_rfc3339(),
818 message.clone(),
819 cost,
820 ));
821 self.append_entry(entry, "message")
822 }
823
824 pub fn append_thinking_level_change(&mut self, thinking_level: &str) -> String {
826 let entry = SessionEntry::ThinkingLevelChange(ThinkingLevelChangeEntry {
827 id: self.storage.create_entry_id(),
828 parent_id: self.storage.get_leaf_id(),
829 timestamp: chrono::Utc::now().to_rfc3339(),
830 thinking_level: thinking_level.to_string(),
831 });
832 self.append_entry(entry, "thinking level change")
833 }
834
835 pub fn append_model_change(&mut self, provider: &str, model_id: &str) -> String {
837 let entry = SessionEntry::ModelChange(ModelChangeEntry {
838 id: self.storage.create_entry_id(),
839 parent_id: self.storage.get_leaf_id(),
840 timestamp: chrono::Utc::now().to_rfc3339(),
841 provider: provider.to_string(),
842 model_id: model_id.to_string(),
843 });
844 self.append_entry(entry, "model change")
845 }
846
847 pub fn append_active_tools_change(&mut self, active_tool_names: &[String]) -> String {
849 let entry = SessionEntry::ActiveToolsChange(ActiveToolsChangeEntry {
850 id: self.storage.create_entry_id(),
851 parent_id: self.storage.get_leaf_id(),
852 timestamp: chrono::Utc::now().to_rfc3339(),
853 active_tool_names: active_tool_names.to_vec(),
854 });
855 self.append_entry(entry, "active tools change")
856 }
857
858 pub fn append_compaction(
860 &mut self,
861 summary: &str,
862 first_kept_entry_id: &str,
863 tokens_before: u64,
864 details: Option<serde_json::Value>,
865 from_hook: Option<bool>,
866 ) -> String {
867 let entry = SessionEntry::Compaction(CompactionEntry {
868 id: self.storage.create_entry_id(),
869 parent_id: self.storage.get_leaf_id(),
870 timestamp: chrono::Utc::now().to_rfc3339(),
871 summary: summary.to_string(),
872 first_kept_entry_id: first_kept_entry_id.to_string(),
873 tokens_before,
874 details,
875 from_hook,
876 });
877 self.append_entry(entry, "compaction")
878 }
879
880 pub fn append_session_info(&mut self, name: &str) -> String {
883 let sanitized = name.replace(['\r', '\n'], " ").trim().to_string();
884 let entry = SessionEntry::SessionInfo(SessionInfoEntry {
885 id: self.storage.create_entry_id(),
886 parent_id: self.storage.get_leaf_id(),
887 timestamp: chrono::Utc::now().to_rfc3339(),
888 name: sanitized,
889 });
890 self.append_entry(entry, "session info")
891 }
892
893 pub fn append_branch_summary(
895 &mut self,
896 from_id: &str,
897 summary: &str,
898 details: Option<serde_json::Value>,
899 from_hook: Option<bool>,
900 ) -> String {
901 let entry = SessionEntry::BranchSummary(BranchSummaryEntry {
902 id: self.storage.create_entry_id(),
903 parent_id: self.storage.get_leaf_id(),
904 timestamp: chrono::Utc::now().to_rfc3339(),
905 from_id: from_id.to_string(),
906 summary: summary.to_string(),
907 details,
908 from_hook,
909 });
910 self.append_entry(entry, "branch summary")
911 }
912
913 pub fn append_label_change(
916 &mut self,
917 target_id: &str,
918 label: Option<&str>,
919 ) -> Result<String, SessionError> {
920 if self.storage.get_entry(target_id).is_none() {
921 return Err(SessionError::NotFound(format!(
922 "Entry {} not found",
923 target_id
924 )));
925 }
926 let entry = SessionEntry::Label(LabelEntry {
927 id: self.storage.create_entry_id(),
928 parent_id: self.storage.get_leaf_id(),
929 timestamp: chrono::Utc::now().to_rfc3339(),
930 target_id: target_id.to_string(),
931 label: label.map(|s| s.to_string()),
932 });
933 let id = entry.id().to_string();
934 self.storage
935 .append_entry(entry)
936 .map_err(SessionError::Storage)?;
937 Ok(id)
938 }
939
940 pub fn append_custom_entry(&mut self, custom_type: &str, data: serde_json::Value) -> String {
942 let entry = SessionEntry::Custom(CustomEntry {
943 id: self.storage.create_entry_id(),
944 parent_id: self.storage.get_leaf_id(),
945 timestamp: chrono::Utc::now().to_rfc3339(),
946 custom_type: custom_type.to_string(),
947 data,
948 });
949 self.append_entry(entry, "custom entry")
950 }
951
952 pub fn append_custom_message_entry(
954 &mut self,
955 custom_type: &str,
956 content: serde_json::Value,
957 display: bool,
958 details: Option<serde_json::Value>,
959 ) -> String {
960 let entry = SessionEntry::CustomMessage(CustomMessageEntry {
961 id: self.storage.create_entry_id(),
962 parent_id: self.storage.get_leaf_id(),
963 timestamp: chrono::Utc::now().to_rfc3339(),
964 custom_type: custom_type.to_string(),
965 content,
966 display,
967 details,
968 });
969 self.append_entry(entry, "custom message")
970 }
971
972 pub fn move_to(
978 &mut self,
979 entry_id: Option<&str>,
980 summary: Option<(String, Option<serde_json::Value>, Option<bool>)>,
981 ) -> Result<Option<String>, String> {
982 if let Some(ref id) = entry_id
984 && self.get_entry(id).is_none()
985 {
986 return Err(format!("Entry {} not found", id));
987 }
988 self.storage.set_leaf_id(entry_id)?;
990
991 if let Some((summary_text, details, from_hook)) = summary {
993 let entry = SessionEntry::BranchSummary(BranchSummaryEntry {
994 id: self.storage.create_entry_id(),
995 parent_id: entry_id.map(|s| s.to_string()),
996 timestamp: chrono::Utc::now().to_rfc3339(),
997 from_id: entry_id.unwrap_or("root").to_string(),
998 summary: summary_text,
999 details,
1000 from_hook,
1001 });
1002 Ok(Some(self.append_entry(entry, "branch summary")))
1003 } else {
1004 Ok(None)
1005 }
1006 }
1007
1008 pub fn set_leaf_id(&mut self, leaf_id: Option<&str>) -> Result<(), String> {
1011 self.storage.set_leaf_id(leaf_id)
1012 }
1013
1014 pub fn reset_leaf(&mut self) -> Result<(), String> {
1016 self.storage.set_leaf_id(None)
1017 }
1018}
1019
1020pub fn build_session_context(path: &[SessionEntry]) -> SessionContext {
1025 let mut thinking_level = "off".to_string();
1026 let mut model: Option<(String, String)> = None;
1027 let mut active_tool_names: Option<Vec<String>> = None;
1028 let mut compaction_entry: Option<&CompactionEntry> = None;
1029
1030 for entry in path {
1031 match entry {
1032 SessionEntry::ThinkingLevelChange(e) => {
1033 thinking_level = e.thinking_level.clone();
1034 }
1035 SessionEntry::ModelChange(e) => {
1036 model = Some((e.provider.clone(), e.model_id.clone()));
1037 }
1038 SessionEntry::ActiveToolsChange(e) => {
1039 active_tool_names = Some(e.active_tool_names.clone());
1040 }
1041 SessionEntry::Compaction(e) => {
1042 compaction_entry = Some(e);
1043 }
1044 _ => {}
1045 }
1046 }
1047
1048 if model.is_none() {
1050 for entry in path {
1051 if let SessionEntry::Message(e) = entry
1052 && let yoagent::types::AgentMessage::Llm(yoagent::types::Message::Assistant {
1053 model: ref m,
1054 provider: ref p,
1055 ..
1056 }) = e.message
1057 && !m.is_empty()
1058 && !p.is_empty()
1059 {
1060 model = Some((p.clone(), m.clone()));
1061 break;
1062 }
1063 }
1064 }
1065
1066 let messages = if let Some(compaction) = compaction_entry {
1067 let mut msgs: Vec<yoagent::types::AgentMessage> = Vec::new();
1068
1069 let comp_text = format!(
1071 "The conversation history before this point was compacted into the following summary:\n\n<summary>\n{}\n</summary>",
1072 compaction.summary
1073 );
1074 msgs.push(yoagent::types::AgentMessage::Llm(
1075 yoagent::types::Message::User {
1076 content: vec![yoagent::types::Content::Text { text: comp_text }],
1077 timestamp: chrono::Utc::now().timestamp_millis() as u64,
1078 },
1079 ));
1080
1081 let compaction_idx = path
1083 .iter()
1084 .position(|e| matches!(e, SessionEntry::Compaction(ce) if ce.id == compaction.id));
1085
1086 if let Some(cidx) = compaction_idx {
1087 let mut found_first_kept = false;
1089 for entry in path.iter().take(cidx) {
1090 if entry.id() == compaction.first_kept_entry_id {
1091 found_first_kept = true;
1092 }
1093 if found_first_kept {
1094 append_entry_to_message_list(entry, &mut msgs);
1095 }
1096 }
1097
1098 for entry in path.iter().skip(cidx + 1) {
1100 append_entry_to_message_list(entry, &mut msgs);
1101 }
1102 } else {
1103 for entry in path {
1105 append_entry_to_message_list(entry, &mut msgs);
1106 }
1107 }
1108
1109 msgs
1110 } else {
1111 let mut msgs: Vec<yoagent::types::AgentMessage> = Vec::new();
1113 for entry in path {
1114 append_entry_to_message_list(entry, &mut msgs);
1115 }
1116 msgs
1117 };
1118
1119 SessionContext {
1120 messages,
1121 thinking_level,
1122 model,
1123 active_tool_names,
1124 }
1125}
1126
1127fn append_entry_to_message_list(
1132 entry: &SessionEntry,
1133 msgs: &mut Vec<yoagent::types::AgentMessage>,
1134) {
1135 match entry {
1136 SessionEntry::Message(e) => {
1137 if crate::agent::types::message_error(&e.message).is_some() {
1139 return;
1140 }
1141 msgs.push(e.message.clone());
1142 }
1143 SessionEntry::CustomMessage(e) => {
1144 msgs.push(yoagent::types::AgentMessage::Extension(
1145 yoagent::types::ExtensionMessage::new(
1146 &e.custom_type,
1147 serde_json::json!({ "text": e.content.get("text").and_then(|v| v.as_str()).unwrap_or(""), "display": e.display }),
1148 ),
1149 ));
1150 }
1151 SessionEntry::BranchSummary(e) if !e.summary.is_empty() => {
1152 let bs_text = format!(
1154 "The following is a summary of a branch that this conversation came back from:\n\n<summary>\n{}\n</summary>",
1155 e.summary
1156 );
1157 msgs.push(yoagent::types::AgentMessage::Llm(
1158 yoagent::types::Message::User {
1159 content: vec![yoagent::types::Content::Text { text: bs_text }],
1160 timestamp: chrono::Utc::now().timestamp_millis() as u64,
1161 },
1162 ));
1163 }
1164 _ => {}
1165 }
1166}
1167
1168pub struct SessionManager {
1176 session: Session,
1178 session_dir: PathBuf,
1180 cwd: PathBuf,
1182 persist: bool,
1184 flushed: bool,
1186}
1187
1188impl SessionManager {
1189 pub fn with_session(
1193 session: Session,
1194 session_dir: PathBuf,
1195 cwd: PathBuf,
1196 persist: bool,
1197 ) -> Self {
1198 Self {
1199 session,
1200 session_dir,
1201 cwd,
1202 persist,
1203 flushed: false,
1204 }
1205 }
1206
1207 fn create_persisted(
1210 cwd: &Path,
1211 session_dir: &Path,
1212 options: Option<&NewSessionOptions>,
1213 ) -> Self {
1214 let id = options
1215 .and_then(|o| o.id.as_deref())
1216 .map(|s| s.to_string())
1217 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1218 let created_at = chrono::Utc::now().to_rfc3339();
1219
1220 let meta = super::storage::SessionMetadata {
1222 id: id.clone(),
1223 created_at: created_at.clone(),
1224 cwd: cwd.to_string_lossy().to_string(),
1225 path: None, parent_session_path: options.and_then(|o| o.parent_session.clone()),
1227 };
1228 let storage = InMemorySessionStorage::new(meta);
1229 let session = Session::new(Box::new(storage));
1230 Self::with_session(session, session_dir.to_path_buf(), cwd.to_path_buf(), true)
1231 }
1232
1233 fn open_session(path: &Path, session_dir: &Path, cwd_override: Option<&Path>) -> Self {
1235 let cwd = cwd_override
1236 .map(|p| p.to_path_buf())
1237 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")));
1238
1239 let storage: Box<dyn SessionStorage> = match JsonlSessionStorage::open(path.to_path_buf()) {
1240 Ok(s) => Box::new(s),
1241 Err(e) => {
1242 eprintln!("Warning: failed to open session: {}, creating new", e);
1243 let id = uuid::Uuid::new_v4().to_string();
1245 match JsonlSessionStorage::create(
1246 path.to_path_buf(),
1247 &cwd.to_string_lossy(),
1248 &id,
1249 None,
1250 ) {
1251 Ok(s) => Box::new(s),
1252 Err(e2) => {
1253 eprintln!("Warning: failed to create session file: {}", e2);
1254 Box::new(InMemorySessionStorage::new(
1255 super::storage::SessionMetadata {
1256 id,
1257 created_at: chrono::Utc::now().to_rfc3339(),
1258 cwd: cwd.to_string_lossy().to_string(),
1259 path: Some(path.to_path_buf()),
1260 parent_session_path: None,
1261 },
1262 ))
1263 }
1264 }
1265 }
1266 };
1267 let cwd = cwd_override
1268 .map(|p| p.to_path_buf())
1269 .unwrap_or_else(|| PathBuf::from(storage.metadata().cwd));
1270 let session = Session::new(storage);
1271 let mut sm = Self::with_session(session, session_dir.to_path_buf(), cwd, true);
1272 sm.flushed = true;
1274 sm
1275 }
1276
1277 fn create_in_memory(cwd: &Path, session_dir: &Path) -> Self {
1279 let meta = super::storage::SessionMetadata {
1280 id: uuid::Uuid::new_v4().to_string(),
1281 created_at: chrono::Utc::now().to_rfc3339(),
1282 cwd: cwd.to_string_lossy().to_string(),
1283 path: None,
1284 parent_session_path: None,
1285 };
1286 let storage = InMemorySessionStorage::new(meta);
1287 let session = Session::new(Box::new(storage));
1288 Self::with_session(session, session_dir.to_path_buf(), cwd.to_path_buf(), false)
1289 }
1290
1291 pub fn new_session(&mut self, options: Option<&NewSessionOptions>) {
1294 let id = options
1295 .and_then(|o| o.id.as_deref())
1296 .map(|s| s.to_string())
1297 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1298 let created_at = chrono::Utc::now().to_rfc3339();
1299
1300 let meta = super::storage::SessionMetadata {
1303 id,
1304 created_at,
1305 cwd: self.cwd.to_string_lossy().to_string(),
1306 path: None,
1307 parent_session_path: options.and_then(|o| o.parent_session.clone()),
1308 };
1309 let storage = InMemorySessionStorage::new(meta);
1310 self.session = Session::new(Box::new(storage));
1311 self.flushed = false;
1312 }
1313
1314 pub fn ensure_flushed(&mut self) {
1318 if self.flushed || !self.persist {
1319 return;
1320 }
1321
1322 let id = self.session.metadata().id;
1323 let created_at = self.session.metadata().created_at.clone();
1324 let cwd_str = self.cwd.to_string_lossy().to_string();
1325 let parent_session = self.session.metadata().parent_session_path.clone();
1326 let file_ts = created_at.replace([':', '.'], "-");
1327 let file_path = self.session_dir.join(format!("{}_{}.jsonl", file_ts, id));
1328
1329 let existing_entries = self.session.get_entries();
1331
1332 match JsonlSessionStorage::create(file_path.clone(), &cwd_str, &id, parent_session) {
1334 Ok(mut file_storage) => {
1335 for entry in &existing_entries {
1337 if let Err(e) = file_storage.append_entry(entry.clone()) {
1338 eprintln!("Warning: failed to write entry to session file: {}", e);
1339 }
1340 }
1341 self.session = Session::new(Box::new(file_storage));
1342 self.flushed = true;
1343 }
1344 Err(e) => {
1345 eprintln!("Warning: failed to create session file: {}", e);
1346 self.flushed = true;
1348 }
1349 }
1350 }
1351
1352 pub fn is_persisted(&self) -> bool {
1355 self.persist
1356 }
1357
1358 pub fn cwd(&self) -> &Path {
1359 &self.cwd
1360 }
1361
1362 pub fn session_dir(&self) -> &Path {
1363 &self.session_dir
1364 }
1365
1366 pub fn uses_default_session_dir(&self) -> bool {
1368 self.session_dir == get_default_session_dir(&self.cwd)
1369 }
1370
1371 pub fn session(&self) -> &Session {
1374 &self.session
1375 }
1376
1377 pub fn session_mut(&mut self) -> &mut Session {
1379 &mut self.session
1380 }
1381
1382 pub fn into_session(self) -> Session {
1384 self.session
1385 }
1386
1387 pub fn get_leaf_entry(&self) -> Option<SessionEntry> {
1391 self.session
1392 .get_leaf_id()
1393 .as_ref()
1394 .and_then(|id| self.session.get_entry(id.as_str()))
1395 }
1396
1397 pub fn get_children(&self, parent_id: &str) -> Vec<SessionEntry> {
1399 self.session
1400 .get_entries()
1401 .iter()
1402 .filter(|e| e.parent_id() == Some(parent_id))
1403 .cloned()
1404 .collect()
1405 }
1406
1407 pub fn get_header(&self) -> Option<SessionHeader> {
1409 let meta = self.session.metadata();
1412 Some(SessionHeader {
1413 type_: "session".to_string(),
1414 version: Some(CURRENT_SESSION_VERSION),
1415 id: meta.id,
1416 timestamp: meta.created_at,
1417 cwd: meta.cwd,
1418 parent_session: meta.parent_session_path,
1419 })
1420 }
1421
1422 pub fn get_tree(&self) -> Vec<SessionTreeNode> {
1424 let entries = self.session.get_entries();
1425 let mut node_map: HashMap<String, SessionTreeNode> = HashMap::new();
1426
1427 for entry in &entries {
1428 let label = self.session.get_label(entry.id());
1429 let label_timestamp = self.session.get_label_timestamp(entry.id());
1430 node_map.insert(
1431 entry.id().to_string(),
1432 SessionTreeNode {
1433 entry: entry.clone(),
1434 children: Vec::new(),
1435 label,
1436 label_timestamp,
1437 },
1438 );
1439 }
1440
1441 let child_edges: Vec<(Option<String>, String)> = entries
1442 .iter()
1443 .map(|e| (e.parent_id().map(|s| s.to_string()), e.id().to_string()))
1444 .collect();
1445
1446 let mut child_additions: Vec<(String, SessionTreeNode)> = Vec::new();
1447 let mut roots: Vec<String> = Vec::new();
1448 for (parent_id, child_id) in &child_edges {
1449 if let Some(pid) = parent_id {
1450 if !node_map.contains_key(pid) {
1451 roots.push(child_id.clone());
1452 } else if let Some(child) = node_map.get(child_id) {
1453 child_additions.push((pid.clone(), child.clone()));
1454 }
1455 } else {
1456 roots.push(child_id.clone());
1457 }
1458 }
1459 for (pid, child) in child_additions {
1460 if let Some(parent) = node_map.get_mut(&pid) {
1461 parent.children.push(child);
1462 }
1463 }
1464
1465 fn sort_tree(node: &mut SessionTreeNode) {
1466 node.children
1467 .sort_by_key(|c| c.entry.timestamp().to_string());
1468 for child in &mut node.children {
1469 sort_tree(child);
1470 }
1471 }
1472
1473 let mut result: Vec<SessionTreeNode> =
1474 roots.iter().filter_map(|id| node_map.remove(id)).collect();
1475 for root in &mut result {
1476 sort_tree(root);
1477 }
1478
1479 result
1480 }
1481
1482 pub fn get_entries(&self) -> Vec<SessionEntry> {
1484 self.session.get_entries()
1485 }
1486
1487 fn has_assistant_message(&self) -> bool {
1491 self.session.get_entries().iter().any(|e| {
1492 matches!(
1493 e,
1494 SessionEntry::Message(m) if matches!(&m.message, yoagent::types::AgentMessage::Llm(yoagent::types::Message::Assistant { .. }))
1495 )
1496 })
1497 }
1498
1499 fn ensure_flushed_on_assistant(&mut self, message: &yoagent::types::AgentMessage) {
1501 if !self.flushed && self.persist {
1502 let is_assistant = matches!(
1503 message,
1504 yoagent::types::AgentMessage::Llm(yoagent::types::Message::Assistant { .. })
1505 );
1506 if is_assistant || self.has_assistant_message() {
1507 self.ensure_flushed();
1508 }
1509 }
1510 }
1511
1512 pub fn append_message(&mut self, message: &yoagent::types::AgentMessage) -> String {
1513 self.ensure_flushed_on_assistant(message);
1514 self.session.append_message(message)
1515 }
1516
1517 pub fn append_message_with_cost(
1520 &mut self,
1521 message: &yoagent::types::AgentMessage,
1522 cost: MessageCost,
1523 ) -> String {
1524 self.ensure_flushed_on_assistant(message);
1525 self.session.append_message_with_cost(message, cost)
1526 }
1527
1528 pub fn set_branch(&mut self, branch_from_id: &str) -> Result<(), String> {
1533 self.session.set_leaf_id(Some(branch_from_id))
1534 }
1535
1536 pub fn reset_leaf(&mut self) {
1538 let _ = self.session.reset_leaf();
1539 }
1540
1541 pub fn branch_with_summary(
1544 &mut self,
1545 branch_from_id: Option<&str>,
1546 summary: &str,
1547 details: Option<serde_json::Value>,
1548 from_hook: Option<bool>,
1549 ) -> Result<String, String> {
1550 let summary_tuple = Some((summary.to_string(), details, from_hook));
1551 self.session
1552 .move_to(branch_from_id, summary_tuple)
1553 .map(|opt| opt.unwrap_or_default())
1554 }
1555
1556 pub fn create(cwd: &Path, session_dir: Option<&Path>) -> Self {
1560 let dir = session_dir
1561 .map(|p| p.to_path_buf())
1562 .unwrap_or_else(|| get_default_session_dir(cwd));
1563 Self::create_persisted(cwd, &dir, None)
1564 }
1565
1566 pub fn create_with_options(
1568 cwd: &Path,
1569 session_dir: Option<&Path>,
1570 options: Option<&NewSessionOptions>,
1571 ) -> Self {
1572 let dir = session_dir
1573 .map(|p| p.to_path_buf())
1574 .unwrap_or_else(|| get_default_session_dir(cwd));
1575 Self::create_persisted(cwd, &dir, options)
1576 }
1577
1578 pub fn open(path: &Path, session_dir: Option<&Path>, cwd_override: Option<&Path>) -> Self {
1580 let dir = session_dir.map(|p| p.to_path_buf()).unwrap_or_else(|| {
1581 path.parent()
1582 .map(|p| p.to_path_buf())
1583 .unwrap_or_else(|| get_default_session_dir(&PathBuf::from("/")))
1584 });
1585 Self::open_session(path, &dir, cwd_override)
1586 }
1587
1588 pub fn in_memory(cwd: &Path) -> Self {
1590 let dir = get_default_session_dir(cwd);
1591 Self::create_in_memory(cwd, &dir)
1592 }
1593
1594 pub fn continue_recent(cwd: &Path, session_dir: Option<&Path>) -> Self {
1596 let dir = session_dir
1597 .map(|p| p.to_path_buf())
1598 .unwrap_or_else(|| get_default_session_dir(cwd));
1599 let filter_cwd = session_dir.is_some_and(|sd| sd != get_default_session_dir(cwd));
1600 let most_recent = find_most_recent_session(&dir, if filter_cwd { Some(cwd) } else { None });
1601 if let Some(path) = most_recent {
1602 Self::open_session(&path, &dir, Some(cwd))
1603 } else {
1604 Self::create_persisted(cwd, &dir, None)
1605 }
1606 }
1607
1608 pub fn fork_from(
1611 source_path: &Path,
1612 target_cwd: &Path,
1613 session_dir: Option<&Path>,
1614 options: Option<&NewSessionOptions>,
1615 ) -> std::io::Result<Self> {
1616 let resolved_source = source_path;
1617 let resolved_target = target_cwd.to_path_buf();
1618 let dir = session_dir
1619 .map(|p| p.to_path_buf())
1620 .unwrap_or_else(|| get_default_session_dir(&resolved_target));
1621
1622 let source_entries = load_entries_from_file(resolved_source);
1623 if source_entries.is_empty() {
1624 return Err(std::io::Error::new(
1625 std::io::ErrorKind::InvalidData,
1626 "Cannot fork: source session is empty or invalid",
1627 ));
1628 }
1629
1630 let _source_header = read_session_header(resolved_source).ok_or_else(|| {
1631 std::io::Error::new(
1632 std::io::ErrorKind::InvalidData,
1633 "Cannot fork: source session has no header",
1634 )
1635 })?;
1636
1637 let id = options
1639 .and_then(|o| o.id.clone())
1640 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1641 let timestamp = chrono::Utc::now().to_rfc3339();
1642 let file_ts = timestamp.replace([':', '.'], "-");
1643 let file_name = format!("{}_{}.jsonl", file_ts, id);
1644 let target_path = dir.join(&file_name);
1645
1646 let mut storage = JsonlSessionStorage::create(
1648 target_path.clone(),
1649 &resolved_target.to_string_lossy(),
1650 &id,
1651 Some(resolved_source.to_string_lossy().to_string()),
1652 )
1653 .map_err(std::io::Error::other)?;
1654
1655 for entry in &source_entries {
1657 storage
1658 .append_entry(entry.clone())
1659 .map_err(std::io::Error::other)?;
1660 }
1661
1662 let session = Session::new(Box::new(storage));
1663 Ok(Self::with_session(session, dir, resolved_target, true))
1664 }
1665
1666 pub fn create_branched_session(&mut self, leaf_id: &str) -> Option<PathBuf> {
1670 let path = self.session.get_branch(Some(leaf_id)).unwrap_or_default();
1671 if path.is_empty() {
1672 return None;
1673 }
1674
1675 let mut path_clean: Vec<SessionEntry> = Vec::new();
1677 let mut path_parent_id: Option<String> = None;
1678 for entry in &path {
1679 if matches!(entry, SessionEntry::Label(_) | SessionEntry::Leaf(_)) {
1680 continue;
1681 }
1682 let mut e = entry.clone();
1683 e.set_parent_id(path_parent_id.clone());
1684 path_parent_id = Some(e.id().to_string());
1685 path_clean.push(e);
1686 }
1687
1688 let path_entry_ids: std::collections::HashSet<String> =
1690 path_clean.iter().map(|e| e.id().to_string()).collect();
1691 let mut labels_to_write: Vec<(String, String)> = Vec::new();
1692 for id in &path_entry_ids {
1693 if let Some(label) = self.session.get_label(id) {
1694 labels_to_write.push((id.clone(), label));
1695 }
1696 }
1697
1698 let new_session_id = uuid::Uuid::new_v4().to_string();
1699 let timestamp = chrono::Utc::now().to_rfc3339();
1700 let file_ts = timestamp.replace([':', '.'], "-");
1701 let new_session_file = self
1702 .session_dir
1703 .join(format!("{}_{}.jsonl", file_ts, new_session_id));
1704
1705 let cwd_str = self.cwd.to_string_lossy().to_string();
1706
1707 if self.persist {
1709 let header = SessionHeader {
1710 type_: "session".to_string(),
1711 version: Some(CURRENT_SESSION_VERSION),
1712 id: new_session_id,
1713 timestamp,
1714 cwd: cwd_str,
1715 parent_session: self
1716 .session
1717 .metadata()
1718 .path
1719 .map(|p| p.to_string_lossy().to_string()),
1720 };
1721
1722 if let Some(parent) = new_session_file.parent() {
1723 let _ = std::fs::create_dir_all(parent);
1724 }
1725 let mut content = serde_json::to_string(&header).unwrap_or_default();
1726 content.push('\n');
1727 for entry in &path_clean {
1728 let line = serde_json::to_string(entry).unwrap_or_default();
1729 content.push_str(&line);
1730 content.push('\n');
1731 }
1732 for (target_id, label) in &labels_to_write {
1733 let label_entry = SessionEntry::Label(LabelEntry {
1734 id: uuid::Uuid::new_v4().to_string()[..8].to_string(),
1735 parent_id: path_parent_id.clone(),
1736 timestamp: chrono::Utc::now().to_rfc3339(),
1737 target_id: target_id.clone(),
1738 label: Some(label.clone()),
1739 });
1740 let line = serde_json::to_string(&label_entry).unwrap_or_default();
1741 content.push_str(&line);
1742 content.push('\n');
1743 }
1744 let _ = std::fs::write(&new_session_file, &content);
1745 }
1746
1747 Some(new_session_file)
1748 }
1749}
1750
1751pub fn find_most_recent_session(session_dir: &Path, filter_cwd: Option<&Path>) -> Option<PathBuf> {
1753 let resolved_cwd = filter_cwd.map(|c| c.to_path_buf());
1754 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
1755
1756 let entries = std::fs::read_dir(session_dir).ok()?;
1757 for entry in entries.flatten() {
1758 let path = entry.path();
1759 if path.extension().is_some_and(|ext| ext == "jsonl") {
1760 let header = read_session_header(&path);
1761 if let Some(ref h) = header {
1762 if let Some(ref rcwd) = resolved_cwd
1763 && h.cwd != rcwd.to_string_lossy().as_ref()
1764 {
1765 continue;
1766 }
1767 } else {
1768 continue;
1769 }
1770 if let Ok(meta) = path.metadata()
1771 && let Ok(mtime) = meta.modified()
1772 {
1773 files.push((path, mtime));
1774 }
1775 }
1776 }
1777
1778 files.sort_by_key(|b| std::cmp::Reverse(b.1));
1779 files.into_iter().next().map(|(path, _)| path)
1780}
1781
1782pub fn list_sessions(session_dir: &Path) -> Vec<SessionInfo> {
1787 let mut sessions: Vec<SessionInfo> = Vec::new();
1788 let dir = match std::fs::read_dir(session_dir) {
1789 Ok(d) => d,
1790 Err(_) => return sessions,
1791 };
1792 for entry in dir.flatten() {
1793 let path = entry.path();
1794 if path.extension().is_some_and(|ext| ext == "jsonl")
1795 && let Some(info) = load_session_info(&path)
1796 {
1797 sessions.push(info);
1798 }
1799 }
1800 sessions.sort_by_key(|b| std::cmp::Reverse(b.created));
1801 sessions
1802}
1803
1804pub fn load_session_info(path: &Path) -> Option<SessionInfo> {
1806 let header = read_session_header(path)?;
1807 let created = DateTime::parse_from_rfc3339(&header.timestamp)
1808 .ok()?
1809 .with_timezone(&Utc);
1810 let modified = path.metadata().ok()?.modified().ok()?;
1811 let modified_dt: DateTime<Utc> = modified.into();
1812 let entries = load_entries_from_file(path);
1813 let name = entries.iter().rev().find_map(|e| {
1814 if let SessionEntry::SessionInfo(si) = e {
1815 let n = si.name.trim();
1816 if n.is_empty() {
1817 None
1818 } else {
1819 Some(n.to_string())
1820 }
1821 } else {
1822 None
1823 }
1824 });
1825 let message_count = entries
1826 .iter()
1827 .filter(|e| matches!(e, SessionEntry::Message(_)))
1828 .count();
1829 let first_message = entries
1830 .iter()
1831 .find_map(|e| {
1832 if let SessionEntry::Message(m) = e {
1833 Some(crate::agent::types::message_text(&m.message))
1834 } else {
1835 None
1836 }
1837 })
1838 .unwrap_or_default();
1839 let all_messages_text = entries
1840 .iter()
1841 .filter_map(|e| {
1842 if let SessionEntry::Message(m) = e {
1843 Some(crate::agent::types::message_text(&m.message))
1844 } else {
1845 None
1846 }
1847 })
1848 .collect::<Vec<_>>()
1849 .join("\n");
1850
1851 Some(SessionInfo {
1852 path: path.to_path_buf(),
1853 id: header.id,
1854 cwd: header.cwd,
1855 name,
1856 parent_session_path: header.parent_session,
1857 created,
1858 modified: modified_dt,
1859 message_count,
1860 first_message,
1861 all_messages_text,
1862 })
1863}
1864
1865pub fn delete_session(path: &Path) -> std::io::Result<()> {
1867 if path.exists() {
1868 std::fs::remove_file(path)?;
1869 }
1870 Ok(())
1871}
1872
1873pub fn fork_session(
1879 source_path: &Path,
1880 target_dir: &Path,
1881 entry_id: Option<&str>,
1882 position: Option<&str>,
1883) -> std::io::Result<String> {
1884 let header = read_session_header(source_path).ok_or_else(|| {
1885 std::io::Error::new(std::io::ErrorKind::InvalidData, "Missing session header")
1886 })?;
1887 let entries = load_entries_from_file(source_path);
1888
1889 let by_id: HashMap<String, &SessionEntry> =
1891 entries.iter().map(|e| (e.id().to_string(), e)).collect();
1892
1893 let forked_entries: Vec<SessionEntry> = if let Some(target_id) = entry_id {
1894 let target = by_id.get(target_id).ok_or_else(|| {
1896 std::io::Error::new(std::io::ErrorKind::InvalidInput, "Entry not found")
1897 })?;
1898
1899 let effective_leaf_id = match position.unwrap_or("before") {
1901 "at" => Some(target.id().to_string()),
1902 _ => {
1903 if !matches!(target, SessionEntry::Message(m) if crate::agent::types::message_is_user(&m.message))
1904 {
1905 return Err(std::io::Error::new(
1906 std::io::ErrorKind::InvalidInput,
1907 "Entry is not a user message",
1908 ));
1909 }
1910 target.parent_id().map(|s| s.to_string())
1911 }
1912 };
1913
1914 let mut path: Vec<&SessionEntry> = Vec::new();
1916 let mut current = effective_leaf_id.as_ref().and_then(|id| by_id.get(id));
1917 while let Some(entry) = current {
1918 path.push(entry);
1919 current = entry.parent_id().and_then(|pid| by_id.get(pid));
1920 }
1921 path.reverse();
1922 path.into_iter().cloned().collect()
1923 } else {
1924 entries.clone()
1925 };
1926
1927 let session_id = uuid::Uuid::new_v4().to_string();
1929 let timestamp = chrono::Utc::now().to_rfc3339();
1930 let file_ts = timestamp.replace([':', '.'], "-");
1931 let file_name = format!("{}_{}.jsonl", file_ts, session_id);
1932 let target_path = target_dir.join(&file_name);
1933
1934 std::fs::create_dir_all(target_dir)?;
1935
1936 let new_header = SessionHeader {
1937 type_: "session".to_string(),
1938 version: Some(CURRENT_SESSION_VERSION),
1939 id: session_id.clone(),
1940 timestamp,
1941 cwd: header.cwd.clone(),
1942 parent_session: Some(source_path.to_string_lossy().to_string()),
1943 };
1944 write_entries_to_file(&target_path, &new_header, &forked_entries)?;
1945
1946 Ok(session_id)
1947}
1948
1949#[cfg(test)]
1952mod tests {
1953 use super::*;
1954 use crate::agent::types::user_message;
1955 use tempfile::TempDir;
1956
1957 fn make_user_msg(content: &str) -> AgentMessage {
1958 user_message(content)
1959 }
1960
1961 fn make_asst_msg(content: &str) -> AgentMessage {
1962 crate::agent::types::assistant_message(content)
1963 }
1964
1965 #[test]
1968 fn test_build_context_tracks_metadata() {
1969 let tmp = TempDir::new().unwrap();
1970 let sessions_dir = tmp.path().join("sessions");
1971 let cwd = tmp.path().join("project");
1972 std::fs::create_dir_all(&cwd).unwrap();
1973
1974 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1975 sm.session_mut().append_thinking_level_change("high");
1976 sm.session_mut()
1977 .append_model_change("opencode_go", "deepseek-v4-pro");
1978 sm.session_mut()
1979 .append_active_tools_change(&["read".to_string(), "write".to_string()]);
1980 sm.append_message(&make_user_msg("hello"));
1981 sm.append_message(&make_asst_msg("hi"));
1982
1983 let context = sm.session().build_context();
1984 assert_eq!(context.thinking_level, "high");
1985 assert_eq!(
1986 context.model,
1987 Some(("opencode_go".to_string(), "deepseek-v4-pro".to_string()))
1988 );
1989 assert_eq!(
1990 context.active_tool_names,
1991 Some(vec!["read".to_string(), "write".to_string()])
1992 );
1993 assert_eq!(context.messages.len(), 2);
1994 }
1995
1996 #[test]
1997 fn test_build_context_defaults_when_no_metadata() {
1998 let cwd = Path::new("/tmp/test");
1999 let sm = SessionManager::in_memory(cwd);
2000 let context = sm.session().build_context();
2001 assert_eq!(context.thinking_level, "off");
2002 assert!(context.model.is_none());
2003 assert!(context.active_tool_names.is_none());
2004 assert!(context.messages.is_empty());
2005 }
2006
2007 #[test]
2010 fn test_find_entries_by_type() {
2011 let cwd = Path::new("/tmp/test");
2012 let mut sm = SessionManager::in_memory(cwd);
2013 sm.append_message(&make_user_msg("hello"));
2014 sm.session_mut().append_thinking_level_change("high");
2015 sm.session_mut().append_model_change("p", "m");
2016 sm.session_mut().append_session_info("test session");
2017
2018 let messages = sm.session().find_entries("message");
2019 assert_eq!(messages.len(), 1);
2020
2021 let thinking = sm.session().find_entries("thinking_level_change");
2022 assert_eq!(thinking.len(), 1);
2023
2024 let models = sm.session().find_entries("model_change");
2025 assert_eq!(models.len(), 1);
2026
2027 let infos = sm.session().find_entries("session_info");
2028 assert_eq!(infos.len(), 1);
2029 }
2030
2031 #[test]
2034 fn test_list_sessions_empty_dir() {
2035 let tmp = TempDir::new().unwrap();
2036 let sessions = list_sessions(tmp.path());
2037 assert!(sessions.is_empty());
2038 }
2039
2040 #[test]
2041 fn test_list_sessions() {
2042 let tmp = TempDir::new().unwrap();
2043 let sessions_dir = tmp.path().join("sessions");
2044 let cwd = tmp.path().join("project");
2045 std::fs::create_dir_all(&cwd).unwrap();
2046
2047 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2048 sm.append_message(&make_user_msg("first"));
2049 sm.append_message(&make_asst_msg("response"));
2050 let path = sm.session().session_file().unwrap().to_path_buf();
2051 drop(sm);
2052
2053 let sessions = list_sessions(&sessions_dir);
2054 assert_eq!(sessions.len(), 1);
2055 assert_eq!(sessions[0].path, path);
2056 assert_eq!(sessions[0].message_count, 2);
2057 }
2058
2059 #[test]
2060 fn test_fork_session_all_entries() {
2061 let tmp = TempDir::new().unwrap();
2062 let sessions_dir = tmp.path().join("sessions");
2063 let cwd = tmp.path().join("project");
2064 std::fs::create_dir_all(&cwd).unwrap();
2065
2066 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2067 sm.append_message(&make_user_msg("hello"));
2068 sm.append_message(&make_asst_msg("world"));
2069 let source_path = sm.session().session_file().unwrap().to_path_buf();
2070 drop(sm);
2071
2072 let target_dir = tmp.path().join("forked");
2073 let new_id = fork_session(&source_path, &target_dir, None, None).unwrap();
2074 assert!(!new_id.is_empty());
2075
2076 let sessions = list_sessions(&target_dir);
2077 assert_eq!(sessions.len(), 1);
2078 assert_eq!(sessions[0].message_count, 2);
2079 }
2080
2081 #[test]
2082 fn test_delete_session() {
2083 let tmp = TempDir::new().unwrap();
2084 let path = tmp.path().join("test.jsonl");
2085 std::fs::write(&path, "{\"type\":\"session\",\"id\":\"test\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":\"/\"}\n").unwrap();
2086 assert!(path.exists());
2087 delete_session(&path).unwrap();
2088 assert!(!path.exists());
2089 delete_session(&path).unwrap();
2091 }
2092
2093 #[test]
2094 fn test_parse_session_entry_line() {
2095 let entry = SessionEntry::SessionInfo(SessionInfoEntry {
2096 id: "abc12345".to_string(),
2097 parent_id: None,
2098 timestamp: "2026-06-19T12:00:00Z".to_string(),
2099 name: "Test session".to_string(),
2100 });
2101 let json = serde_json::to_string(&entry).unwrap();
2102 let parsed = parse_session_entry_line(&json);
2103 assert!(parsed.is_some());
2104 }
2105
2106 #[test]
2107 fn test_parse_session_entry_line_empty() {
2108 assert!(parse_session_entry_line("").is_none());
2109 assert!(parse_session_entry_line(" ").is_none());
2110 }
2111
2112 #[test]
2113 fn test_parse_session_entry_line_malformed() {
2114 assert!(parse_session_entry_line("not valid json").is_none());
2115 }
2116
2117 #[test]
2118 fn test_parse_session_header_line() {
2119 let header = SessionHeader {
2120 type_: "session".to_string(),
2121 version: Some(3),
2122 id: "session123".to_string(),
2123 timestamp: "2026-06-19T12:00:00Z".to_string(),
2124 cwd: "/home/user/project".to_string(),
2125 parent_session: None,
2126 };
2127 let json = serde_json::to_string(&header).unwrap();
2128 let parsed = parse_session_header_line(&json);
2129 assert!(parsed.is_some());
2130 assert_eq!(parsed.unwrap().id, "session123");
2131 }
2132
2133 #[test]
2134 fn test_parse_session_header_line_wrong_type() {
2135 let json =
2137 r#"{"type":"message","id":"abc","timestamp":"2026-06-19T12:00:00Z","cwd":"/home"}"#;
2138 let result = parse_session_header_line(json);
2139 assert!(result.is_none());
2140 }
2141
2142 #[test]
2143 fn test_write_and_read_entries() {
2144 let tmp = TempDir::new().unwrap();
2145 let file_path = tmp.path().join("test.jsonl");
2146
2147 let header = SessionHeader {
2148 type_: "session".to_string(),
2149 version: Some(3),
2150 id: "session1".to_string(),
2151 timestamp: "2026-06-19T12:00:00Z".to_string(),
2152 cwd: "/home/user/project".to_string(),
2153 parent_session: None,
2154 };
2155
2156 let entries: Vec<SessionEntry> = vec![
2157 SessionEntry::Message(MessageEntry::new(
2158 "msg1".to_string(),
2159 None,
2160 "2026-06-19T12:00:01Z".to_string(),
2161 make_user_msg("hello"),
2162 MessageCost::ZERO,
2163 )),
2164 SessionEntry::Message(MessageEntry {
2165 cost: MessageCost::ZERO,
2166 id: "msg2".to_string(),
2167 parent_id: Some("msg1".to_string()),
2168 timestamp: "2026-06-19T12:00:02Z".to_string(),
2169 message: AgentMessage::Llm(yoagent::types::Message::Assistant {
2170 content: vec![yoagent::types::Content::Text {
2171 text: "hi there".to_string(),
2172 }],
2173 stop_reason: yoagent::types::StopReason::Stop,
2174 model: String::new(),
2175 provider: String::new(),
2176 usage: yoagent::types::Usage {
2177 input: 10,
2178 output: 5,
2179 ..Default::default()
2180 },
2181 timestamp: 0,
2182 error_message: None,
2183 }),
2184 }),
2185 ];
2186
2187 write_entries_to_file(&file_path, &header, &entries).unwrap();
2188
2189 let read_header = read_session_header(&file_path).unwrap();
2191 assert_eq!(read_header.id, "session1");
2192
2193 let read_entries = load_entries_from_file(&file_path);
2195 assert_eq!(read_entries.len(), 2);
2196
2197 match &read_entries[0] {
2198 SessionEntry::Message(e) => {
2199 assert_eq!(e.id, "msg1");
2200 assert!(crate::agent::types::message_is_user(&e.message));
2201 assert_eq!(crate::agent::types::message_text(&e.message), "hello");
2202 }
2203 _ => panic!("Expected Message"),
2204 }
2205
2206 match &read_entries[1] {
2207 SessionEntry::Message(e) => {
2208 assert_eq!(e.id, "msg2");
2209 assert!(crate::agent::types::message_is_assistant(&e.message));
2210 assert_eq!(crate::agent::types::message_text(&e.message), "hi there");
2211 assert!(crate::agent::types::message_usage(&e.message).is_some());
2212 }
2213 _ => panic!("Expected Message"),
2214 }
2215 }
2216
2217 #[test]
2218 fn test_append_entry_to_file() {
2219 let tmp = TempDir::new().unwrap();
2220 let file_path = tmp.path().join("append_test.jsonl");
2221
2222 let entry = SessionEntry::SessionInfo(SessionInfoEntry {
2223 id: "abc12345".to_string(),
2224 parent_id: None,
2225 timestamp: "2026-06-19T12:00:00Z".to_string(),
2226 name: "Test".to_string(),
2227 });
2228
2229 append_entry_to_file(&file_path, &entry).unwrap();
2230
2231 let content = fs::read_to_string(&file_path).unwrap();
2232 assert!(content.contains("Test"));
2233 assert!(content.contains("abc12345"));
2234 }
2235
2236 #[test]
2237 fn test_load_entries_missing_file() {
2238 let entries = load_entries_from_file(Path::new("/nonexistent/file.jsonl"));
2239 assert!(entries.is_empty());
2240 }
2241
2242 #[test]
2243 fn test_read_session_header_missing_file() {
2244 let header = read_session_header(Path::new("/nonexistent/file.jsonl"));
2245 assert!(header.is_none());
2246 }
2247
2248 #[test]
2251 fn test_encode_cwd() {
2252 assert_eq!(
2253 encode_cwd_for_dir(Path::new("/home/user/project")),
2254 "--home-user-project--"
2255 );
2256 }
2257
2258 #[test]
2259 fn test_encode_cwd_windows_style() {
2260 assert_eq!(
2261 encode_cwd_for_dir(Path::new("C:\\Users\\user\\project")),
2262 "--C--Users-user-project--"
2263 );
2264 }
2265
2266 #[test]
2267 fn test_encode_cwd_no_leading_slash() {
2268 assert_eq!(
2269 encode_cwd_for_dir(Path::new("home/user/project")),
2270 "--home-user-project--"
2271 );
2272 }
2273
2274 #[test]
2275 fn test_encode_cwd_special_chars() {
2276 assert_eq!(
2277 encode_cwd_for_dir(Path::new("/home/user/my:project")),
2278 "--home-user-my-project--"
2279 );
2280 }
2281
2282 #[test]
2285 fn test_entry_id_accessor() {
2286 let entry = SessionEntry::Message(MessageEntry::new(
2287 "myid".to_string(),
2288 None,
2289 "2026-06-19T12:00:00Z".to_string(),
2290 make_user_msg("hello"),
2291 MessageCost::ZERO,
2292 ));
2293 assert_eq!(entry.id(), "myid");
2294 }
2295
2296 #[test]
2297 fn test_entry_parent_id_accessor() {
2298 let entry = SessionEntry::Message(MessageEntry::new(
2299 "myid".to_string(),
2300 Some("parent".to_string()),
2301 "2026-06-19T12:00:00Z".to_string(),
2302 make_user_msg("hello"),
2303 MessageCost::ZERO,
2304 ));
2305 assert_eq!(entry.parent_id(), Some("parent"));
2306 }
2307
2308 #[test]
2309 fn test_entry_timestamp_accessor() {
2310 let entry = SessionEntry::Message(MessageEntry::new(
2311 "myid".to_string(),
2312 None,
2313 "2026-06-19T12:00:00Z".to_string(),
2314 make_user_msg("hello"),
2315 MessageCost::ZERO,
2316 ));
2317 assert_eq!(entry.timestamp(), "2026-06-19T12:00:00Z");
2318 }
2319
2320 #[test]
2323 fn test_generate_entry_id_length() {
2324 let map = HashMap::new();
2325 let id = generate_entry_id(&map);
2326 assert_eq!(id.len(), 8);
2327 }
2328
2329 #[test]
2330 fn test_generate_entry_id_hex() {
2331 let map = HashMap::new();
2332 let id = generate_entry_id(&map);
2333 assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
2334 }
2335
2336 #[test]
2337 fn test_generate_entry_id_collision_fallback() {
2338 let map = HashMap::new();
2343 let id1 = generate_entry_id(&map);
2344 assert!(!id1.is_empty());
2345 }
2346
2347 #[test]
2350 fn test_deserialize_pi_format_message() {
2351 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}}"#;
2354 let entry: SessionEntry = serde_json::from_str(json).unwrap();
2355 match entry {
2356 SessionEntry::Message(e) => {
2357 assert_eq!(e.id, "abc12345");
2358 assert_eq!(crate::agent::types::message_text(&e.message), "hello");
2359 }
2360 _ => panic!("Expected Message"),
2361 }
2362 }
2363
2364 #[test]
2365 fn test_deserialize_pi_format_thinking_level() {
2366 let json = r#"{"type":"thinking_level_change","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","thinkingLevel":"high"}"#;
2367 let entry: SessionEntry = serde_json::from_str(json).unwrap();
2368 match entry {
2369 SessionEntry::ThinkingLevelChange(e) => {
2370 assert_eq!(e.thinking_level, "high");
2371 }
2372 _ => panic!("Expected ThinkingLevelChange"),
2373 }
2374 }
2375
2376 #[test]
2377 fn test_deserialize_pi_format_model_change() {
2378 let json = r#"{"type":"model_change","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","provider":"opencode_go","modelId":"deepseek-v4-pro"}"#;
2379 let entry: SessionEntry = serde_json::from_str(json).unwrap();
2380 match entry {
2381 SessionEntry::ModelChange(e) => {
2382 assert_eq!(e.provider, "opencode_go");
2383 assert_eq!(e.model_id, "deepseek-v4-pro");
2384 }
2385 _ => panic!("Expected ModelChange"),
2386 }
2387 }
2388
2389 #[test]
2390 fn test_deserialize_pi_format_compaction() {
2391 let json = r#"{"type":"compaction","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","summary":"Earlier conversation summarized","firstKeptEntryId":"entry123","tokensBefore":5000}"#;
2392 let entry: SessionEntry = serde_json::from_str(json).unwrap();
2393 match entry {
2394 SessionEntry::Compaction(e) => {
2395 assert_eq!(e.summary, "Earlier conversation summarized");
2396 assert_eq!(e.first_kept_entry_id, "entry123");
2397 assert_eq!(e.tokens_before, 5000);
2398 }
2399 _ => panic!("Expected Compaction"),
2400 }
2401 }
2402
2403 #[test]
2404 fn test_deserialize_pi_format_session_info() {
2405 let json = r#"{"type":"session_info","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","name":"My session"}"#;
2406 let entry: SessionEntry = serde_json::from_str(json).unwrap();
2407 match entry {
2408 SessionEntry::SessionInfo(e) => {
2409 assert_eq!(e.name, "My session");
2410 }
2411 _ => panic!("Expected SessionInfo"),
2412 }
2413 }
2414
2415 #[test]
2418 fn test_session_create_in_memory() {
2419 let cwd = Path::new("/tmp/test-project");
2420 let sm = SessionManager::in_memory(cwd);
2421 assert!(!sm.is_persisted());
2422 assert!(!sm.session().session_id().is_empty());
2423 assert_eq!(sm.cwd(), cwd);
2424 assert!(sm.session().get_leaf_id().is_none());
2425 assert!(sm.session().get_entries().is_empty());
2426 }
2427
2428 #[test]
2429 fn test_session_create_persisted() {
2430 let tmp = TempDir::new().unwrap();
2431 let sessions_dir = tmp.path().join("sessions");
2432 let cwd = tmp.path().join("project");
2433 std::fs::create_dir_all(&cwd).unwrap();
2434
2435 let sm = SessionManager::create(&cwd, Some(&sessions_dir));
2436 assert!(sm.is_persisted());
2437 assert!(!sm.session().session_id().is_empty());
2438 assert!(
2440 sm.session().session_file().is_none(),
2441 "session file should not be created until first assistant message (lazy write)"
2442 );
2443 assert!(!sm.flushed);
2444 }
2445
2446 #[test]
2447 fn test_session_append_and_build_context() {
2448 let tmp = TempDir::new().unwrap();
2449 let sessions_dir = tmp.path().join("sessions");
2450 let cwd = tmp.path().join("project");
2451 std::fs::create_dir_all(&cwd).unwrap();
2452
2453 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2454
2455 let user_msg = make_user_msg("hello");
2456 let user_id = sm.append_message(&user_msg);
2457 assert_eq!(
2458 sm.session().get_leaf_id().as_deref(),
2459 Some(user_id.as_str())
2460 );
2461
2462 assert_eq!(sm.session().get_entries().len(), 1);
2464
2465 let assistant_msg = make_asst_msg("hi there");
2466 sm.append_message(&assistant_msg);
2467 assert_eq!(sm.session().get_entries().len(), 2);
2468
2469 assert!(
2471 sm.session().session_file().unwrap().exists(),
2472 "session file should exist after first assistant message"
2473 );
2474
2475 let context = sm.session().build_context();
2476 assert_eq!(context.messages.len(), 2);
2477 assert_eq!(
2478 crate::agent::types::message_text(&context.messages[0]),
2479 "hello"
2480 );
2481 assert_eq!(
2482 crate::agent::types::message_text(&context.messages[1]),
2483 "hi there"
2484 );
2485 }
2486
2487 #[test]
2488 fn test_session_open_existing() {
2489 let tmp = TempDir::new().unwrap();
2490 let sessions_dir = tmp.path().join("sessions");
2491 let cwd = tmp.path().join("project");
2492 std::fs::create_dir_all(&cwd).unwrap();
2493
2494 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2496 sm.append_message(&make_user_msg("first"));
2497 sm.append_message(&make_asst_msg("response"));
2498
2499 let file_path = sm.session().session_file().unwrap().to_path_buf();
2500 let session_id = sm.session().session_id().to_string();
2501 drop(sm);
2502
2503 let sm2 = SessionManager::open(&file_path, Some(&sessions_dir), None);
2505 assert_eq!(sm2.session().session_id(), session_id);
2506 let context = sm2.session().build_context();
2507 assert_eq!(context.messages.len(), 2);
2508 assert_eq!(
2509 crate::agent::types::message_text(&context.messages[0]),
2510 "first"
2511 );
2512 assert_eq!(
2513 crate::agent::types::message_text(&context.messages[1]),
2514 "response"
2515 );
2516 }
2517
2518 #[test]
2519 fn test_session_continue_recent() {
2520 let tmp = TempDir::new().unwrap();
2521 let sessions_dir = tmp.path().join("sessions");
2522 let cwd = tmp.path().join("project");
2523 std::fs::create_dir_all(&cwd).unwrap();
2524
2525 let mut sm1 = SessionManager::create(&cwd, Some(&sessions_dir));
2527 sm1.append_message(&make_user_msg("old session"));
2528 sm1.append_message(&make_asst_msg("old response"));
2529 let _old_id = sm1.session().session_id().to_string();
2530 drop(sm1);
2531
2532 std::thread::sleep(std::time::Duration::from_millis(10));
2534
2535 let mut sm2 = SessionManager::create(&cwd, Some(&sessions_dir));
2537 sm2.append_message(&make_user_msg("new session"));
2538 sm2.append_message(&make_asst_msg("new response"));
2539 let new_id = sm2.session().session_id().to_string();
2540 drop(sm2);
2541
2542 let sm3 = SessionManager::continue_recent(&cwd, Some(&sessions_dir));
2544 assert_eq!(sm3.session().session_id(), new_id);
2545 let context = sm3.session().build_context();
2546 assert_eq!(
2547 crate::agent::types::message_text(&context.messages[0]),
2548 "new session"
2549 );
2550 }
2551
2552 #[test]
2553 fn test_session_continue_recent_none_exist() {
2554 let tmp = TempDir::new().unwrap();
2555 let sessions_dir = tmp.path().join("sessions");
2556 let cwd = tmp.path().join("project");
2557 std::fs::create_dir_all(&sessions_dir).unwrap();
2558 std::fs::create_dir_all(&cwd).unwrap();
2559
2560 let sm = SessionManager::continue_recent(&cwd, Some(&sessions_dir));
2562 assert!(!sm.session().session_id().is_empty());
2563 assert!(sm.session().get_entries().is_empty());
2564 }
2565
2566 #[test]
2567 fn test_session_name() {
2568 let tmp = TempDir::new().unwrap();
2569 let sessions_dir = tmp.path().join("sessions");
2570 let cwd = tmp.path().join("project");
2571 std::fs::create_dir_all(&cwd).unwrap();
2572
2573 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2574 assert!(sm.session().session_name().is_none());
2575
2576 sm.session_mut().append_session_info("My Task");
2577 sm.append_message(&make_user_msg("hello"));
2578 sm.append_message(&make_asst_msg("hi"));
2579 assert_eq!(sm.session().session_name().as_deref(), Some("My Task"));
2580
2581 sm.session_mut().append_session_info("");
2583 assert!(sm.session().session_name().is_none());
2584 }
2585
2586 #[test]
2587 fn test_session_thinking_level_change() {
2588 let tmp = TempDir::new().unwrap();
2589 let sessions_dir = tmp.path().join("sessions");
2590 let cwd = tmp.path().join("project");
2591 std::fs::create_dir_all(&cwd).unwrap();
2592
2593 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2594 sm.session_mut().append_thinking_level_change("high");
2595
2596 assert_eq!(sm.session().get_entries().len(), 1);
2597 match &sm.session().get_entries()[0] {
2598 SessionEntry::ThinkingLevelChange(e) => {
2599 assert_eq!(e.thinking_level, "high");
2600 }
2601 _ => panic!("Expected ThinkingLevelChange"),
2602 }
2603 }
2604
2605 #[test]
2606 fn test_session_model_change() {
2607 let tmp = TempDir::new().unwrap();
2608 let sessions_dir = tmp.path().join("sessions");
2609 let cwd = tmp.path().join("project");
2610 std::fs::create_dir_all(&cwd).unwrap();
2611
2612 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2613 sm.session_mut()
2614 .append_model_change("opencode_go", "deepseek-v4-pro");
2615
2616 assert_eq!(sm.session().get_entries().len(), 1);
2617 match &sm.session().get_entries()[0] {
2618 SessionEntry::ModelChange(e) => {
2619 assert_eq!(e.provider, "opencode_go");
2620 assert_eq!(e.model_id, "deepseek-v4-pro");
2621 }
2622 _ => panic!("Expected ModelChange"),
2623 }
2624 }
2625
2626 #[test]
2627 fn test_session_compaction() {
2628 let tmp = TempDir::new().unwrap();
2629 let sessions_dir = tmp.path().join("sessions");
2630 let cwd = tmp.path().join("project");
2631 std::fs::create_dir_all(&cwd).unwrap();
2632
2633 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2634 sm.session_mut().append_compaction(
2635 "Earlier work summarized",
2636 "entry_kept",
2637 5000,
2638 None,
2639 None,
2640 );
2641
2642 match &sm.session().get_entries()[0] {
2643 SessionEntry::Compaction(e) => {
2644 assert_eq!(e.summary, "Earlier work summarized");
2645 assert_eq!(e.first_kept_entry_id, "entry_kept");
2646 assert_eq!(e.tokens_before, 5000);
2647 }
2648 _ => panic!("Expected Compaction"),
2649 }
2650 }
2651
2652 #[test]
2653 fn test_session_label() {
2654 let tmp = TempDir::new().unwrap();
2655 let sessions_dir = tmp.path().join("sessions");
2656 let cwd = tmp.path().join("project");
2657 std::fs::create_dir_all(&cwd).unwrap();
2658
2659 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2660 let msg_id = sm.append_message(&make_user_msg("important message"));
2661 sm.append_message(&make_asst_msg("ok"));
2662
2663 sm.session_mut()
2665 .append_label_change(&msg_id, Some("important"))
2666 .unwrap();
2667 assert_eq!(
2668 sm.session().get_label(&msg_id).as_deref(),
2669 Some("important")
2670 );
2671
2672 sm.session_mut().append_label_change(&msg_id, None).unwrap();
2674 assert_eq!(sm.session().get_label(&msg_id), None);
2675 }
2676
2677 #[test]
2678 fn test_session_branch_navigation() {
2679 let tmp = TempDir::new().unwrap();
2680 let sessions_dir = tmp.path().join("sessions");
2681 let cwd = tmp.path().join("project");
2682 std::fs::create_dir_all(&cwd).unwrap();
2683
2684 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2685 let m1 = sm.append_message(&make_user_msg("one"));
2686 sm.append_message(&make_asst_msg("response one"));
2687 let _m2 = sm.append_message(&make_user_msg("two"));
2688 sm.append_message(&make_asst_msg("response two"));
2689
2690 assert_eq!(sm.session().get_entries().len(), 4);
2692
2693 sm.set_branch(&m1).unwrap();
2695 assert_eq!(sm.session().get_entries().len(), 4);
2697 assert_eq!(sm.session().get_leaf_id().as_deref(), Some(m1.as_str()));
2698
2699 sm.append_message(&make_asst_msg("alternate response"));
2701 assert_eq!(sm.session().get_entries().len(), 5);
2703
2704 let context = sm.session().build_context();
2706 assert_eq!(context.messages.len(), 2); assert_eq!(context.thinking_level, "off");
2709 assert!(context.model.is_none());
2710 assert!(context.active_tool_names.is_none());
2711 }
2712
2713 #[test]
2714 fn test_session_reset_leaf() {
2715 let tmp = TempDir::new().unwrap();
2716 let sessions_dir = tmp.path().join("sessions");
2717 let cwd = tmp.path().join("project");
2718 std::fs::create_dir_all(&cwd).unwrap();
2719
2720 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2721 sm.append_message(&make_user_msg("one"));
2722 sm.append_message(&make_asst_msg("response"));
2723 assert_eq!(sm.session().get_entries().len(), 2);
2724
2725 sm.reset_leaf();
2727 assert_eq!(sm.session().get_entries().len(), 2);
2729 assert!(sm.session().get_leaf_id().is_none());
2730
2731 sm.append_message(&make_user_msg("fresh start"));
2733 assert_eq!(sm.session().get_entries().len(), 3);
2734 match &sm.session().get_entries()[2] {
2736 SessionEntry::Message(m) => {
2737 assert!(m.parent_id.is_none());
2738 }
2739 _ => panic!("Expected Message"),
2740 }
2741 }
2742
2743 #[test]
2744 fn test_session_branch_summary() {
2745 let tmp = TempDir::new().unwrap();
2746 let sessions_dir = tmp.path().join("sessions");
2747 let cwd = tmp.path().join("project");
2748 std::fs::create_dir_all(&cwd).unwrap();
2749
2750 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2751 sm.append_message(&make_user_msg("one"));
2752 sm.append_message(&make_asst_msg("response"));
2753
2754 sm.session_mut()
2755 .append_branch_summary("root", "Abandoned path summary", None, None);
2756
2757 match &sm.session().get_entries()[2] {
2758 SessionEntry::BranchSummary(e) => {
2759 assert_eq!(e.summary, "Abandoned path summary");
2760 assert_eq!(e.from_id, "root");
2761 }
2762 _ => panic!("Expected BranchSummary"),
2763 }
2764 }
2765
2766 #[test]
2767 fn test_session_children() {
2768 let tmp = TempDir::new().unwrap();
2769 let sessions_dir = tmp.path().join("sessions");
2770 let cwd = tmp.path().join("project");
2771 std::fs::create_dir_all(&cwd).unwrap();
2772
2773 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2774 let m1 = sm.append_message(&make_user_msg("one"));
2775 sm.append_message(&make_asst_msg("response"));
2776
2777 let children = sm.get_children(&m1);
2779 assert_eq!(children.len(), 1);
2780 }
2781
2782 #[test]
2783 fn test_session_custom_entry() {
2784 let tmp = TempDir::new().unwrap();
2785 let sessions_dir = tmp.path().join("sessions");
2786 let cwd = tmp.path().join("project");
2787 std::fs::create_dir_all(&cwd).unwrap();
2788
2789 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2790 sm.append_message(&make_user_msg("one"));
2791 sm.append_message(&make_asst_msg("ok"));
2792 sm.session_mut()
2793 .append_custom_entry("my_ext", serde_json::json!({"key": "value"}));
2794
2795 match &sm.session().get_entries()[2] {
2796 SessionEntry::Custom(e) => {
2797 assert_eq!(e.custom_type, "my_ext");
2798 assert_eq!(e.data["key"], "value");
2799 }
2800 _ => panic!("Expected Custom"),
2801 }
2802 }
2803
2804 #[test]
2805 fn test_find_most_recent_session() {
2806 let tmp = TempDir::new().unwrap();
2807 let sessions_dir = tmp.path().join("sessions");
2808 let cwd = tmp.path().join("project");
2809 std::fs::create_dir_all(&sessions_dir).unwrap();
2810 std::fs::create_dir_all(&cwd).unwrap();
2811
2812 let mut sm1 = SessionManager::create(&cwd, Some(&sessions_dir));
2814 sm1.append_message(&make_user_msg("old"));
2815 sm1.append_message(&make_asst_msg("old"));
2816 let _path1 = sm1.session().session_file().unwrap().to_path_buf();
2817 drop(sm1);
2818
2819 std::thread::sleep(std::time::Duration::from_millis(10));
2820
2821 let mut sm2 = SessionManager::create(&cwd, Some(&sessions_dir));
2823 sm2.append_message(&make_user_msg("new"));
2824 sm2.append_message(&make_asst_msg("new"));
2825 let path2 = sm2.session().session_file().unwrap().to_path_buf();
2826 drop(sm2);
2827
2828 let most_recent = find_most_recent_session(&sessions_dir, None).unwrap();
2829 assert_eq!(most_recent, path2);
2830 }
2831
2832 #[test]
2835 fn test_corrupt_empty_file_is_recovered() {
2836 let tmp = TempDir::new().unwrap();
2837 let sessions_dir = tmp.path().join("sessions");
2838 let cwd = tmp.path().join("project");
2839 std::fs::create_dir_all(&sessions_dir).unwrap();
2840 std::fs::create_dir_all(&cwd).unwrap();
2841
2842 let file_path = sessions_dir.join("empty.jsonl");
2844 std::fs::write(&file_path, "").unwrap();
2845
2846 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2848 assert!(!sm.session().session_id().is_empty());
2849 assert!(sm.session().get_entries().is_empty());
2850 assert_eq!(sm.session().session_file().unwrap(), file_path);
2851 }
2852
2853 #[test]
2854 fn test_corrupt_garbage_file_is_recovered() {
2855 let tmp = TempDir::new().unwrap();
2856 let sessions_dir = tmp.path().join("sessions");
2857 let cwd = tmp.path().join("project");
2858 std::fs::create_dir_all(&sessions_dir).unwrap();
2859 std::fs::create_dir_all(&cwd).unwrap();
2860
2861 let file_path = sessions_dir.join("garbage.jsonl");
2863 std::fs::write(
2864 &file_path,
2865 "this is not json\nneither is this\n{half-json\n",
2866 )
2867 .unwrap();
2868
2869 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2871 assert!(!sm.session().session_id().is_empty());
2872 assert!(sm.session().get_entries().is_empty());
2873 }
2874
2875 #[test]
2876 fn test_corrupt_header_only_file_is_kept() {
2877 let tmp = TempDir::new().unwrap();
2878 let sessions_dir = tmp.path().join("sessions");
2879 let cwd = tmp.path().join("project");
2880 std::fs::create_dir_all(&sessions_dir).unwrap();
2881 std::fs::create_dir_all(&cwd).unwrap();
2882
2883 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2885 sm.append_message(&make_user_msg("test"));
2886 sm.append_message(&make_asst_msg("ok"));
2887 let original_id = sm.session().session_id().to_string();
2888 let file_path = sm.session().session_file().unwrap().to_path_buf();
2889 drop(sm);
2890
2891 let content = std::fs::read_to_string(&file_path).unwrap();
2893 let header_line = content.lines().next().unwrap();
2894 std::fs::write(&file_path, format!("{}\n", header_line)).unwrap();
2895
2896 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2898 assert_eq!(sm.session().session_id(), original_id);
2899 assert!(sm.session().get_entries().is_empty());
2900 }
2901
2902 #[test]
2903 fn test_corrupt_malformed_lines_are_skipped() {
2904 let tmp = TempDir::new().unwrap();
2905 let sessions_dir = tmp.path().join("sessions");
2906 let cwd = tmp.path().join("project");
2907 std::fs::create_dir_all(&sessions_dir).unwrap();
2908 std::fs::create_dir_all(&cwd).unwrap();
2909
2910 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2912 sm.append_message(&make_user_msg("valid message"));
2913 sm.append_message(&make_asst_msg("valid response"));
2914 let file_path = sm.session().session_file().unwrap().to_path_buf();
2915 drop(sm);
2916
2917 let mut content = std::fs::read_to_string(&file_path).unwrap();
2919 content.push_str("this is garbage\n");
2920 content.push_str("{incomplete json\n");
2921 content.push('\n'); std::fs::write(&file_path, &content).unwrap();
2923
2924 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2926 let ctx = sm.session().build_context();
2927 assert_eq!(ctx.messages.len(), 2);
2928 assert_eq!(
2929 crate::agent::types::message_text(&ctx.messages[0]),
2930 "valid message"
2931 );
2932 assert_eq!(
2933 crate::agent::types::message_text(&ctx.messages[1]),
2934 "valid response"
2935 );
2936 }
2937
2938 #[test]
2939 fn test_corrupt_missing_header_uses_new_id() {
2940 let tmp = TempDir::new().unwrap();
2941 let sessions_dir = tmp.path().join("sessions");
2942 let cwd = tmp.path().join("project");
2943 std::fs::create_dir_all(&sessions_dir).unwrap();
2944 std::fs::create_dir_all(&cwd).unwrap();
2945
2946 let entry = SessionEntry::Message(MessageEntry::new(
2948 "msg1".to_string(),
2949 None,
2950 "2026-01-01T00:00:00Z".to_string(),
2951 make_user_msg("orphan message"),
2952 MessageCost::ZERO,
2953 ));
2954 let json = serde_json::to_string(&entry).unwrap();
2955 let file_path = sessions_dir.join("no_header.jsonl");
2956 std::fs::write(&file_path, format!("{}\n", json)).unwrap();
2957
2958 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2961 assert!(!sm.session().session_id().is_empty());
2962 assert_eq!(sm.session().get_entries().len(), 0);
2963 }
2964
2965 #[test]
2966 fn test_corrupt_file_then_append_works() {
2967 let tmp = TempDir::new().unwrap();
2968 let sessions_dir = tmp.path().join("sessions");
2969 let cwd = tmp.path().join("project");
2970 std::fs::create_dir_all(&sessions_dir).unwrap();
2971 std::fs::create_dir_all(&cwd).unwrap();
2972
2973 let file_path = sessions_dir.join("recovered.jsonl");
2975 std::fs::write(&file_path, "garbage\nmore garbage\n").unwrap();
2976
2977 let mut sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2979 assert!(sm.session().get_entries().is_empty());
2980
2981 sm.append_message(&make_user_msg("fresh start"));
2983 sm.append_message(&make_asst_msg("fresh response"));
2984
2985 let ctx = sm.session().build_context();
2986 assert_eq!(ctx.messages.len(), 2);
2987 assert_eq!(
2988 crate::agent::types::message_text(&ctx.messages[0]),
2989 "fresh start"
2990 );
2991
2992 let content = std::fs::read_to_string(&file_path).unwrap();
2994 assert!(content.contains("fresh start"));
2995 assert!(!content.contains("garbage"));
2996 }
2997
2998 #[test]
2999 fn test_corrupt_all_lines_malformed_is_empty() {
3000 let entries = load_entries_from_file(Path::new("/nonexistent/file.jsonl"));
3001 assert!(entries.is_empty());
3002 }
3003
3004 #[test]
3005 fn test_corrupt_malformed_line_returns_none() {
3006 let result = parse_session_entry_line("not valid json");
3007 assert!(result.is_none());
3008 }
3009
3010 #[test]
3011 fn test_corrupt_blank_lines_are_skipped() {
3012 let result = parse_session_entry_line("");
3013 assert!(result.is_none());
3014 let result = parse_session_entry_line(" ");
3015 assert!(result.is_none());
3016 }
3017
3018 #[test]
3019 fn test_corrupt_header_line_malformed_returns_none() {
3020 let result = read_session_header(Path::new("/nonexistent"));
3021 assert!(result.is_none());
3022 }
3023
3024 #[test]
3027 fn test_session_name_sanitizes_newlines() {
3028 let mut sm = SessionManager::in_memory(Path::new("/tmp/test"));
3029 sm.session_mut()
3030 .append_session_info("My\nTask\rWith\r\nNewlines");
3031 assert_eq!(
3032 sm.session().session_name().as_deref(),
3033 Some("My Task With Newlines")
3034 );
3035 }
3036
3037 #[test]
3040 fn test_append_label_nonexistent_target_returns_error() {
3041 let mut sm = SessionManager::in_memory(Path::new("/tmp/test"));
3042 let result = sm
3043 .session_mut()
3044 .append_label_change("nonexistent", Some("label"));
3045 assert!(result.is_err());
3046 match result {
3047 Err(SessionError::NotFound(msg)) => {
3048 assert!(msg.contains("nonexistent"));
3049 }
3050 _ => panic!("Expected SessionError::NotFound"),
3051 }
3052 }
3053
3054 #[test]
3057 fn test_session_label_timestamp() {
3058 let mut sm = SessionManager::in_memory(Path::new("/tmp/test"));
3059 let msg_id = sm.append_message(&make_user_msg("important"));
3060 sm.append_message(&make_asst_msg("ok"));
3061
3062 assert!(sm.session().get_label_timestamp(&msg_id).is_none());
3064
3065 sm.session_mut()
3067 .append_label_change(&msg_id, Some("important"))
3068 .unwrap();
3069 let ts = sm.session().get_label_timestamp(&msg_id);
3070 assert!(ts.is_some());
3071 chrono::DateTime::parse_from_rfc3339(&ts.unwrap()).unwrap();
3073
3074 sm.session_mut().append_label_change(&msg_id, None).unwrap();
3076 assert!(sm.session().get_label_timestamp(&msg_id).is_none());
3077 }
3078
3079 #[test]
3080 fn test_get_tree_includes_label_timestamp() {
3081 let mut sm = SessionManager::in_memory(Path::new("/tmp/test"));
3082 let msg_id = sm.append_message(&make_user_msg("mark this"));
3083 sm.session_mut()
3084 .append_label_change(&msg_id, Some("bookmark"))
3085 .unwrap();
3086
3087 let tree = sm.get_tree();
3088 let node = tree.iter().find(|n| n.entry.id() == msg_id);
3090 assert!(node.is_some());
3091 let node = node.unwrap();
3092 assert_eq!(node.label.as_deref(), Some("bookmark"));
3093 assert!(
3094 node.label_timestamp.is_some(),
3095 "label_timestamp should be populated in get_tree()"
3096 );
3097 }
3098
3099 #[test]
3102 fn test_parse_session_header_line_wrong_version() {
3103 let json = r#"{"type":"session","version":2,"id":"abc","timestamp":"2026-01-01T00:00:00Z","cwd":"/home"}"#;
3105 let result = parse_session_header_line(json);
3106 assert!(result.is_none());
3107 }
3108
3109 #[test]
3110 fn test_parse_session_header_line_empty_id() {
3111 let json = r#"{"type":"session","version":3,"id":"","timestamp":"2026-01-01T00:00:00Z","cwd":"/home"}"#;
3112 let result = parse_session_header_line(json);
3113 assert!(result.is_none());
3114 }
3115
3116 #[test]
3117 fn test_parse_session_header_line_empty_timestamp() {
3118 let json = r#"{"type":"session","version":3,"id":"abc","timestamp":"","cwd":"/home"}"#;
3119 let result = parse_session_header_line(json);
3120 assert!(result.is_none());
3121 }
3122
3123 #[test]
3124 fn test_parse_session_header_line_empty_cwd() {
3125 let json = r#"{"type":"session","version":3,"id":"abc","timestamp":"2026-01-01T00:00:00Z","cwd":""}"#;
3126 let result = parse_session_header_line(json);
3127 assert!(result.is_none());
3128 }
3129
3130 #[test]
3133 fn test_session_error_display() {
3134 assert_eq!(
3135 SessionError::NotFound("entry x".to_string()).to_string(),
3136 "not found: entry x"
3137 );
3138 assert_eq!(
3139 SessionError::InvalidSession("bad file".to_string()).to_string(),
3140 "invalid session: bad file"
3141 );
3142 assert_eq!(
3143 SessionError::InvalidEntry("bad line".to_string()).to_string(),
3144 "invalid entry: bad line"
3145 );
3146 assert_eq!(
3147 SessionError::InvalidForkTarget("wrong position".to_string()).to_string(),
3148 "invalid fork target: wrong position"
3149 );
3150 assert_eq!(
3151 SessionError::Storage("io error".to_string()).to_string(),
3152 "storage error: io error"
3153 );
3154 }
3155
3156 #[test]
3157 fn test_session_error_from_io_error() {
3158 let io_err = std::io::Error::new(std::io::ErrorKind::Other, "disk full");
3159 let session_err: SessionError = io_err.into();
3160 assert!(matches!(session_err, SessionError::Storage(_)));
3161 assert_eq!(session_err.to_string(), "storage error: disk full");
3162 }
3163
3164 #[test]
3165 fn test_session_error_from_json_error() {
3166 let json_err = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
3167 let session_err: SessionError = json_err.into();
3168 assert!(matches!(session_err, SessionError::InvalidEntry(_)));
3169 }
3170}