1pub mod context;
9pub mod cwd;
10pub mod entries;
11pub mod list;
12
13use std::collections::{HashMap, HashSet};
14use std::fs::{self, File, OpenOptions};
15use std::io::{self, Write};
16use std::path::{Path, PathBuf};
17use std::sync::atomic::{AtomicU64, Ordering};
18
19use pi_agent::AgentMessage;
20use serde_json::Value;
21use thiserror::Error;
22
23use super::config::{
24 PathInputOptions, get_agent_dir, get_sessions_dir, normalize_path, resolve_path,
25};
26use super::messages::{CustomMessageContent, MessageConversionError};
27
28pub use context::{
29 DEFAULT_THINKING_LEVEL, LeafRef, SessionContext, SessionModel, build_context_entries,
30 build_session_context, build_session_path, get_latest_compaction_entry,
31 session_entry_to_context_messages,
32};
33pub use cwd::{
34 MissingSessionCwdError, SessionCwdIssue, SessionCwdSource, assert_session_cwd_exists,
35 format_missing_session_cwd_error, format_missing_session_cwd_prompt,
36 get_missing_session_cwd_issue,
37};
38pub use entries::{
39 BranchSummaryEntry, CURRENT_SESSION_VERSION, CompactionEntry, CustomEntry, CustomMessageEntry,
40 FileEntry, LabelEntry, ModelChangeEntry, NO_MESSAGES_PLACEHOLDER, SessionEntry, SessionHeader,
41 SessionInfoEntry, SessionMessageEntry, ThinkingLevelChangeEntry, assert_valid_session_id,
42 create_session_id, generate_id, load_entries_from_file, migrate_session_entries, now_iso,
43 parse_session_entries, parse_session_entry_line, read_session_header,
44};
45pub use list::{
46 MAX_CONCURRENT_SESSION_INFO_LOADS, SessionInfo, SessionListProgress, build_session_info,
47 find_most_recent_session, list_all_sessions, list_sessions_for_cwd, list_sessions_from_dir,
48 session_cwd_matches,
49};
50
51use entries::{
52 file_entry_to_line, iso_to_millis, load_values_from_file, migrate_values_to_current,
53 path_exists, session_entry_to_line,
54};
55
56#[derive(Debug, Error)]
62pub enum SessionError {
63 #[error(
65 "Session id must be non-empty, contain only alphanumeric characters, '-', '_', and '.', and start and end with an alphanumeric character"
66 )]
67 InvalidSessionId,
68 #[error("Entry {0} not found")]
70 EntryNotFound(String),
71 #[error("Session file is not a valid pi session: {0}")]
73 InvalidSessionFile(String),
74 #[error("Cannot fork: source session file is empty or invalid: {0}")]
76 ForkSourceEmpty(String),
77 #[error("Cannot fork: source session has no header: {0}")]
79 ForkSourceNoHeader(String),
80 #[error("I/O error on {path}: {source}")]
82 Io {
83 path: String,
85 source: io::Error,
87 },
88 #[error("JSON error: {0}")]
90 Json(#[from] serde_json::Error),
91 #[error(transparent)]
93 Message(#[from] MessageConversionError),
94}
95
96#[derive(Clone, Debug, Default)]
104pub struct NewSessionOptions {
105 pub id: Option<String>,
107 pub parent_session: Option<String>,
109}
110
111#[derive(Clone, Debug, PartialEq)]
113pub struct SessionTreeNode {
114 pub entry: SessionEntry,
116 pub children: Vec<SessionTreeNode>,
118 pub label: Option<String>,
120 pub label_timestamp: Option<String>,
122}
123
124#[derive(Clone, Debug)]
129enum Leaf {
130 Null,
131 Id(String),
132 Tail,
134}
135
136#[derive(Clone, Debug, Default)]
138struct LabelMap {
139 entries: Vec<(String, String, String)>,
141 index: HashMap<String, usize>,
143}
144
145impl LabelMap {
146 fn clear(&mut self) {
147 self.entries.clear();
148 self.index.clear();
149 }
150
151 fn get(&self, target: &str) -> Option<&str> {
152 self.index.get(target).map(|&i| self.entries[i].1.as_str())
153 }
154
155 fn get_timestamp(&self, target: &str) -> Option<&str> {
156 self.index.get(target).map(|&i| self.entries[i].2.as_str())
157 }
158
159 fn set(&mut self, target: String, label: String, timestamp: String) {
161 if let Some(&i) = self.index.get(&target) {
162 self.entries[i].1 = label;
163 self.entries[i].2 = timestamp;
164 } else {
165 let i = self.entries.len();
166 self.index.insert(target.clone(), i);
167 self.entries.push((target, label, timestamp));
168 }
169 }
170
171 fn delete(&mut self, target: &str) {
172 let Some(i) = self.index.remove(target) else {
173 return;
174 };
175 self.entries.remove(i);
176 self.index.clear();
178 for (idx, (t, _, _)) in self.entries.iter().enumerate() {
179 self.index.insert(t.clone(), idx);
180 }
181 }
182
183 fn iter(&self) -> impl Iterator<Item = (&str, &str, &str)> {
184 self.entries
185 .iter()
186 .map(|(t, l, ts)| (t.as_str(), l.as_str(), ts.as_str()))
187 }
188}
189
190#[derive(Debug)]
199pub struct SessionManager {
200 session_id: String,
201 session_file: Option<String>,
202 session_dir: String,
203 cwd: String,
204 persist: bool,
205 flushed: bool,
206 file_entries: Vec<FileEntry>,
207 by_id: HashMap<String, usize>,
209 labels: LabelMap,
210 leaf: Leaf,
211}
212
213fn assemble_tree_node(
214 id: &str,
215 node_map: &HashMap<String, SessionTreeNode>,
216 children_of: &HashMap<String, Vec<String>>,
217) -> Option<SessionTreeNode> {
218 let mut node = node_map.get(id)?.clone();
219 node.children.clear();
220 if let Some(kids) = children_of.get(id) {
221 for kid in kids {
222 if let Some(child) = assemble_tree_node(kid, node_map, children_of) {
223 node.children.push(child);
224 }
225 }
226 node.children.sort_by(|a, b| {
227 let ta = a.entry.timestamp().and_then(iso_to_millis);
228 let tb = b.entry.timestamp().and_then(iso_to_millis);
229 match (ta, tb) {
230 (Some(x), Some(y)) => x.cmp(&y),
231 _ => std::cmp::Ordering::Equal,
232 }
233 });
234 }
235 Some(node)
236}
237
238impl SessionManager {
239 fn construct(
240 cwd: &str,
241 session_dir: &str,
242 session_file: Option<String>,
243 persist: bool,
244 options: Option<NewSessionOptions>,
245 ) -> Result<Self, SessionError> {
246 let cwd = path_to_string(&resolve_path(cwd));
247 let session_dir = path_to_string(&normalize_path(session_dir, PathInputOptions::new()));
248 if persist && !session_dir.is_empty() && !path_exists(Path::new(&session_dir)) {
249 fs::create_dir_all(&session_dir).map_err(|source| SessionError::Io {
250 path: session_dir.clone(),
251 source,
252 })?;
253 }
254
255 let mut sm = Self {
256 session_id: String::new(),
257 session_file: None,
258 session_dir,
259 cwd,
260 persist,
261 flushed: false,
262 file_entries: Vec::new(),
263 by_id: HashMap::new(),
264 labels: LabelMap::default(),
265 leaf: Leaf::Null,
266 };
267
268 if let Some(file) = session_file {
269 sm.set_session_file(&file)?;
270 } else {
271 sm.new_session(options)?;
272 }
273 Ok(sm)
274 }
275
276 pub fn set_session_file(&mut self, session_file: &str) -> Result<(), SessionError> {
283 let resolved = path_to_string(&resolve_path(session_file));
284 self.session_file = Some(resolved.clone());
285
286 if path_exists(Path::new(&resolved)) {
287 let mut values =
288 load_values_from_file(Path::new(&resolved)).map_err(|source| SessionError::Io {
289 path: resolved.clone(),
290 source,
291 })?;
292
293 if values.is_empty() {
294 let size = fs::metadata(&resolved)
295 .map_err(|source| SessionError::Io {
296 path: resolved.clone(),
297 source,
298 })?
299 .len();
300 if size > 0 {
301 return Err(SessionError::InvalidSessionFile(resolved));
302 }
303 self.new_session(None)?;
305 self.session_file = Some(resolved);
306 self.rewrite_file()?;
307 self.flushed = true;
308 return Ok(());
309 }
310
311 let header_id = values
312 .iter()
313 .find(|v| v.get("type").and_then(Value::as_str) == Some("session"))
314 .and_then(|h| h.get("id").and_then(Value::as_str))
315 .map(str::to_owned);
316 self.session_id = header_id.unwrap_or_else(create_session_id);
317
318 let migrated = migrate_values_to_current(&mut values);
319 self.file_entries = values
320 .into_iter()
321 .map(entries::file_entry_from_value)
322 .collect();
323 if migrated {
324 self.rewrite_file()?;
325 }
326 self.build_index();
327 self.flushed = true;
328 } else {
329 let explicit = resolved;
330 self.new_session(None)?;
331 self.session_file = Some(explicit);
332 }
333 Ok(())
334 }
335
336 pub fn new_session(
344 &mut self,
345 options: Option<NewSessionOptions>,
346 ) -> Result<Option<String>, SessionError> {
347 if let Some(id) = options.as_ref().and_then(|opts| opts.id.as_ref()) {
348 assert_valid_session_id(id)?;
349 }
350 let options = options.unwrap_or_default();
351 self.session_id = options.id.unwrap_or_else(create_session_id);
352 let timestamp = now_iso();
353 let header = SessionHeader::new(
354 self.session_id.clone(),
355 timestamp.clone(),
356 self.cwd.clone(),
357 options.parent_session,
358 );
359 self.file_entries = vec![FileEntry::Header(header)];
360 self.by_id.clear();
361 self.labels.clear();
362 self.leaf = Leaf::Null;
363 self.flushed = false;
364
365 if self.persist {
366 let file_name = session_file_name(×tamp, &self.session_id);
367 self.session_file = Some(
368 Path::new(&self.session_dir)
369 .join(file_name)
370 .to_string_lossy()
371 .into_owned(),
372 );
373 } else {
374 self.session_file = None;
375 }
376 Ok(self.session_file.clone())
377 }
378
379 fn build_index(&mut self) {
380 self.by_id.clear();
381 self.labels.clear();
382 self.leaf = Leaf::Null;
383 for (idx, fe) in self.file_entries.iter().enumerate() {
384 if fe.is_session_header() {
385 continue;
386 }
387 let Some(entry) = fe.entry() else {
388 continue;
389 };
390 if let Some(id) = entry.id() {
391 self.by_id.insert(id.to_owned(), idx);
392 self.leaf = Leaf::Id(id.to_owned());
393 } else {
394 self.leaf = Leaf::Tail;
395 }
396 if let Some((target, label)) = entry.label_fields() {
397 match (target, label) {
398 (Some(t), Some(l)) if !l.is_empty() => {
399 let ts = entry.timestamp().unwrap_or("").to_owned();
400 self.labels.set(t.to_owned(), l.to_owned(), ts);
401 }
402 (Some(t), _) => {
403 self.labels.delete(t);
404 }
405 _ => {}
406 }
407 }
408 }
409 }
410
411 fn rewrite_file(&self) -> Result<(), SessionError> {
412 if !self.persist {
413 return Ok(());
414 }
415 let Some(ref path) = self.session_file else {
416 return Ok(());
417 };
418 atomic_replace_file(Path::new(path), |file| {
419 for entry in &self.file_entries {
420 let line = file_entry_to_line(entry)?;
421 writeln!(file, "{line}").map_err(|source| SessionError::Io {
422 path: path.clone(),
423 source,
424 })?;
425 }
426 Ok(())
427 })
428 }
429
430 fn persist_entry_at(&mut self, idx: usize) -> Result<(), SessionError> {
431 if !self.persist {
432 return Ok(());
433 }
434 let Some(path) = self.session_file.clone() else {
435 return Ok(());
436 };
437
438 let has_assistant = self
439 .file_entries
440 .iter()
441 .filter_map(FileEntry::entry)
442 .any(SessionEntry::is_assistant_message);
443
444 if !has_assistant {
445 if self.flushed
446 && let Some(entry) = self.file_entries.get(idx).and_then(FileEntry::entry)
447 {
448 append_line(&path, entry)?;
449 }
450 return Ok(());
452 }
453
454 if self.flushed
455 && let Some(entry) = self.file_entries.get(idx).and_then(FileEntry::entry)
456 {
457 append_line(&path, entry)?;
458 } else {
459 let mut contents = String::new();
462 for fe in &self.file_entries {
463 contents.push_str(&file_entry_to_line(fe)?);
464 contents.push('\n');
465 }
466 let mut file = OpenOptions::new()
467 .write(true)
468 .create_new(true)
469 .open(&path)
470 .map_err(|source| SessionError::Io {
471 path: path.clone(),
472 source,
473 })?;
474 if let Err(source) = file
475 .write_all(contents.as_bytes())
476 .and_then(|()| file.sync_all())
477 {
478 drop(file);
479 let _ = fs::remove_file(&path);
480 return Err(SessionError::Io {
481 path: path.clone(),
482 source,
483 });
484 }
485 self.flushed = true;
486 }
487 Ok(())
488 }
489
490 fn append_entry(&mut self, entry: SessionEntry) -> Result<String, SessionError> {
491 let id = entry.id().unwrap_or("").to_owned();
492 let previous_leaf = self.leaf.clone();
493 let previous_flushed = self.flushed;
494 let previous_index = if id.is_empty() {
495 None
496 } else {
497 self.by_id.insert(id.clone(), self.file_entries.len())
498 };
499 self.file_entries.push(FileEntry::Entry(entry));
500 let idx = self.file_entries.len() - 1;
501 self.leaf = if id.is_empty() {
502 Leaf::Tail
503 } else {
504 Leaf::Id(id.clone())
505 };
506 if let Err(err) = self.persist_entry_at(idx) {
507 self.file_entries.pop();
508 if !id.is_empty() {
509 match previous_index {
510 Some(index) => {
511 self.by_id.insert(id.clone(), index);
512 }
513 None => {
514 self.by_id.remove(&id);
515 }
516 }
517 }
518 self.leaf = previous_leaf;
519 self.flushed = previous_flushed;
520 return Err(err);
521 }
522 Ok(id)
523 }
524
525 fn next_id(&self) -> String {
526 generate_id(|c| self.by_id.contains_key(c))
527 }
528
529 #[must_use]
533 pub const fn is_persisted(&self) -> bool {
534 self.persist
535 }
536
537 #[must_use]
539 pub fn get_cwd(&self) -> &str {
540 &self.cwd
541 }
542
543 #[must_use]
545 pub fn get_session_dir(&self) -> &str {
546 &self.session_dir
547 }
548
549 #[must_use]
551 pub fn uses_default_session_dir(&self) -> bool {
552 let default = default_session_dir_path(&self.cwd, &get_agent_dir());
553 self.session_dir == default
554 }
555
556 #[must_use]
558 pub fn get_session_id(&self) -> &str {
559 &self.session_id
560 }
561
562 #[must_use]
564 pub fn get_session_file(&self) -> Option<&str> {
565 self.session_file.as_deref()
566 }
567
568 #[must_use]
570 pub fn get_leaf_id(&self) -> Option<&str> {
571 match &self.leaf {
572 Leaf::Id(id) => Some(id.as_str()),
573 Leaf::Null | Leaf::Tail => None,
574 }
575 }
576
577 #[must_use]
579 pub fn get_leaf_entry(&self) -> Option<&SessionEntry> {
580 match &self.leaf {
581 Leaf::Id(id) => self.get_entry(id),
582 Leaf::Null | Leaf::Tail => None,
583 }
584 }
585
586 #[must_use]
588 pub fn get_entry(&self, id: &str) -> Option<&SessionEntry> {
589 self.by_id
590 .get(id)
591 .and_then(|&i| self.file_entries.get(i))
592 .and_then(FileEntry::entry)
593 }
594
595 #[must_use]
597 pub fn get_children(&self, parent_id: &str) -> Vec<&SessionEntry> {
598 self.file_entries
599 .iter()
600 .filter_map(FileEntry::entry)
601 .filter(|e| e.parent_id() == Some(parent_id))
602 .collect()
603 }
604
605 #[must_use]
607 pub fn get_label(&self, id: &str) -> Option<&str> {
608 self.labels.get(id)
609 }
610
611 #[must_use]
613 pub fn get_header(&self) -> Option<&SessionHeader> {
614 self.file_entries.iter().find_map(FileEntry::header)
615 }
616
617 #[must_use]
619 pub fn get_entries(&self) -> Vec<&SessionEntry> {
620 self.file_entries
621 .iter()
622 .filter_map(FileEntry::entry)
623 .collect()
624 }
625
626 #[must_use]
628 pub fn get_session_name(&self) -> Option<String> {
629 for entry in self.get_entries().into_iter().rev() {
630 if let Some(name) = entry.session_info_name() {
631 return name
632 .map(str::trim)
633 .filter(|s| !s.is_empty())
634 .map(str::to_owned);
635 }
636 }
637 None
638 }
639
640 pub fn append_message(&mut self, message: &AgentMessage) -> Result<String, SessionError> {
648 let id = self.next_id();
649 let parent = self.leaf_parent();
650 let timestamp = now_iso();
651 let value = serde_json::json!({
652 "type": "message",
653 "id": id,
654 "parentId": parent,
655 "timestamp": timestamp,
656 "message": message_to_value(message)?,
657 });
658 let entry: SessionEntry = serde_json::from_value(value)?;
659 self.append_entry(entry)
660 }
661
662 pub fn append_thinking_level_change(
668 &mut self,
669 thinking_level: &str,
670 ) -> Result<String, SessionError> {
671 let id = self.next_id();
672 let value = serde_json::json!({
673 "type": "thinking_level_change",
674 "id": id,
675 "parentId": self.leaf_parent(),
676 "timestamp": now_iso(),
677 "thinkingLevel": thinking_level,
678 });
679 let entry: SessionEntry = serde_json::from_value(value)?;
680 self.append_entry(entry)
681 }
682
683 pub fn append_model_change(
689 &mut self,
690 provider: &str,
691 model_id: &str,
692 ) -> Result<String, SessionError> {
693 let id = self.next_id();
694 let value = serde_json::json!({
695 "type": "model_change",
696 "id": id,
697 "parentId": self.leaf_parent(),
698 "timestamp": now_iso(),
699 "provider": provider,
700 "modelId": model_id,
701 });
702 let entry: SessionEntry = serde_json::from_value(value)?;
703 self.append_entry(entry)
704 }
705
706 pub fn append_compaction(
712 &mut self,
713 summary: &str,
714 first_kept_entry_id: &str,
715 tokens_before: i64,
716 details: Option<Value>,
717 from_hook: Option<bool>,
718 ) -> Result<String, SessionError> {
719 let id = self.next_id();
720 let mut value = serde_json::json!({
721 "type": "compaction",
722 "id": id,
723 "parentId": self.leaf_parent(),
724 "timestamp": now_iso(),
725 "summary": summary,
726 "firstKeptEntryId": first_kept_entry_id,
727 "tokensBefore": tokens_before,
728 });
729 if let Some(d) = details
730 && let Some(obj) = value.as_object_mut()
731 {
732 obj.insert("details".to_owned(), d);
733 }
734 if let Some(fh) = from_hook
735 && let Some(obj) = value.as_object_mut()
736 {
737 obj.insert("fromHook".to_owned(), Value::Bool(fh));
738 }
739 let entry: SessionEntry = serde_json::from_value(value)?;
740 self.append_entry(entry)
741 }
742
743 pub fn append_custom_entry(
749 &mut self,
750 custom_type: &str,
751 data: Option<Value>,
752 ) -> Result<String, SessionError> {
753 let id = self.next_id();
754 let mut value = serde_json::json!({
755 "type": "custom",
756 "customType": custom_type,
757 "id": id,
758 "parentId": self.leaf_parent(),
759 "timestamp": now_iso(),
760 });
761 if let Some(d) = data
762 && let Some(obj) = value.as_object_mut()
763 {
764 obj.insert("data".to_owned(), d);
765 }
766 let entry: SessionEntry = serde_json::from_value(value)?;
767 self.append_entry(entry)
768 }
769
770 pub fn append_session_info(&mut self, name: &str) -> Result<String, SessionError> {
776 let sanitized = {
778 let mut result = String::with_capacity(name.len());
779 let mut chars = name.chars().peekable();
780 while let Some(c) = chars.next() {
781 if c == '\r' || c == '\n' {
782 while matches!(chars.peek(), Some('\r' | '\n')) {
783 chars.next();
784 }
785 result.push(' ');
786 } else {
787 result.push(c);
788 }
789 }
790 result.trim().to_owned()
791 };
792 let id = self.next_id();
793 let value = serde_json::json!({
794 "type": "session_info",
795 "id": id,
796 "parentId": self.leaf_parent(),
797 "timestamp": now_iso(),
798 "name": sanitized,
799 });
800 let entry: SessionEntry = serde_json::from_value(value)?;
801 self.append_entry(entry)
802 }
803
804 pub fn append_custom_message_entry(
810 &mut self,
811 custom_type: &str,
812 content: &CustomMessageContent,
813 display: bool,
814 details: Option<Value>,
815 ) -> Result<String, SessionError> {
816 let id = self.next_id();
817 let mut value = serde_json::json!({
818 "type": "custom_message",
819 "customType": custom_type,
820 "content": content,
821 "display": display,
822 "id": id,
823 "parentId": self.leaf_parent(),
824 "timestamp": now_iso(),
825 });
826 if let Some(d) = details
827 && let Some(obj) = value.as_object_mut()
828 {
829 obj.insert("details".to_owned(), d);
830 }
831 let entry: SessionEntry = serde_json::from_value(value)?;
832 self.append_entry(entry)
833 }
834
835 pub fn append_label_change(
841 &mut self,
842 target_id: &str,
843 label: Option<&str>,
844 ) -> Result<String, SessionError> {
845 if !self.by_id.contains_key(target_id) {
846 return Err(SessionError::EntryNotFound(target_id.to_owned()));
847 }
848 let id = self.next_id();
849 let timestamp = now_iso();
850 let mut value = serde_json::json!({
851 "type": "label",
852 "id": id,
853 "parentId": self.leaf_parent(),
854 "timestamp": timestamp,
855 "targetId": target_id,
856 });
857 if let Some(l) = label
858 && let Some(obj) = value.as_object_mut()
859 {
860 obj.insert("label".to_owned(), Value::String(l.to_owned()));
861 }
862 let entry: SessionEntry = serde_json::from_value(value)?;
863 let result_id = self.append_entry(entry)?;
864 match label {
865 Some(l) if !l.is_empty() => {
866 self.labels
867 .set(target_id.to_owned(), l.to_owned(), timestamp);
868 }
869 _ => {
870 self.labels.delete(target_id);
871 }
872 }
873 Ok(result_id)
874 }
875
876 #[must_use]
883 pub fn get_branch(&self, from_id: Option<&str>) -> Vec<&SessionEntry> {
884 let start = from_id.or(match &self.leaf {
885 Leaf::Id(id) => Some(id.as_str()),
886 Leaf::Null | Leaf::Tail => None,
887 });
888 let Some(start_id) = start.filter(|s| !s.is_empty()) else {
889 return Vec::new();
890 };
891 let mut path = Vec::new();
892 let mut current = self.get_entry(start_id);
893 while let Some(entry) = current {
894 path.push(entry);
895 current = entry.parent_id().and_then(|pid| self.get_entry(pid));
896 }
897 path.reverse();
898 path
899 }
900
901 #[must_use]
903 pub fn build_context_entries(&self) -> Vec<&SessionEntry> {
904 let entries = self.get_entries();
905 let leaf = self.leaf_ref();
906 context::build_context_entries(&entries, leaf)
907 }
908
909 pub fn build_session_context(&self) -> Result<SessionContext, MessageConversionError> {
915 let entries = self.get_entries();
916 let leaf = self.leaf_ref();
917 context::build_session_context(&entries, leaf)
918 }
919
920 #[must_use]
922 pub fn get_tree(&self) -> Vec<SessionTreeNode> {
923 let entries = self.get_entries();
924 let mut node_map: HashMap<String, SessionTreeNode> = HashMap::new();
925 let mut order: Vec<String> = Vec::new();
926
927 for entry in &entries {
928 let Some(id) = entry.id() else {
929 continue;
930 };
931 if !node_map.contains_key(id) {
932 order.push(id.to_owned());
933 }
934 let label = self.labels.get(id).map(str::to_owned);
935 let label_timestamp = self.labels.get_timestamp(id).map(str::to_owned);
936 node_map.insert(
937 id.to_owned(),
938 SessionTreeNode {
939 entry: (*entry).clone(),
940 children: Vec::new(),
941 label,
942 label_timestamp,
943 },
944 );
945 }
946
947 let mut roots: Vec<String> = Vec::new();
948 let mut child_links: Vec<(String, String)> = Vec::new(); for entry in &entries {
951 let Some(id) = entry.id() else {
952 continue;
953 };
954 match entry.parent_id() {
955 Some(pid) if pid != id && node_map.contains_key(pid) => {
956 child_links.push((pid.to_owned(), id.to_owned()));
957 }
958 None | Some(_) => roots.push(id.to_owned()),
959 }
960 }
961
962 let mut children_of: HashMap<String, Vec<String>> = HashMap::new();
963 for (parent, child) in child_links {
964 children_of.entry(parent).or_default().push(child);
965 }
966
967 let mut seen = HashSet::new();
969 let mut root_nodes = Vec::new();
970 for id in roots {
971 if seen.insert(id.clone())
972 && let Some(node) = assemble_tree_node(&id, &node_map, &children_of)
973 {
974 root_nodes.push(node);
975 }
976 }
977 let _ = order;
980 root_nodes
981 }
982
983 pub fn branch(&mut self, branch_from_id: &str) -> Result<(), SessionError> {
991 if !self.by_id.contains_key(branch_from_id) {
992 return Err(SessionError::EntryNotFound(branch_from_id.to_owned()));
993 }
994 self.leaf = Leaf::Id(branch_from_id.to_owned());
995 Ok(())
996 }
997
998 pub fn reset_leaf(&mut self) {
1000 self.leaf = Leaf::Null;
1001 }
1002
1003 pub fn branch_with_summary(
1009 &mut self,
1010 branch_from_id: Option<&str>,
1011 summary: &str,
1012 details: Option<Value>,
1013 from_hook: Option<bool>,
1014 ) -> Result<String, SessionError> {
1015 if let Some(id) = branch_from_id {
1016 if !self.by_id.contains_key(id) {
1017 return Err(SessionError::EntryNotFound(id.to_owned()));
1018 }
1019 self.leaf = Leaf::Id(id.to_owned());
1020 } else {
1021 self.leaf = Leaf::Null;
1022 }
1023 let id = self.next_id();
1024 let from_id = branch_from_id.unwrap_or("root");
1025 let mut value = serde_json::json!({
1026 "type": "branch_summary",
1027 "id": id,
1028 "parentId": branch_from_id,
1029 "timestamp": now_iso(),
1030 "fromId": from_id,
1031 "summary": summary,
1032 });
1033 if let Some(d) = details
1034 && let Some(obj) = value.as_object_mut()
1035 {
1036 obj.insert("details".to_owned(), d);
1037 }
1038 if let Some(fh) = from_hook
1039 && let Some(obj) = value.as_object_mut()
1040 {
1041 obj.insert("fromHook".to_owned(), Value::Bool(fh));
1042 }
1043 let entry: SessionEntry = serde_json::from_value(value)?;
1044 self.append_entry(entry)
1045 }
1046
1047 pub fn create_branched_session(
1057 &mut self,
1058 leaf_id: &str,
1059 ) -> Result<Option<String>, SessionError> {
1060 let previous_session_file = self.session_file.clone();
1061 let path: Vec<SessionEntry> = self
1062 .get_branch(Some(leaf_id))
1063 .into_iter()
1064 .cloned()
1065 .collect();
1066 if path.is_empty() {
1067 return Err(SessionError::EntryNotFound(leaf_id.to_owned()));
1068 }
1069
1070 let mut path_without_labels: Vec<SessionEntry> = Vec::new();
1071 let mut path_parent: Option<String> = None;
1072 for entry in path {
1073 if entry.discriminant() == "label" {
1074 continue;
1075 }
1076 let mut cloned = entry;
1077 cloned.set_parent_id(path_parent.clone());
1078 path_parent = cloned.id().map(str::to_owned);
1079 path_without_labels.push(cloned);
1080 }
1081
1082 let new_session_id = create_session_id();
1083 let timestamp = now_iso();
1084 let file_name = session_file_name(×tamp, &new_session_id);
1085 let new_session_file = Path::new(&self.session_dir)
1086 .join(&file_name)
1087 .to_string_lossy()
1088 .into_owned();
1089
1090 let header = SessionHeader::new(
1091 new_session_id.clone(),
1092 timestamp,
1093 self.cwd.clone(),
1094 if self.persist {
1095 previous_session_file
1096 } else {
1097 None
1098 },
1099 );
1100
1101 let path_ids: HashSet<String> = path_without_labels
1102 .iter()
1103 .filter_map(|e| e.id().map(str::to_owned))
1104 .collect();
1105
1106 let labels_to_write: Vec<(String, String, String)> = self
1107 .labels
1108 .iter()
1109 .filter(|(t, _, _)| path_ids.contains(*t))
1110 .map(|(t, l, ts)| (t.to_owned(), l.to_owned(), ts.to_owned()))
1111 .collect();
1112
1113 let mut collision: HashSet<String> = path_ids;
1114 let mut parent = path_without_labels
1115 .last()
1116 .and_then(|e| e.id().map(str::to_owned));
1117 let mut label_entries: Vec<SessionEntry> = Vec::new();
1118 for (target, label, ts) in labels_to_write {
1119 let lid = generate_id(|c| collision.contains(c));
1120 collision.insert(lid.clone());
1121 let value = serde_json::json!({
1122 "type": "label",
1123 "id": lid,
1124 "parentId": parent,
1125 "timestamp": ts,
1126 "targetId": target,
1127 "label": label,
1128 });
1129 let entry: SessionEntry = serde_json::from_value(value)?;
1130 parent = entry.id().map(str::to_owned);
1131 label_entries.push(entry);
1132 }
1133
1134 let mut file_entries = vec![FileEntry::Header(header)];
1135 file_entries.extend(path_without_labels.into_iter().map(FileEntry::Entry));
1136 file_entries.extend(label_entries.into_iter().map(FileEntry::Entry));
1137
1138 self.file_entries = file_entries;
1139 self.session_id = new_session_id;
1140 self.build_index();
1141
1142 if self.persist {
1143 self.session_file = Some(new_session_file.clone());
1144 let has_assistant = self
1145 .file_entries
1146 .iter()
1147 .filter_map(FileEntry::entry)
1148 .any(SessionEntry::is_assistant_message);
1149 if has_assistant {
1150 self.rewrite_file()?;
1151 self.flushed = true;
1152 } else {
1153 self.flushed = false;
1154 }
1155 Ok(Some(new_session_file))
1156 } else {
1157 Ok(None)
1159 }
1160 }
1161
1162 pub fn create(
1170 cwd: &str,
1171 session_dir: Option<&str>,
1172 options: Option<NewSessionOptions>,
1173 ) -> Result<Self, SessionError> {
1174 let dir = match session_dir {
1175 Some(d) => path_to_string(&normalize_path(d, PathInputOptions::new())),
1176 None => default_session_dir(cwd, &get_agent_dir())?,
1177 };
1178 Self::construct(cwd, &dir, None, true, options)
1179 }
1180
1181 pub fn open(
1188 path: &str,
1189 session_dir: Option<&str>,
1190 cwd_override: Option<&str>,
1191 ) -> Result<Self, SessionError> {
1192 let resolved = path_to_string(&resolve_path(path));
1193 let entries = load_entries_from_file(Path::new(&resolved));
1194 let header_cwd = entries
1195 .iter()
1196 .find_map(FileEntry::header)
1197 .and_then(|h| h.cwd.clone());
1198 let cwd = cwd_override.map_or_else(
1199 || {
1200 header_cwd.unwrap_or_else(|| {
1201 std::env::current_dir()
1202 .map_or_else(|_| ".".to_owned(), |p| p.to_string_lossy().into_owned())
1203 })
1204 },
1205 str::to_owned,
1206 );
1207 let dir = match session_dir {
1208 Some(d) => path_to_string(&normalize_path(d, PathInputOptions::new())),
1209 None => Path::new(&resolved)
1210 .parent()
1211 .map_or_else(|| ".".to_owned(), |p| p.to_string_lossy().into_owned()),
1212 };
1213 Self::construct(&cwd, &dir, Some(resolved), true, None)
1214 }
1215
1216 pub fn continue_recent(cwd: &str, session_dir: Option<&str>) -> Result<Self, SessionError> {
1222 let dir = match session_dir {
1223 Some(d) => path_to_string(&normalize_path(d, PathInputOptions::new())),
1224 None => default_session_dir(cwd, &get_agent_dir())?,
1225 };
1226 let filter_cwd =
1227 session_dir.is_some() && dir != default_session_dir_path(cwd, &get_agent_dir());
1228 let most_recent =
1229 find_most_recent_session(Path::new(&dir), if filter_cwd { Some(cwd) } else { None });
1230 match most_recent {
1231 Some(f) => Self::construct(
1232 cwd,
1233 &dir,
1234 Some(f.to_string_lossy().into_owned()),
1235 true,
1236 None,
1237 ),
1238 None => Self::construct(cwd, &dir, None, true, None),
1239 }
1240 }
1241
1242 pub fn in_memory(
1248 cwd: Option<&str>,
1249 options: Option<NewSessionOptions>,
1250 ) -> Result<Self, SessionError> {
1251 let cwd = cwd.map_or_else(
1252 || {
1253 std::env::current_dir()
1254 .map_or_else(|_| ".".to_owned(), |p| p.to_string_lossy().into_owned())
1255 },
1256 str::to_owned,
1257 );
1258 Self::construct(&cwd, "", None, false, options)
1259 }
1260
1261 pub fn fork_from(
1270 source_path: &str,
1271 target_cwd: &str,
1272 session_dir: Option<&str>,
1273 options: Option<NewSessionOptions>,
1274 ) -> Result<Self, SessionError> {
1275 let resolved_source = path_to_string(&resolve_path(source_path));
1276 let resolved_target = path_to_string(&resolve_path(target_cwd));
1277 let values = load_values_from_file(Path::new(&resolved_source)).map_err(|source| {
1278 SessionError::Io {
1279 path: resolved_source.clone(),
1280 source,
1281 }
1282 })?;
1283 if values.is_empty() {
1284 return Err(SessionError::ForkSourceEmpty(resolved_source));
1285 }
1286 if !values
1287 .iter()
1288 .any(|v| v.get("type").and_then(Value::as_str) == Some("session"))
1289 {
1290 return Err(SessionError::ForkSourceNoHeader(resolved_source));
1291 }
1292
1293 let dir = match session_dir {
1294 Some(d) => path_to_string(&normalize_path(d, PathInputOptions::new())),
1295 None => default_session_dir(&resolved_target, &get_agent_dir())?,
1296 };
1297 if !path_exists(Path::new(&dir)) {
1298 fs::create_dir_all(&dir).map_err(|source| SessionError::Io {
1299 path: dir.clone(),
1300 source,
1301 })?;
1302 }
1303
1304 if let Some(id) = options.as_ref().and_then(|opts| opts.id.as_ref()) {
1305 assert_valid_session_id(id)?;
1306 }
1307 let options = options.unwrap_or_default();
1308 let new_session_id = options.id.unwrap_or_else(create_session_id);
1309 let timestamp = now_iso();
1310 let file_name = session_file_name(×tamp, &new_session_id);
1311 let new_session_file = Path::new(&dir)
1312 .join(file_name)
1313 .to_string_lossy()
1314 .into_owned();
1315
1316 let header = SessionHeader::new(
1317 new_session_id,
1318 timestamp,
1319 resolved_target.clone(),
1320 Some(resolved_source),
1321 );
1322
1323 let mut file = OpenOptions::new()
1324 .write(true)
1325 .create_new(true)
1326 .open(&new_session_file)
1327 .map_err(|source| SessionError::Io {
1328 path: new_session_file.clone(),
1329 source,
1330 })?;
1331 writeln!(file, "{}", serde_json::to_string(&header)?).map_err(|source| {
1332 SessionError::Io {
1333 path: new_session_file.clone(),
1334 source,
1335 }
1336 })?;
1337 for value in &values {
1338 if value.get("type").and_then(Value::as_str) == Some("session") {
1339 continue;
1340 }
1341 writeln!(file, "{}", serde_json::to_string(value)?).map_err(|source| {
1342 SessionError::Io {
1343 path: new_session_file.clone(),
1344 source,
1345 }
1346 })?;
1347 }
1348 drop(file);
1349
1350 Self::construct(&resolved_target, &dir, Some(new_session_file), true, None)
1351 }
1352
1353 pub async fn list(
1359 cwd: &str,
1360 session_dir: Option<&str>,
1361 on_progress: Option<SessionListProgress<'_>>,
1362 ) -> Result<Vec<SessionInfo>, SessionError> {
1363 let dir = match session_dir {
1364 Some(d) => path_to_string(&normalize_path(d, PathInputOptions::new())),
1365 None => default_session_dir(cwd, &get_agent_dir())?,
1366 };
1367 let filter_cwd =
1368 session_dir.is_some() && dir != default_session_dir_path(cwd, &get_agent_dir());
1369 Ok(list_sessions_for_cwd(cwd, Path::new(&dir), filter_cwd, on_progress).await)
1370 }
1371
1372 pub async fn list_all(
1374 session_dir: Option<&str>,
1375 on_progress: Option<SessionListProgress<'_>>,
1376 ) -> Vec<SessionInfo> {
1377 let custom =
1378 session_dir.map(|d| path_to_string(&normalize_path(d, PathInputOptions::new())));
1379 let root = get_sessions_dir();
1380 list_all_sessions(&root, custom.as_deref().map(Path::new), on_progress).await
1381 }
1382
1383 fn leaf_parent(&self) -> Option<String> {
1386 match &self.leaf {
1387 Leaf::Id(id) => Some(id.clone()),
1388 Leaf::Null | Leaf::Tail => None,
1389 }
1390 }
1391
1392 fn leaf_ref(&self) -> LeafRef<'_> {
1393 match &self.leaf {
1394 Leaf::Null => LeafRef::Null,
1395 Leaf::Id(id) => LeafRef::Id(id.as_str()),
1396 Leaf::Tail => LeafRef::Last,
1397 }
1398 }
1399}
1400
1401impl SessionCwdSource for SessionManager {
1402 fn get_cwd(&self) -> &str {
1403 &self.cwd
1404 }
1405 fn get_session_file(&self) -> Option<&str> {
1406 self.session_file.as_deref()
1407 }
1408}
1409
1410#[must_use]
1419pub fn encode_cwd_for_session_dir(resolved_cwd: &str) -> String {
1420 let stripped = resolved_cwd
1421 .strip_prefix('/')
1422 .or_else(|| resolved_cwd.strip_prefix('\\'))
1423 .unwrap_or(resolved_cwd);
1424 let safe: String = stripped
1425 .chars()
1426 .map(|c| {
1427 if matches!(c, '/' | '\\' | ':') {
1428 '-'
1429 } else {
1430 c
1431 }
1432 })
1433 .collect();
1434 format!("--{safe}--")
1435}
1436
1437#[must_use]
1439pub fn default_session_dir_path(cwd: &str, agent_dir: &Path) -> String {
1440 let resolved_cwd = path_to_string(&resolve_path(cwd));
1441 let resolved_agent = path_to_string(&resolve_path(agent_dir.to_string_lossy()));
1442 let name = encode_cwd_for_session_dir(&resolved_cwd);
1443 Path::new(&resolved_agent)
1444 .join("sessions")
1445 .join(name)
1446 .to_string_lossy()
1447 .into_owned()
1448}
1449
1450pub fn default_session_dir(cwd: &str, agent_dir: &Path) -> Result<String, SessionError> {
1456 let dir = default_session_dir_path(cwd, agent_dir);
1457 if !path_exists(Path::new(&dir)) {
1458 fs::create_dir_all(&dir).map_err(|source| SessionError::Io {
1459 path: dir.clone(),
1460 source,
1461 })?;
1462 }
1463 Ok(dir)
1464}
1465
1466fn session_file_name(timestamp_iso: &str, session_id: &str) -> String {
1467 let file_ts: String = timestamp_iso
1468 .chars()
1469 .map(|c| if c == ':' || c == '.' { '-' } else { c })
1470 .collect();
1471 format!("{file_ts}_{session_id}.jsonl")
1472}
1473
1474fn path_to_string(path: &Path) -> String {
1475 path.to_string_lossy().into_owned()
1476}
1477
1478fn append_line(path: &str, entry: &SessionEntry) -> Result<(), SessionError> {
1479 let line = session_entry_to_line(entry)?;
1480 let mut record = String::with_capacity(line.len() + 1);
1481 record.push_str(&line);
1482 record.push('\n');
1483 let mut file = OpenOptions::new()
1484 .append(true)
1485 .create(true)
1486 .open(path)
1487 .map_err(|source| SessionError::Io {
1488 path: path.to_owned(),
1489 source,
1490 })?;
1491 file.write_all(record.as_bytes())
1492 .map_err(|source| SessionError::Io {
1493 path: path.to_owned(),
1494 source,
1495 })
1496}
1497
1498fn message_to_value(message: &AgentMessage) -> Result<Value, SessionError> {
1499 Ok(serde_json::to_value(message)?)
1500}
1501
1502static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
1503
1504fn atomic_replace_file(
1505 path: &Path,
1506 write_contents: impl FnOnce(&mut File) -> Result<(), SessionError>,
1507) -> Result<(), SessionError> {
1508 let path_text = path.to_string_lossy().into_owned();
1509 let parent = path.parent().unwrap_or_else(|| Path::new("."));
1510 let file_name = path
1511 .file_name()
1512 .and_then(|name| name.to_str())
1513 .unwrap_or("session");
1514 let mut temp_path = PathBuf::new();
1515 let mut temp_file = None;
1516 for _ in 0..100 {
1517 let suffix = TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
1518 temp_path = parent.join(format!(".{file_name}.tmp-{}-{suffix}", std::process::id()));
1519 match OpenOptions::new()
1520 .write(true)
1521 .create_new(true)
1522 .open(&temp_path)
1523 {
1524 Ok(file) => {
1525 temp_file = Some(file);
1526 break;
1527 }
1528 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
1529 Err(source) => {
1530 return Err(SessionError::Io {
1531 path: temp_path.to_string_lossy().into_owned(),
1532 source,
1533 });
1534 }
1535 }
1536 }
1537 let Some(mut file) = temp_file else {
1538 return Err(SessionError::Io {
1539 path: path_text,
1540 source: io::Error::new(
1541 io::ErrorKind::AlreadyExists,
1542 "unable to allocate temporary session file",
1543 ),
1544 });
1545 };
1546
1547 let result = write_contents(&mut file)
1548 .and_then(|()| {
1549 file.flush().map_err(|source| SessionError::Io {
1550 path: temp_path.to_string_lossy().into_owned(),
1551 source,
1552 })
1553 })
1554 .and_then(|()| {
1555 file.sync_all().map_err(|source| SessionError::Io {
1556 path: temp_path.to_string_lossy().into_owned(),
1557 source,
1558 })
1559 });
1560 drop(file);
1561 if let Err(err) = result {
1562 let _ = fs::remove_file(&temp_path);
1563 return Err(err);
1564 }
1565
1566 if let Err(source) = fs::rename(&temp_path, path) {
1567 let _ = fs::remove_file(&temp_path);
1568 return Err(SessionError::Io {
1569 path: path_text,
1570 source,
1571 });
1572 }
1573 sync_parent_directory(parent).map_err(|source| SessionError::Io {
1574 path: parent.to_string_lossy().into_owned(),
1575 source,
1576 })
1577}
1578
1579#[cfg(unix)]
1580fn sync_parent_directory(parent: &Path) -> io::Result<()> {
1581 File::open(parent)?.sync_all()
1582}
1583
1584#[cfg(not(unix))]
1585fn sync_parent_directory(_parent: &Path) -> io::Result<()> {
1586 Ok(())
1587}
1588
1589#[cfg(test)]
1594mod tests {
1595 use super::*;
1596 use pi_ai::{Message, TextContent, UserMessage, UserMessageContent};
1597 use serde_json::json;
1598 use tempfile::tempdir;
1599
1600 type TestResult = Result<(), Box<dyn std::error::Error>>;
1601
1602 #[test]
1603 fn atomic_replace_failure_preserves_live_file() -> TestResult {
1604 let dir = tempdir()?;
1605 let file = dir.path().join("atomic.jsonl");
1606 fs::write(&file, "original\n")?;
1607
1608 let result = atomic_replace_file(&file, |temp| {
1609 temp.write_all(b"partial replacement\n")
1610 .map_err(|source| SessionError::Io {
1611 path: file.to_string_lossy().into_owned(),
1612 source,
1613 })?;
1614 Err(SessionError::Io {
1615 path: file.to_string_lossy().into_owned(),
1616 source: io::Error::other("injected rewrite failure"),
1617 })
1618 });
1619
1620 assert!(result.is_err());
1621 assert_eq!(fs::read_to_string(&file)?, "original\n");
1622 let leftovers: Vec<_> = fs::read_dir(dir.path())?
1623 .flatten()
1624 .filter(|entry| entry.file_name().to_string_lossy().contains(".tmp-"))
1625 .collect();
1626 assert!(leftovers.is_empty(), "failed rewrite must remove temp file");
1627 Ok(())
1628 }
1629
1630 fn path_str(path: &Path) -> Result<&str, Box<dyn std::error::Error>> {
1631 path.to_str()
1632 .ok_or_else(|| "path is not valid UTF-8".into())
1633 }
1634
1635 fn user_agent(text: &str, ts: i64) -> AgentMessage {
1636 AgentMessage::Llm(Box::new(Message::User(UserMessage::new(
1637 UserMessageContent::Text(text.to_owned()),
1638 ts,
1639 ))))
1640 }
1641
1642 fn assistant_agent(text: &str, ts: i64) -> AgentMessage {
1643 let mut msg =
1644 pi_ai::AssistantMessage::new("anthropic-messages", "anthropic", "claude-test", ts);
1645 msg.content = vec![pi_ai::AssistantContent::Text(TextContent::new(text))];
1646 msg.usage = pi_ai::Usage {
1647 input: 1,
1648 output: 1,
1649 cache_read: 0,
1650 cache_write: 0,
1651 total_tokens: 2,
1652 cost: pi_ai::UsageCost::default(),
1653 ..Default::default()
1654 };
1655 AgentMessage::Llm(Box::new(Message::Assistant(msg)))
1656 }
1657
1658 #[test]
1659 fn deferred_write_until_first_assistant() -> TestResult {
1660 let dir = tempdir()?;
1661 let mut sm =
1662 SessionManager::create(path_str(dir.path())?, Some(path_str(dir.path())?), None)?;
1663 let file = sm.get_session_file().ok_or("file")?.to_owned();
1664 assert!(!path_exists(Path::new(&file)), "file must not exist yet");
1665
1666 sm.append_message(&user_agent("hello", 1))?;
1667 assert!(!path_exists(Path::new(&file)), "still deferred after user");
1668
1669 sm.append_message(&assistant_agent("hi", 2))?;
1670 assert!(path_exists(Path::new(&file)), "created on assistant");
1671
1672 let content = fs::read_to_string(&file)?;
1673 let lines: Vec<_> = content.lines().filter(|l| !l.is_empty()).collect();
1674 assert_eq!(lines.len(), 3); let header: Value = serde_json::from_str(lines[0])?;
1676 assert_eq!(header["type"], "session");
1677 assert_eq!(header["version"], 3);
1678 Ok(())
1679 }
1680
1681 #[test]
1682 fn append_prefix_stability() -> TestResult {
1683 let dir = tempdir()?;
1684 let file = dir.path().join("stable.jsonl");
1685 let original = concat!(
1686 r#"{"type":"session","version":3,"id":"sess-1","timestamp":"2025-01-01T00:00:00.000Z","cwd":"/tmp"}"#,
1687 "\n",
1688 r#"{"type":"message","id":"aaaaaaaa","parentId":null,"timestamp":"2025-01-01T00:00:01.000Z","message":{"role":"user","content":"hi","timestamp":1}}"#,
1689 "\n",
1690 r#"{"type":"message","id":"bbbbbbbb","parentId":"aaaaaaaa","timestamp":"2025-01-01T00:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"yo"}],"api":"test","provider":"test","model":"test","usage":{"input":1,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":2,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":2}}"#,
1691 "\n",
1692 );
1693 fs::write(&file, original)?;
1694
1695 let mut sm = SessionManager::open(path_str(&file)?, Some(path_str(dir.path())?), None)?;
1696 sm.append_message(&user_agent("next", 3))?;
1697
1698 let after = fs::read(&file)?;
1699 assert!(
1700 after.starts_with(original.as_bytes()),
1701 "original lines must be byte-stable on append"
1702 );
1703 Ok(())
1704 }
1705
1706 #[test]
1707 fn failed_append_does_not_advance_tree_and_can_retry() -> TestResult {
1708 let dir = tempdir()?;
1709 let file = dir.path().join("retry.jsonl");
1710 let original = concat!(
1711 r#"{"type":"session","version":3,"id":"sess-1","timestamp":"2025-01-01T00:00:00.000Z","cwd":"/tmp"}"#,
1712 "\n",
1713 r#"{"type":"message","id":"aaaaaaaa","parentId":null,"timestamp":"2025-01-01T00:00:01.000Z","message":{"role":"user","content":"hi","timestamp":1}}"#,
1714 "\n",
1715 r#"{"type":"message","id":"bbbbbbbb","parentId":"aaaaaaaa","timestamp":"2025-01-01T00:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"yo"}],"api":"test","provider":"test","model":"test","usage":{"input":1,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":2,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":2}}"#,
1716 "\n",
1717 );
1718 fs::write(&file, original)?;
1719 let mut sm = SessionManager::open(path_str(&file)?, Some(path_str(dir.path())?), None)?;
1720 let before_count = sm.get_entries().len();
1721 let before_leaf = sm.get_leaf_id().map(str::to_owned);
1722
1723 let backup = dir.path().join("retry.backup");
1724 fs::rename(&file, &backup)?;
1725 fs::create_dir(&file)?;
1726 let result = sm.append_message(&user_agent("retry me", 3));
1727 assert!(matches!(result, Err(SessionError::Io { .. })));
1728 assert_eq!(sm.get_entries().len(), before_count);
1729 assert_eq!(sm.get_leaf_id(), before_leaf.as_deref());
1730
1731 fs::remove_dir(&file)?;
1732 fs::rename(&backup, &file)?;
1733 let id = sm.append_message(&user_agent("retry me", 3))?;
1734 assert_eq!(sm.get_entries().len(), before_count + 1);
1735 assert_eq!(sm.get_leaf_id(), Some(id.as_str()));
1736 let persisted = fs::read_to_string(&file)?;
1737 assert_eq!(persisted.matches("retry me").count(), 1);
1738 Ok(())
1739 }
1740
1741 #[test]
1742 fn invalid_file_preserved() -> TestResult {
1743 let dir = tempdir()?;
1744 let file = dir.path().join("bad.jsonl");
1745 let original = r#"{"type":"event","data":"not a session"}
1746"#;
1747 fs::write(&file, original)?;
1748 let Err(err) = SessionManager::open(path_str(&file)?, Some(path_str(dir.path())?), None)
1749 else {
1750 return Err("expected invalid session error".into());
1751 };
1752 match err {
1753 SessionError::InvalidSessionFile(p) => {
1754 assert!(p.contains("bad.jsonl"));
1755 }
1756 other => return Err(format!("wrong error: {other}").into()),
1757 }
1758 assert_eq!(fs::read_to_string(&file)?, original);
1759 Ok(())
1760 }
1761
1762 #[test]
1763 fn empty_file_gets_header() -> TestResult {
1764 let dir = tempdir()?;
1765 let file = dir.path().join("empty.jsonl");
1766 fs::write(&file, "")?;
1767 let sm = SessionManager::open(path_str(&file)?, Some(path_str(dir.path())?), None)?;
1768 assert!(!sm.get_session_id().is_empty());
1769 let content = fs::read_to_string(&file)?;
1770 let header: Value = serde_json::from_str(content.trim())?;
1771 assert_eq!(header["type"], "session");
1772 assert_eq!(header["id"], sm.get_session_id());
1773 Ok(())
1774 }
1775
1776 #[test]
1777 fn unknown_entry_roundtrip_and_excluded_from_context() -> TestResult {
1778 let dir = tempdir()?;
1779 let file = dir.path().join("unknown.jsonl");
1780 let original = concat!(
1781 r#"{"type":"session","version":3,"id":"sess-1","timestamp":"2025-01-01T00:00:00.000Z","cwd":"/tmp"}"#,
1782 "\n",
1783 r#"{"type":"future_thing","id":"u1","parentId":null,"timestamp":"2025-01-01T00:00:01.000Z","customField":{"a":1}}"#,
1784 "\n",
1785 r#"{"type":"message","id":"m1","parentId":"u1","timestamp":"2025-01-01T00:00:02.000Z","message":{"role":"user","content":"hi","timestamp":1}}"#,
1786 "\n",
1787 r#"{"type":"message","id":"m2","parentId":"m1","timestamp":"2025-01-01T00:00:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"yo"}],"api":"test","provider":"test","model":"test","usage":{"input":1,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":2,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":2}}"#,
1788 "\n",
1789 );
1790 fs::write(&file, original)?;
1791
1792 let mut sm = SessionManager::open(path_str(&file)?, Some(path_str(dir.path())?), None)?;
1793 assert!(sm.get_entry("u1").is_some());
1794 assert_eq!(
1795 sm.get_entry("u1").map(SessionEntry::discriminant),
1796 Some("future_thing")
1797 );
1798 let ctx = sm.build_session_context()?;
1799 assert_eq!(ctx.messages.len(), 2); assert_eq!(ctx.messages[0].role(), "user");
1801
1802 sm.append_message(&user_agent("next", 3))?;
1803 let after = fs::read(&file)?;
1804 assert!(after.starts_with(original.as_bytes()));
1805 Ok(())
1806 }
1807
1808 #[test]
1809 fn leaf_is_last_file_order_not_deepest() -> TestResult {
1810 let dir = tempdir()?;
1811 let file = dir.path().join("leaf.jsonl");
1812 let content = concat!(
1814 r#"{"type":"session","version":3,"id":"s","timestamp":"2025-01-01T00:00:00.000Z","cwd":"/tmp"}"#,
1815 "\n",
1816 r#"{"type":"message","id":"A","parentId":null,"timestamp":"2025-01-01T00:00:01.000Z","message":{"role":"user","content":"a","timestamp":1}}"#,
1817 "\n",
1818 r#"{"type":"message","id":"B","parentId":"A","timestamp":"2025-01-01T00:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"b"}],"api":"test","provider":"test","model":"test","usage":{"input":1,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":2,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":2}}"#,
1819 "\n",
1820 r#"{"type":"message","id":"C","parentId":"B","timestamp":"2025-01-01T00:00:03.000Z","message":{"role":"user","content":"c","timestamp":3}}"#,
1821 "\n",
1822 r#"{"type":"message","id":"D","parentId":"A","timestamp":"2025-01-01T00:00:04.000Z","message":{"role":"user","content":"d","timestamp":4}}"#,
1823 "\n",
1824 );
1825 fs::write(&file, content)?;
1826 let sm = SessionManager::open(path_str(&file)?, Some(path_str(dir.path())?), None)?;
1827 assert_eq!(sm.get_leaf_id(), Some("D"));
1828 let branch: Vec<&str> = sm.get_branch(None).iter().filter_map(|e| e.id()).collect();
1829 assert_eq!(branch, vec!["A", "D"]);
1830 Ok(())
1831 }
1832
1833 #[test]
1834 fn labels_and_rechain_on_branched_session() -> TestResult {
1835 let mut sm = SessionManager::in_memory(Some("/tmp"), None)?;
1836 let msg1 = sm.append_message(&user_agent("hello", 1))?;
1837 sm.append_label_change(&msg1, Some("checkpoint"))?;
1838 let model = sm.append_model_change("anthropic", "claude-test")?;
1839 let msg2 = sm.append_message(&user_agent("followup", 2))?;
1840
1841 sm.create_branched_session(&msg2)?;
1842
1843 assert_eq!(
1844 sm.get_entry(&model).and_then(|e| e.parent_id()),
1845 Some(msg1.as_str())
1846 );
1847 assert_eq!(sm.get_label(&msg1), Some("checkpoint"));
1848 Ok(())
1849 }
1850
1851 #[test]
1852 fn create_branched_session_defers_without_assistant() -> TestResult {
1853 let dir = tempdir()?;
1854 let mut sm =
1855 SessionManager::create(path_str(dir.path())?, Some(path_str(dir.path())?), None)?;
1856 let id1 = sm.append_message(&user_agent("first", 1))?;
1857 sm.append_message(&assistant_agent("answer", 2))?;
1858 sm.append_message(&user_agent("second", 3))?;
1859 sm.append_message(&assistant_agent("answer2", 4))?;
1860
1861 let new_file = sm.create_branched_session(&id1)?.ok_or("path")?;
1862 assert!(
1863 !path_exists(Path::new(&new_file)),
1864 "no assistant on path → deferred"
1865 );
1866 sm.append_custom_entry("preset-state", Some(json!({"name": "plan"})))?;
1867 sm.append_message(&assistant_agent("new answer", 5))?;
1868 assert!(path_exists(Path::new(&new_file)));
1869
1870 let content = fs::read_to_string(&new_file)?;
1871 let records: Vec<Value> = content
1872 .lines()
1873 .filter(|l| !l.is_empty())
1874 .map(serde_json::from_str)
1875 .collect::<Result<_, _>>()?;
1876 assert_eq!(
1877 records
1878 .iter()
1879 .filter(|r| r.get("type").and_then(Value::as_str) == Some("session"))
1880 .count(),
1881 1
1882 );
1883 let ids: Vec<&str> = records
1884 .iter()
1885 .filter(|r| r.get("type").and_then(Value::as_str) != Some("session"))
1886 .filter_map(|r| r.get("id").and_then(Value::as_str))
1887 .collect();
1888 let set: HashSet<&str> = ids.iter().copied().collect();
1889 assert_eq!(set.len(), ids.len(), "no duplicate ids");
1890 Ok(())
1891 }
1892
1893 #[test]
1894 fn create_branched_session_writes_with_assistant() -> TestResult {
1895 let dir = tempdir()?;
1896 let mut sm =
1897 SessionManager::create(path_str(dir.path())?, Some(path_str(dir.path())?), None)?;
1898 sm.append_message(&user_agent("first", 1))?;
1899 let id2 = sm.append_message(&assistant_agent("answer", 2))?;
1900 sm.append_message(&user_agent("second", 3))?;
1901 sm.append_message(&assistant_agent("answer2", 4))?;
1902
1903 let new_file = sm.create_branched_session(&id2)?.ok_or("path")?;
1904 assert!(path_exists(Path::new(&new_file)));
1905 Ok(())
1906 }
1907
1908 #[test]
1909 fn fork_header_and_entries() -> TestResult {
1910 let dir = tempdir()?;
1911 let source = dir.path().join("source.jsonl");
1912 fs::write(
1913 &source,
1914 concat!(
1915 r#"{"type":"session","version":3,"id":"legacy-session-id","timestamp":"2025-01-01T00:00:00.000Z","cwd":"/old"}"#,
1916 "\n",
1917 r#"{"type":"message","id":"entry-1","parentId":null,"timestamp":"2025-01-01T00:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hello"}],"api":"openai-responses","provider":"openai","model":"gpt-5.4","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1}}"#,
1918 "\n",
1919 ),
1920 )?;
1921 let forked = SessionManager::fork_from(
1922 path_str(&source)?,
1923 path_str(dir.path())?,
1924 Some(path_str(dir.path())?),
1925 None,
1926 )?;
1927 let header = forked.get_header().ok_or("header")?;
1928 assert_ne!(header.id.as_deref(), Some("legacy-session-id"));
1929 let id = header.id.as_deref().ok_or("id")?;
1931 assert!(id.contains('-'));
1932 assert_eq!(header.parent_session.as_deref(), Some(path_str(&source)?));
1933 assert!(header.cwd.is_some());
1934 assert!(forked.get_entry("entry-1").is_some());
1936 Ok(())
1937 }
1938
1939 #[test]
1940 fn encoded_cwd() {
1941 assert_eq!(
1942 encode_cwd_for_session_dir("/home/user/project"),
1943 "--home-user-project--"
1944 );
1945 assert_eq!(encode_cwd_for_session_dir("C:\\Users\\x"), "--C--Users-x--");
1946 let agent = Path::new("/tmp/agent");
1947 let dir = default_session_dir_path("/a/b", agent);
1948 assert!(dir.ends_with("sessions/--a-b--") || dir.contains("sessions/--a-b--"));
1949 }
1950
1951 #[test]
1952 fn custom_session_id_and_filename() -> TestResult {
1953 let dir = tempdir()?;
1954 let sm = SessionManager::create(
1955 path_str(dir.path())?,
1956 Some(path_str(dir.path())?),
1957 Some(NewSessionOptions {
1958 id: Some("created-session-id".to_owned()),
1959 parent_session: None,
1960 }),
1961 )?;
1962 assert_eq!(sm.get_session_id(), "created-session-id");
1963 let file = sm.get_session_file().ok_or("file")?;
1964 let base = Path::new(file)
1965 .file_name()
1966 .ok_or("file name")?
1967 .to_string_lossy();
1968 assert!(base.ends_with("_created-session-id.jsonl"));
1970 assert!(!path_exists(Path::new(file)));
1971 Ok(())
1972 }
1973
1974 #[test]
1975 fn open_preserves_explicit_missing_session_path() -> TestResult {
1976 let dir = tempdir()?;
1977 let file = dir.path().join("explicit-session.jsonl");
1978
1979 let manager = SessionManager::open(path_str(&file)?, Some(path_str(dir.path())?), None)?;
1980
1981 assert_eq!(manager.get_session_file(), file.to_str());
1982 assert!(!file.exists());
1983 Ok(())
1984 }
1985
1986 #[test]
1987 fn invalid_session_ids_rejected() -> TestResult {
1988 for id in [
1989 "", "-abc", "abc-", "_abc", "abc_", ".abc", "abc.", "abc/def", "abc\\def", "abc def",
1990 ] {
1991 let mut sm = SessionManager::in_memory(Some("/tmp"), None)?;
1992 let Err(err) = sm.new_session(Some(NewSessionOptions {
1993 id: Some(id.to_owned()),
1994 parent_session: None,
1995 })) else {
1996 return Err(format!("expected invalid id for {id:?}").into());
1997 };
1998 assert!(matches!(err, SessionError::InvalidSessionId));
1999 }
2000 Ok(())
2001 }
2002
2003 #[test]
2004 fn tree_and_branch() -> TestResult {
2005 let mut sm = SessionManager::in_memory(Some("/tmp"), None)?;
2006 let id1 = sm.append_message(&user_agent("1", 1))?;
2007 let id2 = sm.append_message(&assistant_agent("2", 2))?;
2008 let id3 = sm.append_message(&user_agent("3", 3))?;
2009 assert_eq!(sm.get_leaf_id(), Some(id3.as_str()));
2010
2011 sm.branch(&id2)?;
2012 let id4 = sm.append_message(&user_agent("4-branch", 4))?;
2013 let tree = sm.get_tree();
2014 assert_eq!(tree.len(), 1);
2015 assert_eq!(tree[0].entry.id(), Some(id1.as_str()));
2016 let node2 = &tree[0].children[0];
2017 assert_eq!(node2.entry.id(), Some(id2.as_str()));
2018 assert_eq!(node2.children.len(), 2);
2019 let child_ids: HashSet<&str> = node2.children.iter().filter_map(|c| c.entry.id()).collect();
2020 assert!(child_ids.contains(id3.as_str()));
2021 assert!(child_ids.contains(id4.as_str()));
2022 Ok(())
2023 }
2024
2025 #[test]
2026 fn model_and_thinking_context() -> TestResult {
2027 let mut sm = SessionManager::in_memory(Some("/tmp"), None)?;
2028 sm.append_message(&user_agent("hello", 1))?;
2029 sm.append_thinking_level_change("high")?;
2030 sm.append_model_change("openai", "gpt-4")?;
2031 sm.append_message(&assistant_agent("hi", 2))?;
2032 let ctx = sm.build_session_context()?;
2033 assert_eq!(ctx.thinking_level, "high");
2034 let model = ctx.model.ok_or("model")?;
2036 assert_eq!(model.provider, "anthropic");
2037 assert_eq!(model.model_id, "claude-test");
2038 assert_eq!(ctx.messages.len(), 2);
2039 Ok(())
2040 }
2041
2042 #[test]
2043 fn compaction_reconstruction() -> TestResult {
2044 let mut sm = SessionManager::in_memory(Some("/tmp"), None)?;
2045 let id1 = sm.append_message(&user_agent("first", 1))?;
2046 sm.append_message(&assistant_agent("r1", 2))?;
2047 let id3 = sm.append_message(&user_agent("second", 3))?;
2048 sm.append_message(&assistant_agent("r2", 4))?;
2049 sm.append_compaction("Summary of first two turns", &id3, 1000, None, None)?;
2050 sm.append_message(&user_agent("third", 5))?;
2051 sm.append_message(&assistant_agent("r3", 6))?;
2052
2053 let ids: Vec<&str> = sm
2054 .build_context_entries()
2055 .iter()
2056 .filter_map(|e| e.id())
2057 .collect();
2058 let compaction_id = sm
2060 .get_entries()
2061 .iter()
2062 .find(|e| e.discriminant() == "compaction")
2063 .and_then(|e| e.id())
2064 .ok_or("compaction entry")?;
2065 assert_eq!(ids[0], compaction_id);
2066 assert!(ids.contains(&id3.as_str()));
2067 assert!(!ids.contains(&id1.as_str())); let ctx = sm.build_session_context()?;
2070 assert_eq!(ctx.messages[0].role(), "compactionSummary");
2071 assert_eq!(ctx.messages.len(), 5);
2072 Ok(())
2073 }
2074
2075 #[test]
2076 fn custom_entries_in_tree_not_context() -> TestResult {
2077 let mut sm = SessionManager::in_memory(Some("/tmp"), None)?;
2078 let msg_id = sm.append_message(&user_agent("hello", 1))?;
2079 let custom_id = sm.append_custom_entry("my_data", Some(json!({"foo": "bar"})))?;
2080 sm.append_message(&assistant_agent("hi", 2))?;
2081
2082 let entries = sm.get_entries();
2083 assert_eq!(entries.len(), 3);
2084 assert_eq!(entries[1].discriminant(), "custom");
2085 assert_eq!(entries[1].id(), Some(custom_id.as_str()));
2086 assert_eq!(entries[1].parent_id(), Some(msg_id.as_str()));
2087
2088 let path = sm.get_branch(None);
2089 assert_eq!(path.len(), 3);
2090
2091 let ctx = sm.build_session_context()?;
2092 assert_eq!(ctx.messages.len(), 2);
2093 Ok(())
2094 }
2095
2096 #[test]
2097 fn label_not_found_throws() -> TestResult {
2098 let mut sm = SessionManager::in_memory(Some("/tmp"), None)?;
2099 let Err(err) = sm.append_label_change("non-existent", Some("label")) else {
2100 return Err("expected entry not found".into());
2101 };
2102 assert!(matches!(err, SessionError::EntryNotFound(_)));
2103 assert_eq!(err.to_string(), "Entry non-existent not found");
2104 Ok(())
2105 }
2106
2107 #[test]
2108 fn branch_not_found_throws() -> TestResult {
2109 let mut sm = SessionManager::in_memory(Some("/tmp"), None)?;
2110 sm.append_message(&user_agent("hello", 1))?;
2111 let Err(err) = sm.branch("nonexistent") else {
2112 return Err("expected entry not found".into());
2113 };
2114 assert_eq!(err.to_string(), "Entry nonexistent not found");
2115 Ok(())
2116 }
2117}