Skip to main content

imp_core/
session.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use imp_llm::{truncate_chars_with_suffix, AssistantMessage, Message, Model, ToolResultMessage};
5use serde::{Deserialize, Serialize};
6
7use crate::agent::AgentEvent;
8use crate::error::Result;
9use crate::usage::{
10    canonical_usage_record_for_assistant_turn_with_model_meta, usage_record_entry,
11    usage_records_from_session, SessionUsageRecord, UsageRecordV1, USAGE_CUSTOM_TYPE,
12};
13
14pub const CHECKPOINT_CUSTOM_TYPE: &str = "checkpoint-record";
15pub const CHECKPOINT_RECORD_VERSION: u32 = 1;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct SessionCheckpointRecord {
19    pub version: u32,
20    pub checkpoint_id: String,
21    pub created_at: u64,
22    pub label: Option<String>,
23    pub files: Vec<String>,
24}
25
26const SESSION_META_VERSION: u32 = 1;
27
28/// A single entry in the session JSONL file.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(tag = "type")]
31pub enum SessionEntry {
32    #[serde(rename = "header")]
33    Header {
34        version: u32,
35        created_at: u64,
36        cwd: String,
37    },
38    #[serde(rename = "message")]
39    Message {
40        id: String,
41        parent_id: Option<String>,
42        message: Message,
43    },
44    #[serde(rename = "compaction")]
45    Compaction {
46        id: String,
47        parent_id: Option<String>,
48        summary: String,
49        first_kept_id: String,
50        #[serde(default)]
51        tokens_before: u32,
52        #[serde(default)]
53        tokens_after: u32,
54    },
55    #[serde(rename = "custom")]
56    Custom {
57        id: String,
58        parent_id: Option<String>,
59        custom_type: String,
60        data: serde_json::Value,
61    },
62    #[serde(rename = "label")]
63    Label { entry_id: String, label: String },
64    #[serde(rename = "session-meta")]
65    SessionMeta {
66        version: u32,
67        name: Option<String>,
68        summary: Option<String>,
69    },
70}
71
72impl SessionEntry {
73    /// Get the id of this entry, if it has one (Header and Label don't).
74    pub fn id(&self) -> Option<&str> {
75        match self {
76            SessionEntry::Header { .. }
77            | SessionEntry::Label { .. }
78            | SessionEntry::SessionMeta { .. } => None,
79            SessionEntry::Message { id, .. }
80            | SessionEntry::Compaction { id, .. }
81            | SessionEntry::Custom { id, .. } => Some(id),
82        }
83    }
84
85    /// Get the parent_id of this entry, if it has one.
86    pub fn parent_id(&self) -> Option<&str> {
87        match self {
88            SessionEntry::Header { .. }
89            | SessionEntry::Label { .. }
90            | SessionEntry::SessionMeta { .. } => None,
91            SessionEntry::Message { parent_id, .. }
92            | SessionEntry::Compaction { parent_id, .. }
93            | SessionEntry::Custom { parent_id, .. } => parent_id.as_deref(),
94        }
95    }
96}
97
98/// A node in the session tree.
99#[derive(Debug, Clone)]
100pub struct TreeNode {
101    pub entry: SessionEntry,
102    pub children: Vec<TreeNode>,
103}
104
105/// Summary of a session for listing.
106#[derive(Debug, Clone)]
107pub struct SessionInfo {
108    pub id: String,
109    pub path: PathBuf,
110    pub cwd: String,
111    pub created_at: u64,
112    pub updated_at: u64,
113    pub message_count: usize,
114    pub first_message: Option<String>,
115    pub name: Option<String>,
116    pub summary: Option<String>,
117}
118
119pub fn checkpoint_record_entry(
120    entry_id: impl Into<String>,
121    record: SessionCheckpointRecord,
122) -> Result<SessionEntry> {
123    Ok(SessionEntry::Custom {
124        id: entry_id.into(),
125        parent_id: None,
126        custom_type: CHECKPOINT_CUSTOM_TYPE.to_string(),
127        data: serde_json::to_value(record)?,
128    })
129}
130
131impl SessionInfo {
132    /// A short, single-line chat title derived from persisted session metadata or message history.
133    pub fn title(&self, max_chars: usize) -> Option<String> {
134        if let Some(name) = self
135            .name
136            .as_deref()
137            .filter(|name| !name.trim().is_empty())
138            .map(|name| truncate_chars_with_suffix(name.trim(), max_chars, "…"))
139        {
140            return Some(name);
141        }
142
143        preferred_title_candidate(
144            self.first_message.as_deref(),
145            self.summary.as_deref(),
146            max_chars,
147        )
148    }
149}
150
151/// Manages a single session's entries and persistence.
152///
153/// Raw persisted entries are always retained in `entries`. Active model-visible
154/// history may differ from the raw branch when a `SessionEntry::Compaction`
155/// exists on the current branch. In that case, callers should prefer
156/// `get_active_messages()` over `get_messages()` when assembling context for an
157/// LLM request.
158pub struct SessionManager {
159    entries: Vec<SessionEntry>,
160    path: Option<PathBuf>,
161    leaf_id: Option<String>,
162    session_name: Option<String>,
163    session_summary: Option<String>,
164}
165
166impl SessionManager {
167    /// Create a new session. Writes the header to disk immediately.
168    pub fn new(cwd: &Path, session_dir: &Path) -> Result<Self> {
169        let session_id = uuid::Uuid::new_v4().to_string();
170        let path = session_dir.join(format!("{session_id}.jsonl"));
171        let header = SessionEntry::Header {
172            version: 1,
173            created_at: imp_llm::now(),
174            cwd: cwd.to_string_lossy().to_string(),
175        };
176
177        // Write header to disk immediately
178        {
179            use std::io::Write;
180            if let Some(parent) = path.parent() {
181                std::fs::create_dir_all(parent)?;
182            }
183            let mut file = std::fs::File::create(&path)?;
184            let line = serde_json::to_string(&header)?;
185            writeln!(file, "{line}")?;
186        }
187
188        Ok(Self {
189            entries: vec![header],
190            path: Some(path),
191            leaf_id: None,
192            session_name: None,
193            session_summary: None,
194        })
195    }
196
197    /// Open an existing session file, skipping malformed lines.
198    pub fn open(path: &Path) -> Result<Self> {
199        let content = std::fs::read_to_string(path)?;
200        let mut entries = Vec::new();
201        let mut last_id = None;
202
203        let mut session_name = None;
204        let mut session_summary = None;
205
206        for (_line_num, line) in content.lines().enumerate() {
207            if line.trim().is_empty() {
208                continue;
209            }
210            match serde_json::from_str::<SessionEntry>(line) {
211                Ok(entry) => {
212                    if let Some(id) = entry.id() {
213                        last_id = Some(id.to_string());
214                    }
215                    if let SessionEntry::SessionMeta { name, summary, .. } = &entry {
216                        session_name = name.clone();
217                        session_summary = summary.clone();
218                    }
219                    entries.push(entry);
220                }
221                Err(_e) => {
222                    // Keep session loading side-effect free for embedded callers like the TUI.
223                    // Malformed lines are skipped so resume/continue can still recover usable history.
224                }
225            }
226        }
227
228        Ok(Self {
229            entries,
230            path: Some(path.to_path_buf()),
231            leaf_id: last_id,
232            session_name,
233            session_summary,
234        })
235    }
236
237    /// In-memory session (no persistence).
238    pub fn in_memory() -> Self {
239        Self {
240            entries: Vec::new(),
241            path: None,
242            leaf_id: None,
243            session_name: None,
244            session_summary: None,
245        }
246    }
247
248    /// Find the most recently modified session for a given cwd.
249    pub fn continue_recent(cwd: &Path, session_dir: &Path) -> Result<Option<Self>> {
250        if !session_dir.exists() {
251            return Ok(None);
252        }
253
254        let cwd_str = cwd.to_string_lossy().to_string();
255        let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
256
257        for dir_entry in std::fs::read_dir(session_dir)? {
258            let dir_entry = dir_entry?;
259            let path = dir_entry.path();
260            if path.extension().is_none_or(|e| e != "jsonl") {
261                continue;
262            }
263            // Check modification time first (cheap) before parsing
264            let modified = dir_entry
265                .metadata()?
266                .modified()
267                .unwrap_or(std::time::UNIX_EPOCH);
268
269            // Only parse if this could be newer than our current best
270            if best.as_ref().is_none_or(|(t, _)| modified > *t) {
271                // Read just the first line to check cwd without parsing the whole file
272                if let Ok(first_line) = read_first_line(&path) {
273                    if let Ok(SessionEntry::Header { cwd, .. }) =
274                        serde_json::from_str::<SessionEntry>(&first_line).as_ref()
275                    {
276                        if *cwd == cwd_str {
277                            best = Some((modified, path));
278                        }
279                    }
280                }
281            }
282        }
283
284        match best {
285            Some((_, path)) => Ok(Some(Self::open(&path)?)),
286            None => Ok(None),
287        }
288    }
289
290    /// Get the session name.
291    pub fn name(&self) -> Option<&str> {
292        self.session_name.as_deref()
293    }
294
295    /// Get the session summary.
296    pub fn summary(&self) -> Option<&str> {
297        self.session_summary.as_deref()
298    }
299
300    /// Set the session name.
301    pub fn set_name(&mut self, name: &str) {
302        self.session_name = Some(name.to_string());
303        let _ = self.persist_session_meta();
304    }
305
306    /// Set the session summary.
307    pub fn set_summary(&mut self, summary: impl Into<String>) {
308        let summary = summary.into();
309        self.session_summary = Some(summary);
310        let _ = self.persist_session_meta();
311    }
312
313    /// Clear the session summary.
314    pub fn clear_summary(&mut self) {
315        self.session_summary = None;
316        let _ = self.persist_session_meta();
317    }
318
319    /// A short, single-line chat title derived from persisted session metadata or message history.
320    pub fn title(&self, max_chars: usize) -> Option<String> {
321        if let Some(name) = self
322            .name()
323            .filter(|name| !name.trim().is_empty())
324            .map(|name| truncate_chars_with_suffix(name.trim(), max_chars, "…"))
325        {
326            return Some(name);
327        }
328
329        let first_prompt = self.entries.iter().find_map(|entry| match entry {
330            SessionEntry::Message { message, .. } => extract_text(message),
331            _ => None,
332        });
333        let summary = self
334            .summary()
335            .filter(|summary| !summary.trim().is_empty())
336            .map(str::to_string)
337            .or_else(|| derive_session_summary(&self.entries));
338
339        preferred_title_candidate(first_prompt.as_deref(), summary.as_deref(), max_chars)
340    }
341
342    fn persist_session_meta(&mut self) -> Result<()> {
343        self.append(SessionEntry::SessionMeta {
344            version: SESSION_META_VERSION,
345            name: self.session_name.clone(),
346            summary: self.session_summary.clone(),
347        })
348    }
349
350    fn refresh_derived_summary(&mut self) {
351        let derived = derive_session_summary(&self.entries);
352        if derived != self.session_summary {
353            self.session_summary = derived;
354            let _ = self.persist_session_meta();
355        }
356    }
357
358    /// Append an entry. Sets parent_id to current leaf_id, updates leaf_id,
359    /// and writes to file if persisted.
360    pub fn append(&mut self, mut entry: SessionEntry) -> Result<()> {
361        // Set parent_id on entries that support it
362        match &mut entry {
363            SessionEntry::Message { parent_id, .. }
364            | SessionEntry::Compaction { parent_id, .. }
365            | SessionEntry::Custom { parent_id, .. } => {
366                *parent_id = self.leaf_id.clone();
367            }
368            SessionEntry::Header { .. }
369            | SessionEntry::Label { .. }
370            | SessionEntry::SessionMeta { .. } => {}
371        }
372
373        // Update leaf_id
374        if let Some(id) = entry.id() {
375            self.leaf_id = Some(id.to_string());
376        }
377
378        // Write to file
379        if let Some(ref path) = self.path {
380            use std::io::Write;
381            if let Some(parent) = path.parent() {
382                std::fs::create_dir_all(parent)?;
383            }
384            let mut file = std::fs::OpenOptions::new()
385                .create(true)
386                .append(true)
387                .open(path)?;
388            let line = serde_json::to_string(&entry)?;
389            writeln!(file, "{line}")?;
390        }
391
392        self.entries.push(entry);
393        Ok(())
394    }
395
396    /// Append an assistant turn and, when available, its canonical usage record.
397    pub fn append_assistant_turn(
398        &mut self,
399        model: &Model,
400        turn_index: u32,
401        message: AssistantMessage,
402    ) -> Result<(String, Option<String>)> {
403        self.append_assistant_turn_with_model_meta(&model.meta, turn_index, message)
404    }
405
406    /// Append an assistant turn and, when available, its canonical usage record.
407    pub fn append_assistant_turn_with_model_meta(
408        &mut self,
409        model_meta: &imp_llm::model::ModelMeta,
410        turn_index: u32,
411        message: AssistantMessage,
412    ) -> Result<(String, Option<String>)> {
413        let assistant_message_id = uuid::Uuid::new_v4().to_string();
414        self.append(SessionEntry::Message {
415            id: assistant_message_id.clone(),
416            parent_id: None,
417            message: Message::Assistant(message.clone()),
418        })?;
419
420        let usage_entry_id = self.append_canonical_usage_for_assistant_turn_with_model_meta(
421            model_meta,
422            &assistant_message_id,
423            turn_index,
424            &message,
425        )?;
426
427        self.refresh_derived_summary();
428
429        Ok((assistant_message_id, usage_entry_id))
430    }
431
432    /// Append a tool result message and return the persisted entry id.
433    pub fn append_tool_result_message(&mut self, result: ToolResultMessage) -> Result<String> {
434        let entry_id = uuid::Uuid::new_v4().to_string();
435        self.append(SessionEntry::Message {
436            id: entry_id.clone(),
437            parent_id: None,
438            message: Message::ToolResult(result),
439        })?;
440        Ok(entry_id)
441    }
442
443    /// Persist the session entries implied by an agent event.
444    ///
445    /// Returns a short description of what was written so callers can surface
446    /// best-effort persistence diagnostics without owning the persistence logic.
447    pub fn persist_agent_event_entries(
448        &mut self,
449        model: &Model,
450        event: &AgentEvent,
451    ) -> Result<Vec<&'static str>> {
452        self.persist_agent_event_entries_with_model_meta(&model.meta, event)
453    }
454
455    /// Persist the session entries implied by an agent event.
456    ///
457    /// Returns a short description of what was written so callers can surface
458    /// best-effort persistence diagnostics without owning the persistence logic.
459    pub fn persist_agent_event_entries_with_model_meta(
460        &mut self,
461        model_meta: &imp_llm::model::ModelMeta,
462        event: &AgentEvent,
463    ) -> Result<Vec<&'static str>> {
464        let mut persisted = Vec::new();
465
466        match event {
467            AgentEvent::ToolExecutionEnd { result, .. } => {
468                self.append_tool_result_message(result.clone())?;
469                persisted.push("tool result");
470            }
471            AgentEvent::TurnEnd { index, message, .. } => {
472                let (_assistant_id, usage_entry_id) = self.append_assistant_turn_with_model_meta(
473                    model_meta,
474                    *index,
475                    message.clone(),
476                )?;
477                persisted.push("assistant message");
478                if usage_entry_id.is_some() {
479                    persisted.push("canonical usage");
480                }
481            }
482            _ => {}
483        }
484
485        Ok(persisted)
486    }
487
488    /// Append a canonical usage entry for an assistant turn, if the turn reports usage
489    /// and no equivalent canonical record already exists.
490    ///
491    /// This is best-effort metadata persistence: callers should treat errors as
492    /// non-fatal to the main agent flow.
493    pub fn append_canonical_usage_for_assistant_turn(
494        &mut self,
495        model: &Model,
496        assistant_message_id: &str,
497        turn_index: u32,
498        message: &AssistantMessage,
499    ) -> Result<Option<String>> {
500        self.append_canonical_usage_for_assistant_turn_with_model_meta(
501            &model.meta,
502            assistant_message_id,
503            turn_index,
504            message,
505        )
506    }
507
508    /// Append a canonical usage entry for an assistant turn, if the turn reports usage
509    /// and no equivalent canonical record already exists.
510    ///
511    /// This is best-effort metadata persistence: callers should treat errors as
512    /// non-fatal to the main agent flow.
513    pub fn append_canonical_usage_for_assistant_turn_with_model_meta(
514        &mut self,
515        model_meta: &imp_llm::model::ModelMeta,
516        assistant_message_id: &str,
517        turn_index: u32,
518        message: &AssistantMessage,
519    ) -> Result<Option<String>> {
520        let Some(record) = canonical_usage_record_for_assistant_turn_with_model_meta(
521            self,
522            model_meta,
523            assistant_message_id,
524            turn_index,
525            message,
526        ) else {
527            return Ok(None);
528        };
529
530        let entry_id = uuid::Uuid::new_v4().to_string();
531        let entry = usage_record_entry(entry_id.clone(), record)?;
532        self.append(entry)?;
533        Ok(Some(entry_id))
534    }
535
536    /// Read canonical usage rows attached to this session.
537    pub fn usage_records(&self) -> Vec<SessionUsageRecord> {
538        usage_records_from_session(self)
539    }
540
541    pub fn append_checkpoint_record(&mut self, record: SessionCheckpointRecord) -> Result<String> {
542        let entry_id = uuid::Uuid::new_v4().to_string();
543        let entry = checkpoint_record_entry(entry_id.clone(), record)?;
544        self.append(entry)?;
545        Ok(entry_id)
546    }
547
548    pub fn checkpoint_records(&self) -> Vec<SessionCheckpointRecord> {
549        self.entries
550            .iter()
551            .filter_map(|entry| {
552                let SessionEntry::Custom {
553                    custom_type, data, ..
554                } = entry
555                else {
556                    return None;
557                };
558
559                if custom_type != CHECKPOINT_CUSTOM_TYPE {
560                    return None;
561                }
562
563                serde_json::from_value::<SessionCheckpointRecord>(data.clone()).ok()
564            })
565            .collect()
566    }
567
568    pub fn find_checkpoint_record(&self, needle: &str) -> Option<SessionCheckpointRecord> {
569        self.checkpoint_records().into_iter().find(|record| {
570            record.checkpoint_id == needle || record.label.as_deref() == Some(needle)
571        })
572    }
573
574    pub fn restore_checkpoint(
575        &self,
576        checkpoint_state: &crate::tools::CheckpointState,
577        needle: &str,
578    ) -> Result<Vec<PathBuf>> {
579        let Some(record) = self.find_checkpoint_record(needle) else {
580            return Ok(Vec::new());
581        };
582        checkpoint_state
583            .restore_checkpoint(&record.checkpoint_id)
584            .map_err(Into::into)
585    }
586
587    /// Check whether a canonical usage record already exists for the given request id.
588    pub fn has_canonical_usage_request_id(&self, request_id: &str) -> bool {
589        self.entries.iter().any(|entry| {
590            let SessionEntry::Custom {
591                custom_type, data, ..
592            } = entry
593            else {
594                return false;
595            };
596
597            if custom_type != USAGE_CUSTOM_TYPE {
598                return false;
599            }
600
601            UsageRecordV1::from_custom_data(data.clone())
602                .map(|record| record.request_id == request_id)
603                .unwrap_or(false)
604        })
605    }
606
607    /// Check whether a canonical usage record already exists for the given assistant turn.
608    pub fn has_canonical_usage_for_assistant_message(&self, assistant_message_id: &str) -> bool {
609        self.entries.iter().any(|entry| {
610            let SessionEntry::Custom {
611                custom_type, data, ..
612            } = entry
613            else {
614                return false;
615            };
616
617            if custom_type != USAGE_CUSTOM_TYPE {
618                return false;
619            }
620
621            UsageRecordV1::from_custom_data(data.clone())
622                .ok()
623                .and_then(|record| record.assistant_message_id)
624                .as_deref()
625                == Some(assistant_message_id)
626        })
627    }
628
629    /// Walk parent_ids from leaf_id to root, return raw entries in chronological order.
630    ///
631    /// This is the durable branch as persisted on disk. It may include
632    /// `SessionEntry::Compaction` markers plus raw pre-compaction messages.
633    /// Callers building model-visible context should prefer
634    /// `get_active_messages()`.
635    pub fn get_branch(&self) -> Vec<&SessionEntry> {
636        let Some(ref leaf) = self.leaf_id else {
637            // No messages yet — return just the header if present
638            return self
639                .entries
640                .iter()
641                .filter(|e| matches!(e, SessionEntry::Header { .. }))
642                .collect();
643        };
644
645        // Build id -> entry index for fast lookups
646        let id_map: HashMap<&str, usize> = self
647            .entries
648            .iter()
649            .enumerate()
650            .filter_map(|(i, e)| e.id().map(|id| (id, i)))
651            .collect();
652
653        // Walk from leaf to root
654        let mut branch = Vec::new();
655        let mut current = Some(leaf.as_str());
656
657        while let Some(id) = current {
658            if let Some(&idx) = id_map.get(id) {
659                let entry = &self.entries[idx];
660                branch.push(entry);
661                current = entry.parent_id();
662            } else {
663                break;
664            }
665        }
666
667        // Include the header
668        for entry in &self.entries {
669            if matches!(entry, SessionEntry::Header { .. }) {
670                branch.push(entry);
671                break;
672            }
673        }
674
675        branch.reverse();
676        branch
677    }
678
679    /// Get raw message entries for the current branch.
680    ///
681    /// This reflects the durable branch exactly and intentionally ignores
682    /// compaction semantics. For model-visible history after a compaction,
683    /// prefer `get_active_messages()`.
684    pub fn get_messages(&self) -> Vec<&Message> {
685        self.get_branch()
686            .into_iter()
687            .filter_map(|e| match e {
688                SessionEntry::Message { message, .. } => Some(message),
689                _ => None,
690            })
691            .collect()
692    }
693
694    /// Return the latest compaction entry on the active branch, if any.
695    pub fn latest_compaction(&self) -> Option<&SessionEntry> {
696        self.get_branch()
697            .into_iter()
698            .rev()
699            .find(|entry| matches!(entry, SessionEntry::Compaction { .. }))
700    }
701
702    /// Build the model-visible message history for the active branch.
703    ///
704    /// Compaction semantics are branch-local and replacement-based:
705    /// - if there is no compaction entry on the branch, this returns the raw
706    ///   branch messages;
707    /// - if a compaction entry exists, all raw messages before that boundary are
708    ///   replaced by a synthetic user summary message derived from the latest
709    ///   compaction entry, followed by the raw messages from `first_kept_id`
710    ///   onward that are still on the active branch.
711    ///
712    /// Raw persisted entries remain intact on disk and are still available via
713    /// `get_branch()` / `get_messages()`.
714    pub fn get_active_messages(&self) -> Vec<Message> {
715        let branch = self.get_branch();
716        let latest_compaction = branch.iter().enumerate().rev().find_map(|(idx, entry)| {
717            let SessionEntry::Compaction {
718                summary,
719                first_kept_id,
720                ..
721            } = entry
722            else {
723                return None;
724            };
725            Some((idx, summary.as_str(), first_kept_id.as_str()))
726        });
727
728        let Some((_compaction_idx, summary, first_kept_id)) = latest_compaction else {
729            return branch
730                .into_iter()
731                .filter_map(|entry| match entry {
732                    SessionEntry::Message { message, .. } => Some(message.clone()),
733                    _ => None,
734                })
735                .collect();
736        };
737
738        let mut active = Vec::new();
739        let summary_text = summary.trim();
740        if !summary_text.is_empty() {
741            active.push(Message::user(summary_text.to_string()));
742        }
743
744        let mut keep = false;
745        for entry in branch {
746            if entry.id() == Some(first_kept_id) {
747                keep = true;
748            }
749            if !keep {
750                continue;
751            }
752            if let SessionEntry::Message { message, .. } = entry {
753                active.push(message.clone());
754            }
755        }
756
757        active
758    }
759
760    /// Get the active model-visible branch entries.
761    ///
762    /// This is a convenience wrapper over `get_active_messages()` for callers
763    /// that still want borrowed-like iteration semantics at the message layer.
764    pub fn active_message_count(&self) -> usize {
765        self.get_active_messages().len()
766    }
767
768    /// Build the full tree structure from all entries.
769    pub fn get_tree(&self) -> Vec<TreeNode> {
770        // Separate roots (entries with no parent_id that have an id) and children
771        let mut children_map: HashMap<&str, Vec<usize>> = HashMap::new();
772        let mut roots: Vec<usize> = Vec::new();
773
774        for (i, entry) in self.entries.iter().enumerate() {
775            match entry.parent_id() {
776                Some(pid) => {
777                    children_map.entry(pid).or_default().push(i);
778                }
779                None => {
780                    roots.push(i);
781                }
782            }
783        }
784
785        roots
786            .into_iter()
787            .map(|i| self.build_subtree(i, &children_map))
788            .collect()
789    }
790
791    fn build_subtree(&self, idx: usize, children_map: &HashMap<&str, Vec<usize>>) -> TreeNode {
792        let entry = &self.entries[idx];
793        let children = entry
794            .id()
795            .and_then(|id| children_map.get(id))
796            .map(|child_indices| {
797                child_indices
798                    .iter()
799                    .map(|&ci| self.build_subtree(ci, children_map))
800                    .collect()
801            })
802            .unwrap_or_default();
803
804        TreeNode {
805            entry: entry.clone(),
806            children,
807        }
808    }
809
810    /// Change the current position in the tree to a different entry.
811    pub fn navigate(&mut self, target_id: &str) -> Result<()> {
812        let exists = self.entries.iter().any(|e| e.id() == Some(target_id));
813        if !exists {
814            return Err(crate::error::Error::Session(format!(
815                "entry not found: {target_id}"
816            )));
817        }
818        self.leaf_id = Some(target_id.to_string());
819        Ok(())
820    }
821
822    /// Create a new session file containing only entries up to (and including) the
823    /// given entry_id, following its branch from root.
824    pub fn fork(&self, entry_id: &str, new_path: &Path) -> Result<SessionManager> {
825        // Build the branch to this entry
826        let id_map: HashMap<&str, usize> = self
827            .entries
828            .iter()
829            .enumerate()
830            .filter_map(|(i, e)| e.id().map(|id| (id, i)))
831            .collect();
832
833        let mut branch_indices = Vec::new();
834        let mut current = Some(entry_id);
835
836        while let Some(id) = current {
837            if let Some(&idx) = id_map.get(id) {
838                branch_indices.push(idx);
839                current = self.entries[idx].parent_id();
840            } else {
841                break;
842            }
843        }
844
845        branch_indices.reverse();
846
847        // Collect header + branch entries
848        let mut forked_entries = Vec::new();
849        for entry in &self.entries {
850            if matches!(entry, SessionEntry::Header { .. }) {
851                forked_entries.push(entry.clone());
852                break;
853            }
854        }
855        for idx in &branch_indices {
856            forked_entries.push(self.entries[*idx].clone());
857        }
858
859        // Also include any Label entries that reference entries in our branch
860        let branch_ids: std::collections::HashSet<String> = forked_entries
861            .iter()
862            .filter_map(|e| e.id().map(String::from))
863            .collect();
864        let labels: Vec<SessionEntry> = self
865            .entries
866            .iter()
867            .filter(|e| {
868                matches!(e, SessionEntry::Label { entry_id, .. } if branch_ids.contains(entry_id.as_str()))
869            })
870            .cloned()
871            .collect();
872        forked_entries.extend(labels);
873
874        // Also include session metadata so names/summaries survive forks.
875        let meta_entries: Vec<SessionEntry> = self
876            .entries
877            .iter()
878            .filter(|e| matches!(e, SessionEntry::SessionMeta { .. }))
879            .cloned()
880            .collect();
881        forked_entries.extend(meta_entries);
882
883        // Write to new file
884        if let Some(parent) = new_path.parent() {
885            std::fs::create_dir_all(parent)?;
886        }
887
888        {
889            use std::io::Write;
890            let mut file = std::fs::File::create(new_path)?;
891            for entry in &forked_entries {
892                let line = serde_json::to_string(entry)?;
893                writeln!(file, "{line}")?;
894            }
895        }
896
897        let leaf_id = forked_entries
898            .iter()
899            .rev()
900            .find_map(|e| e.id())
901            .map(String::from);
902
903        Ok(SessionManager {
904            entries: forked_entries,
905            path: Some(new_path.to_path_buf()),
906            leaf_id,
907            session_name: self.session_name.clone(),
908            session_summary: self.session_summary.clone(),
909        })
910    }
911
912    /// Get all entries.
913    pub fn entries(&self) -> &[SessionEntry] {
914        &self.entries
915    }
916
917    /// Get the session file path.
918    pub fn path(&self) -> Option<&Path> {
919        self.path.as_deref()
920    }
921
922    /// Get the current leaf id.
923    pub fn leaf_id(&self) -> Option<&str> {
924        self.leaf_id.as_deref()
925    }
926
927    /// Get the stable session id derived from the persisted file name, if any.
928    pub fn session_id(&self) -> Option<String> {
929        self.path
930            .as_ref()
931            .and_then(|path| path.file_stem())
932            .map(|stem| stem.to_string_lossy().to_string())
933    }
934
935    /// List available sessions in a directory.
936    pub fn list(session_dir: &Path) -> Result<Vec<SessionInfo>> {
937        let mut sessions = Vec::new();
938        if !session_dir.exists() {
939            return Ok(sessions);
940        }
941
942        for dir_entry in std::fs::read_dir(session_dir)? {
943            let dir_entry = dir_entry?;
944            let path = dir_entry.path();
945            if path.extension().is_none_or(|e| e != "jsonl") {
946                continue;
947            }
948
949            let updated_at = dir_entry
950                .metadata()
951                .ok()
952                .and_then(|m| m.modified().ok())
953                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
954                .map(|d| d.as_secs())
955                .unwrap_or(0);
956
957            if let Ok(session) = Self::open(&path) {
958                let cwd = session
959                    .entries
960                    .iter()
961                    .find_map(|e| match e {
962                        SessionEntry::Header { cwd, .. } => Some(cwd.clone()),
963                        _ => None,
964                    })
965                    .unwrap_or_default();
966
967                let created_at = session
968                    .entries
969                    .iter()
970                    .find_map(|e| match e {
971                        SessionEntry::Header { created_at, .. } => Some(*created_at),
972                        _ => None,
973                    })
974                    .unwrap_or(0);
975
976                let message_count = session
977                    .entries
978                    .iter()
979                    .filter(|e| matches!(e, SessionEntry::Message { .. }))
980                    .count();
981
982                let first_message = session.entries.iter().find_map(|e| match e {
983                    SessionEntry::Message { message, .. } => extract_text(message),
984                    _ => None,
985                });
986
987                // Skip sessions with no messages — nothing to resume
988                if message_count == 0 {
989                    continue;
990                }
991
992                let name = session.name().map(str::to_string);
993                let summary = session
994                    .summary()
995                    .map(str::to_string)
996                    .or_else(|| derive_session_summary(&session.entries));
997
998                sessions.push(SessionInfo {
999                    id: path
1000                        .file_stem()
1001                        .map(|s| s.to_string_lossy().to_string())
1002                        .unwrap_or_default(),
1003                    path,
1004                    cwd,
1005                    created_at,
1006                    updated_at,
1007                    message_count,
1008                    first_message,
1009                    name,
1010                    summary,
1011                });
1012            }
1013        }
1014
1015        sessions.sort_by(|a, b| {
1016            b.updated_at
1017                .cmp(&a.updated_at)
1018                .then_with(|| b.created_at.cmp(&a.created_at))
1019        });
1020        Ok(sessions)
1021    }
1022}
1023
1024/// Sanitize a message history for API submission.
1025///
1026/// Strips unpaired tool_call blocks (assistant tool_use without matching tool_result)
1027/// and orphaned tool_result messages (tool_result without matching tool_use).
1028/// This handles both old sessions (before tool_result persistence) and corrupted
1029/// sessions where tool calls were partially recorded.
1030pub fn sanitize_messages(messages: &mut Vec<Message>) {
1031    use std::collections::HashSet;
1032
1033    // Collect tool_result IDs to find which tool_calls have results
1034    let result_ids: HashSet<String> = messages
1035        .iter()
1036        .filter_map(|m| match m {
1037            Message::ToolResult(tr) => Some(tr.tool_call_id.clone()),
1038            _ => None,
1039        })
1040        .collect();
1041
1042    // Strip unpaired tool_call blocks from assistant messages
1043    for msg in messages.iter_mut() {
1044        if let Message::Assistant(assistant) = msg {
1045            assistant.content.retain(|block| match block {
1046                imp_llm::ContentBlock::ToolCall { id, .. } => result_ids.contains(id),
1047                _ => true,
1048            });
1049        }
1050    }
1051
1052    // Remove empty assistant messages left after stripping
1053    messages.retain(|msg| match msg {
1054        Message::Assistant(a) => !a.content.is_empty(),
1055        _ => true,
1056    });
1057
1058    // Strip orphaned tool_results whose tool_call no longer exists
1059    let remaining_call_ids: HashSet<String> = messages
1060        .iter()
1061        .filter_map(|m| match m {
1062            Message::Assistant(a) => Some(a.content.iter().filter_map(|b| match b {
1063                imp_llm::ContentBlock::ToolCall { id, .. } => Some(id.clone()),
1064                _ => None,
1065            })),
1066            _ => None,
1067        })
1068        .flatten()
1069        .collect();
1070    messages.retain(|msg| match msg {
1071        Message::ToolResult(tr) => remaining_call_ids.contains(&tr.tool_call_id),
1072        _ => true,
1073    });
1074
1075    // Reorder: ensure each tool_result follows the assistant message that
1076    // contains its tool_call. Session persistence can write tool_results
1077    // before the assistant message (ToolExecutionEnd fires before TurnEnd).
1078    reorder_tool_results(messages);
1079}
1080
1081/// Move tool_result messages so they immediately follow the assistant
1082/// message containing the matching tool_call.
1083fn reorder_tool_results(messages: &mut Vec<Message>) {
1084    use std::collections::HashMap;
1085
1086    // Build map: tool_call_id → index of the assistant message that has it
1087    let mut call_to_assistant: HashMap<String, usize> = HashMap::new();
1088    for (i, msg) in messages.iter().enumerate() {
1089        if let Message::Assistant(a) = msg {
1090            for block in &a.content {
1091                if let imp_llm::ContentBlock::ToolCall { id, .. } = block {
1092                    call_to_assistant.insert(id.clone(), i);
1093                }
1094            }
1095        }
1096    }
1097
1098    // Separate tool_results that are out of order
1099    let mut deferred: Vec<(usize, Message)> = Vec::new(); // (target_after_idx, msg)
1100    let mut i = 0;
1101    while i < messages.len() {
1102        if let Message::ToolResult(tr) = &messages[i] {
1103            if let Some(&assistant_idx) = call_to_assistant.get(&tr.tool_call_id) {
1104                if i < assistant_idx {
1105                    // tool_result appears before its assistant — pull it out
1106                    let msg = messages.remove(i);
1107                    deferred.push((assistant_idx, msg));
1108                    // Adjust assistant indices after removal
1109                    for v in call_to_assistant.values_mut() {
1110                        if *v > i {
1111                            *v -= 1;
1112                        }
1113                    }
1114                    for d in &mut deferred {
1115                        if d.0 > i {
1116                            d.0 -= 1;
1117                        }
1118                    }
1119                    continue; // don't increment i
1120                }
1121            }
1122        }
1123        i += 1;
1124    }
1125
1126    // Re-insert deferred tool_results after their assistant messages
1127    // Sort by target index descending so insertions don't shift earlier targets
1128    deferred.sort_by(|a, b| b.0.cmp(&a.0));
1129    for (target_idx, msg) in deferred {
1130        let insert_at = (target_idx + 1).min(messages.len());
1131        messages.insert(insert_at, msg);
1132    }
1133}
1134
1135/// Extract the first text content from a message.
1136fn extract_text(message: &Message) -> Option<String> {
1137    let blocks = match message {
1138        Message::User(u) => &u.content,
1139        Message::Assistant(a) => &a.content,
1140        Message::ToolResult(t) => &t.content,
1141    };
1142    blocks.iter().find_map(|b| match b {
1143        imp_llm::ContentBlock::Text { text } => Some(text.clone()),
1144        _ => None,
1145    })
1146}
1147
1148fn derive_session_summary(entries: &[SessionEntry]) -> Option<String> {
1149    let mut parts = Vec::new();
1150
1151    for entry in entries.iter().rev() {
1152        match entry {
1153            SessionEntry::SessionMeta {
1154                summary: Some(summary),
1155                ..
1156            } if !summary.trim().is_empty() => {
1157                return Some(truncate_chars_with_suffix(summary.trim(), 120, "…"));
1158            }
1159            // Session summaries are stored in compact session-meta entries.
1160            SessionEntry::Compaction { summary, .. } => {
1161                let trimmed = cleanup_summary_text(summary);
1162                if !trimmed.is_empty() {
1163                    parts.push(trimmed);
1164                }
1165            }
1166            SessionEntry::Message { message, .. } => {
1167                if let Message::Assistant(_) = message {
1168                    if let Some(text) = extract_text(message) {
1169                        let trimmed = cleanup_summary_text(&text);
1170                        if !trimmed.is_empty() {
1171                            parts.push(trimmed);
1172                        }
1173                    }
1174                }
1175            }
1176            _ => {}
1177        }
1178
1179        if parts.len() >= 3 {
1180            break;
1181        }
1182    }
1183
1184    if parts.is_empty() {
1185        return None;
1186    }
1187
1188    let joined = parts.into_iter().rev().collect::<Vec<_>>().join(" ");
1189    let collapsed = joined.split_whitespace().collect::<Vec<_>>().join(" ");
1190    if collapsed.is_empty() {
1191        None
1192    } else {
1193        Some(truncate_chars_with_suffix(&collapsed, 120, "…"))
1194    }
1195}
1196
1197fn cleanup_summary_text(text: &str) -> String {
1198    let mut collapsed = text
1199        .split_whitespace()
1200        .collect::<Vec<_>>()
1201        .join(" ")
1202        .trim()
1203        .to_string();
1204
1205    for prefix in [
1206        "summary:",
1207        "session summary:",
1208        "assistant summary:",
1209        "in summary,",
1210        "to summarize,",
1211    ] {
1212        if collapsed.to_ascii_lowercase().starts_with(prefix) {
1213            collapsed = collapsed[prefix.len()..].trim().to_string();
1214            break;
1215        }
1216    }
1217
1218    collapsed
1219}
1220
1221fn preferred_title_candidate(
1222    first_prompt: Option<&str>,
1223    summary: Option<&str>,
1224    max_chars: usize,
1225) -> Option<String> {
1226    let first_prompt = first_prompt
1227        .map(cleanup_summary_text)
1228        .filter(|text| !text.is_empty());
1229    let summary = summary
1230        .map(cleanup_summary_text)
1231        .filter(|text| !text.is_empty());
1232
1233    match (first_prompt.as_deref(), summary.as_deref()) {
1234        (Some(prompt), Some(summary)) => {
1235            let prompt_title = literal_topic_title(prompt, max_chars);
1236            let summary_title = literal_topic_title(summary, max_chars);
1237            choose_better_title(prompt_title, summary_title, max_chars)
1238        }
1239        (Some(prompt), None) => literal_topic_title(prompt, max_chars),
1240        (None, Some(summary)) => literal_topic_title(summary, max_chars),
1241        (None, None) => None,
1242    }
1243}
1244
1245fn choose_better_title(
1246    prompt_title: Option<String>,
1247    summary_title: Option<String>,
1248    max_chars: usize,
1249) -> Option<String> {
1250    match (prompt_title, summary_title) {
1251        (Some(prompt), Some(summary)) => {
1252            if is_generic_title(&prompt) && !is_generic_title(&summary) {
1253                Some(summary)
1254            } else if !is_generic_title(&prompt) && is_generic_title(&summary) {
1255                Some(prompt)
1256            } else if topic_word_count(&summary) > topic_word_count(&prompt) {
1257                Some(summary)
1258            } else {
1259                Some(truncate_chars_with_suffix(&prompt, max_chars, "…"))
1260            }
1261        }
1262        (Some(prompt), None) => Some(prompt),
1263        (None, Some(summary)) => Some(summary),
1264        (None, None) => None,
1265    }
1266}
1267
1268fn topic_word_count(title: &str) -> usize {
1269    title
1270        .split_whitespace()
1271        .filter(|word| word.len() >= 4)
1272        .count()
1273}
1274
1275fn literal_topic_title(text: &str, max_chars: usize) -> Option<String> {
1276    let cleaned = cleanup_summary_text(text);
1277    if cleaned.is_empty() {
1278        return None;
1279    }
1280
1281    let literal = concise_topic_phrase(&cleaned, max_chars);
1282    if !literal.trim().is_empty() && !is_generic_title(&literal) {
1283        return Some(literal);
1284    }
1285
1286    let heuristic = summarize_session_title(&cleaned, max_chars);
1287    if !heuristic.trim().is_empty() && !is_generic_title(&heuristic) {
1288        return Some(heuristic);
1289    }
1290
1291    Some(truncate_chars_with_suffix(cleaned.trim(), max_chars, "…"))
1292}
1293
1294fn is_generic_title(title: &str) -> bool {
1295    let lower = title.trim().to_ascii_lowercase();
1296    if lower.is_empty() {
1297        return true;
1298    }
1299
1300    let generic_words = [
1301        "yes", "yeah", "yep", "ok", "okay", "sure", "think", "some", "pretty", "good", "great",
1302        "nice", "maybe", "just", "really", "thing", "stuff",
1303    ];
1304
1305    let words: Vec<&str> = lower.split_whitespace().collect();
1306    if words.len() <= 2 && words.iter().all(|w| generic_words.contains(w)) {
1307        return true;
1308    }
1309
1310    words.iter().filter(|w| generic_words.contains(w)).count() >= words.len().saturating_sub(1)
1311}
1312
1313fn concise_topic_phrase(text: &str, max_chars: usize) -> String {
1314    let collapsed = text
1315        .split_whitespace()
1316        .collect::<Vec<_>>()
1317        .join(" ")
1318        .trim()
1319        .to_string();
1320
1321    let mut phrase = collapsed
1322        .split_terminator(['.', '!', '?', ';', ':'])
1323        .find_map(|part| {
1324            let trimmed = part.trim();
1325            if trimmed.split_whitespace().count() >= 3 {
1326                Some(trimmed.to_string())
1327            } else {
1328                None
1329            }
1330        })
1331        .unwrap_or(collapsed);
1332
1333    let leading_phrases = [
1334        "we should ",
1335        "let's ",
1336        "i want to ",
1337        "i'd like to ",
1338        "can we ",
1339        "can you ",
1340        "could we ",
1341        "could you ",
1342        "would you ",
1343        "please ",
1344        "help me ",
1345        "yes ",
1346        "yeah ",
1347        "ok ",
1348        "okay ",
1349        "sure ",
1350        "i think ",
1351        "think ",
1352    ];
1353
1354    let lower = phrase.to_ascii_lowercase();
1355    for prefix in leading_phrases {
1356        if let Some(stripped) = lower.strip_prefix(prefix) {
1357            phrase = stripped.trim().to_string();
1358            break;
1359        }
1360    }
1361
1362    let stopwords = [
1363        "a",
1364        "an",
1365        "and",
1366        "are",
1367        "as",
1368        "at",
1369        "be",
1370        "but",
1371        "by",
1372        "for",
1373        "from",
1374        "how",
1375        "i",
1376        "if",
1377        "in",
1378        "into",
1379        "is",
1380        "it",
1381        "its",
1382        "me",
1383        "my",
1384        "of",
1385        "on",
1386        "or",
1387        "please",
1388        "so",
1389        "that",
1390        "the",
1391        "their",
1392        "them",
1393        "there",
1394        "these",
1395        "they",
1396        "this",
1397        "to",
1398        "up",
1399        "we",
1400        "what",
1401        "when",
1402        "where",
1403        "which",
1404        "while",
1405        "with",
1406        "would",
1407        "can",
1408        "could",
1409        "should",
1410        "work",
1411        "working",
1412        "improving",
1413        "improve",
1414        "usability",
1415        "currently",
1416        "displayed",
1417        "shown",
1418        "information",
1419        "some",
1420        "pretty",
1421        "really",
1422        "just",
1423        "think",
1424        "yes",
1425        "yeah",
1426        "okay",
1427        "ok",
1428        "sure",
1429    ];
1430
1431    let normalized = phrase
1432        .replace("/resume", "resume")
1433        .replace("chat summaries", "chat_summaries")
1434        .replace("top bar", "top_bar")
1435        .replace("session picker", "session_picker")
1436        .replace("oauth login", "oauth_login")
1437        .replace("provider refresh", "provider_refresh");
1438
1439    let mut tokens = Vec::new();
1440    for raw in normalized.split(|c: char| !c.is_ascii_alphanumeric() && c != '_') {
1441        if raw.is_empty() {
1442            continue;
1443        }
1444        let lower = raw.to_ascii_lowercase();
1445        if stopwords.contains(&lower.as_str()) {
1446            continue;
1447        }
1448        if tokens.iter().any(|existing: &String| existing == &lower) {
1449            continue;
1450        }
1451        tokens.push(lower);
1452    }
1453
1454    if tokens.is_empty() {
1455        let words: Vec<&str> = phrase.split_whitespace().collect();
1456        let take = words.len().min(4);
1457        return truncate_chars_with_suffix(&words[..take].join(" "), max_chars, "…");
1458    }
1459
1460    let mut out = tokens
1461        .into_iter()
1462        .take(5)
1463        .map(|token| match token.as_str() {
1464            "chat_summaries" => "chat summaries".to_string(),
1465            "top_bar" => "top bar".to_string(),
1466            "session_picker" => "session picker".to_string(),
1467            "oauth_login" => "oauth login".to_string(),
1468            "provider_refresh" => "provider refresh".to_string(),
1469            _ => token,
1470        })
1471        .collect::<Vec<_>>();
1472
1473    if out.len() > 4 {
1474        out.truncate(4);
1475    }
1476
1477    let mut out = out.join(" ");
1478
1479    out = out.replace("resume chat summaries", "resume + summaries");
1480    truncate_chars_with_suffix(out.trim(), max_chars, "…")
1481}
1482
1483fn summarize_session_title(text: &str, max_chars: usize) -> String {
1484    let collapsed = text
1485        .lines()
1486        .map(str::trim)
1487        .filter(|line| !line.is_empty())
1488        .collect::<Vec<_>>()
1489        .join(" ");
1490    let mut normalized = collapsed.to_ascii_lowercase();
1491
1492    for prefix in [
1493        "can we ",
1494        "could we ",
1495        "can you ",
1496        "could you ",
1497        "would you ",
1498        "please ",
1499        "please can you ",
1500        "please could you ",
1501        "help me ",
1502        "i want to ",
1503        "i'd like to ",
1504        "let's ",
1505    ] {
1506        if let Some(stripped) = normalized.strip_prefix(prefix) {
1507            normalized = stripped.to_string();
1508            break;
1509        }
1510    }
1511
1512    for (phrase, token) in [
1513        ("top bar", "top_bar"),
1514        ("prompt box", "prompt_box"),
1515        ("thinking level", "thinking_level"),
1516        ("model name", "model_name"),
1517        ("session name", "session_name"),
1518        ("chat title", "chat_title"),
1519        ("chat name", "chat_name"),
1520        ("session id", "session_id"),
1521        ("context window", "context_window"),
1522    ] {
1523        normalized = normalized.replace(phrase, token);
1524    }
1525
1526    let mentions_top_bar_layout = normalized.contains("top_bar")
1527        && (normalized.contains("display")
1528            || normalized.contains("displayed")
1529            || normalized.contains("shown")
1530            || normalized.contains("information"));
1531
1532    let verbs = [
1533        "fix",
1534        "adjust",
1535        "update",
1536        "change",
1537        "move",
1538        "rename",
1539        "remove",
1540        "add",
1541        "show",
1542        "hide",
1543        "improve",
1544        "refactor",
1545        "debug",
1546        "investigate",
1547        "implement",
1548        "summarize",
1549    ];
1550
1551    let stopwords = [
1552        "a",
1553        "an",
1554        "and",
1555        "are",
1556        "as",
1557        "at",
1558        "be",
1559        "but",
1560        "by",
1561        "for",
1562        "from",
1563        "get",
1564        "have",
1565        "how",
1566        "i",
1567        "if",
1568        "in",
1569        "instead",
1570        "into",
1571        "is",
1572        "it",
1573        "its",
1574        "me",
1575        "my",
1576        "now",
1577        "of",
1578        "on",
1579        "or",
1580        "please",
1581        "right",
1582        "so",
1583        "string",
1584        "that",
1585        "the",
1586        "their",
1587        "them",
1588        "then",
1589        "there",
1590        "these",
1591        "they",
1592        "this",
1593        "to",
1594        "up",
1595        "we",
1596        "what",
1597        "when",
1598        "where",
1599        "which",
1600        "while",
1601        "with",
1602        "would",
1603        "listed",
1604        "resume",
1605        "prompt",
1606        "first",
1607        "summarized",
1608        "summarize",
1609        "information",
1610        "display",
1611        "displayed",
1612        "shown",
1613        "currently",
1614    ];
1615
1616    let mut verb: Option<String> = None;
1617    let mut nouns: Vec<String> = Vec::new();
1618
1619    for raw in normalized.split(|c: char| !c.is_ascii_alphanumeric() && c != '_') {
1620        if raw.is_empty() {
1621            continue;
1622        }
1623        if verb.is_none() && verbs.contains(&raw) {
1624            verb = Some(raw.to_string());
1625            continue;
1626        }
1627        if stopwords.contains(&raw) {
1628            continue;
1629        }
1630        if nouns.iter().any(|existing| existing == raw) {
1631            continue;
1632        }
1633        nouns.push(raw.to_string());
1634    }
1635
1636    let mut parts = Vec::new();
1637    if let Some(verb) = verb {
1638        parts.push(verb);
1639    }
1640
1641    for noun in nouns {
1642        if parts.len() >= 4 {
1643            break;
1644        }
1645        parts.push(noun.clone());
1646        if noun == "top_bar" && mentions_top_bar_layout && parts.len() < 4 {
1647            parts.push("layout".to_string());
1648        }
1649    }
1650
1651    if parts.is_empty() {
1652        parts.push(collapsed.trim().to_string());
1653    }
1654
1655    let summary = parts
1656        .into_iter()
1657        .map(|part| match part.as_str() {
1658            "top_bar" => "top bar".to_string(),
1659            "prompt_box" => "prompt box".to_string(),
1660            "thinking_level" => "thinking level".to_string(),
1661            "model_name" => "model name".to_string(),
1662            "session_name" => "session name".to_string(),
1663            "chat_title" => "chat title".to_string(),
1664            "chat_name" => "chat name".to_string(),
1665            "session_id" => "session id".to_string(),
1666            "context_window" => "context window".to_string(),
1667            _ => part,
1668        })
1669        .collect::<Vec<_>>()
1670        .join(" ");
1671
1672    truncate_chars_with_suffix(summary.trim(), max_chars, "…")
1673}
1674
1675/// Read just the first non-empty line of a file.
1676fn read_first_line(path: &Path) -> Result<String> {
1677    use std::io::BufRead;
1678    let file = std::fs::File::open(path)?;
1679    let reader = std::io::BufReader::new(file);
1680    for line in reader.lines() {
1681        let line = line?;
1682        if !line.trim().is_empty() {
1683            return Ok(line);
1684        }
1685    }
1686    Err(crate::error::Error::Session("empty file".into()))
1687}
1688
1689#[cfg(test)]
1690mod tests {
1691    use super::*;
1692    use async_trait::async_trait;
1693    use futures::stream;
1694    use imp_llm::{
1695        auth::{ApiKey, AuthStore},
1696        model::{Capabilities, ModelMeta, ModelPricing},
1697        provider::{Context, Provider, RequestOptions},
1698        AssistantMessage, ContentBlock, Message, StopReason, StreamEvent,
1699    };
1700    use tempfile::TempDir;
1701
1702    struct NoopProvider {
1703        models: Vec<ModelMeta>,
1704    }
1705
1706    #[async_trait]
1707    impl Provider for NoopProvider {
1708        fn stream(
1709            &self,
1710            _model: &Model,
1711            _context: Context,
1712            _options: RequestOptions,
1713            _api_key: &str,
1714        ) -> std::pin::Pin<Box<dyn futures_core::Stream<Item = imp_llm::Result<StreamEvent>> + Send>>
1715        {
1716            Box::pin(stream::empty())
1717        }
1718
1719        async fn resolve_auth(&self, _auth: &AuthStore) -> imp_llm::Result<ApiKey> {
1720            Ok(String::new())
1721        }
1722
1723        fn id(&self) -> &str {
1724            "noop"
1725        }
1726
1727        fn models(&self) -> &[ModelMeta] {
1728            &self.models
1729        }
1730    }
1731
1732    fn make_msg_entry(id: &str, text: &str) -> SessionEntry {
1733        SessionEntry::Message {
1734            id: id.to_string(),
1735            parent_id: None, // append() will set this
1736            message: Message::user(text),
1737        }
1738    }
1739
1740    #[test]
1741    fn summarized_title_compacts_request_into_short_label() {
1742        let title = summarize_session_title(
1743            "can we adjust the information that is displayed in the top bar",
1744            48,
1745        );
1746        assert_eq!(title, "adjust top bar layout");
1747    }
1748
1749    #[test]
1750    fn literal_topic_title_prefers_subject_words_over_compaction() {
1751        let title = literal_topic_title(
1752            "can we work on improving the usability of /resume and the chat summaries?",
1753            64,
1754        )
1755        .unwrap();
1756
1757        assert!(title.contains("resume") || title.contains("summaries"));
1758        assert!(title.split_whitespace().count() <= 5);
1759    }
1760
1761    #[test]
1762    fn generic_summary_title_falls_back_to_more_descriptive_phrase() {
1763        let title = literal_topic_title(
1764            "yes think some pretty significant issues with oauth login persistence and provider refresh",
1765            64,
1766        )
1767        .unwrap();
1768
1769        assert!(title.contains("oauth") || title.contains("login"));
1770        assert!(title.split_whitespace().count() <= 5);
1771        assert_ne!(title, "yes think some pretty");
1772    }
1773
1774    #[test]
1775    fn session_titles_can_be_derived_from_summary_text() {
1776        let info = SessionInfo {
1777            id: "abc".into(),
1778            path: PathBuf::from("/tmp/abc.jsonl"),
1779            cwd: "/tmp/project".into(),
1780            created_at: 0,
1781            updated_at: 0,
1782            message_count: 1,
1783            first_message: Some("help me with oauth login issues".into()),
1784            name: None,
1785            summary: Some(
1786                "Investigated OAuth login failures and refreshed provider auth flow".into(),
1787            ),
1788        };
1789
1790        let title = info.title(48).unwrap();
1791        assert!(!title.is_empty());
1792        assert!(title.contains("oauth") || title.contains("login") || title.contains("provider"));
1793        assert!(title.split_whitespace().count() <= 5);
1794    }
1795
1796    #[test]
1797    fn session_compaction_active_messages_replace_prefix_with_summary() {
1798        let mut mgr = SessionManager::in_memory();
1799
1800        mgr.append(make_msg_entry("u1", "first request")).unwrap();
1801        mgr.append(SessionEntry::Message {
1802            id: "a1".into(),
1803            parent_id: None,
1804            message: Message::Assistant(AssistantMessage {
1805                content: vec![ContentBlock::Text {
1806                    text: "initial answer".into(),
1807                }],
1808                usage: None,
1809                stop_reason: StopReason::EndTurn,
1810                timestamp: 1,
1811            }),
1812        })
1813        .unwrap();
1814        mgr.append(make_msg_entry("u2", "latest request")).unwrap();
1815        mgr.append(SessionEntry::Compaction {
1816            id: "c1".into(),
1817            parent_id: None,
1818            summary: "Compaction summary of earlier work".into(),
1819            first_kept_id: "u2".into(),
1820            tokens_before: 100,
1821            tokens_after: 40,
1822        })
1823        .unwrap();
1824        mgr.append(SessionEntry::Message {
1825            id: "a2".into(),
1826            parent_id: None,
1827            message: Message::Assistant(AssistantMessage {
1828                content: vec![ContentBlock::Text {
1829                    text: "follow-up answer".into(),
1830                }],
1831                usage: None,
1832                stop_reason: StopReason::EndTurn,
1833                timestamp: 2,
1834            }),
1835        })
1836        .unwrap();
1837
1838        let raw = mgr.get_messages();
1839        assert_eq!(raw.len(), 4);
1840
1841        let active = mgr.get_active_messages();
1842        assert_eq!(active.len(), 3);
1843        match &active[0] {
1844            Message::User(user) => match user.content.as_slice() {
1845                [ContentBlock::Text { text }] => {
1846                    assert_eq!(text, "Compaction summary of earlier work")
1847                }
1848                other => panic!("unexpected summary content: {other:?}"),
1849            },
1850            other => panic!("unexpected active message: {other:?}"),
1851        }
1852        match &active[1] {
1853            Message::User(user) => match user.content.as_slice() {
1854                [ContentBlock::Text { text }] => assert_eq!(text, "latest request"),
1855                other => panic!("unexpected kept user content: {other:?}"),
1856            },
1857            other => panic!("unexpected kept message: {other:?}"),
1858        }
1859    }
1860
1861    #[test]
1862    fn session_compaction_active_messages_fall_back_to_raw_when_first_kept_missing() {
1863        let mut mgr = SessionManager::in_memory();
1864        mgr.append(make_msg_entry("u1", "hello")).unwrap();
1865        mgr.append(SessionEntry::Compaction {
1866            id: "c1".into(),
1867            parent_id: None,
1868            summary: "summary only".into(),
1869            first_kept_id: "missing".into(),
1870            tokens_before: 10,
1871            tokens_after: 3,
1872        })
1873        .unwrap();
1874
1875        let active = mgr.get_active_messages();
1876        assert_eq!(active.len(), 1);
1877        match &active[0] {
1878            Message::User(user) => match user.content.as_slice() {
1879                [ContentBlock::Text { text }] => assert_eq!(text, "summary only"),
1880                other => panic!("unexpected summary-only content: {other:?}"),
1881            },
1882            other => panic!("unexpected active message: {other:?}"),
1883        }
1884    }
1885
1886    #[test]
1887    fn session_compaction_fork_preserves_compacted_branch_semantics() {
1888        let tmp = TempDir::new().unwrap();
1889        let fork_path = tmp.path().join("forked.jsonl");
1890
1891        let mut mgr = SessionManager::in_memory();
1892        mgr.append(make_msg_entry("u1", "older")).unwrap();
1893        mgr.append(make_msg_entry("u2", "newer")).unwrap();
1894        mgr.append(SessionEntry::Compaction {
1895            id: "c1".into(),
1896            parent_id: None,
1897            summary: "summary older".into(),
1898            first_kept_id: "u2".into(),
1899            tokens_before: 20,
1900            tokens_after: 8,
1901        })
1902        .unwrap();
1903        mgr.append(SessionEntry::Message {
1904            id: "a2".into(),
1905            parent_id: None,
1906            message: Message::Assistant(AssistantMessage {
1907                content: vec![ContentBlock::Text {
1908                    text: "done".into(),
1909                }],
1910                usage: None,
1911                stop_reason: StopReason::EndTurn,
1912                timestamp: 3,
1913            }),
1914        })
1915        .unwrap();
1916
1917        let forked = mgr.fork("a2", &fork_path).unwrap();
1918        let active = forked.get_active_messages();
1919        assert_eq!(active.len(), 3);
1920        match &active[0] {
1921            Message::User(user) => match user.content.as_slice() {
1922                [ContentBlock::Text { text }] => assert_eq!(text, "summary older"),
1923                other => panic!("unexpected summary content: {other:?}"),
1924            },
1925            other => panic!("unexpected active message: {other:?}"),
1926        }
1927    }
1928
1929    #[test]
1930    fn session_create_append_reopen() {
1931        let tmp = TempDir::new().unwrap();
1932        let session_dir = tmp.path().join("sessions");
1933        let cwd = tmp.path().join("project");
1934
1935        let mut mgr = SessionManager::new(&cwd, &session_dir).unwrap();
1936        mgr.append(make_msg_entry("m1", "hello")).unwrap();
1937        mgr.append(make_msg_entry("m2", "world")).unwrap();
1938        mgr.append(make_msg_entry("m3", "!")).unwrap();
1939
1940        let path = mgr.path().unwrap().to_path_buf();
1941        assert!(path.exists());
1942
1943        // Reopen and verify messages match
1944        let reopened = SessionManager::open(&path).unwrap();
1945        let original_msgs = mgr.get_messages();
1946        let reopened_msgs = reopened.get_messages();
1947        assert_eq!(original_msgs.len(), reopened_msgs.len());
1948        assert_eq!(reopened_msgs.len(), 3);
1949
1950        // Verify parent chain: m1 has no parent, m2's parent is m1, m3's parent is m2
1951        let entries = reopened.entries();
1952        for entry in entries {
1953            if let SessionEntry::Message { id, parent_id, .. } = entry {
1954                match id.as_str() {
1955                    "m1" => assert_eq!(*parent_id, None),
1956                    "m2" => assert_eq!(parent_id.as_deref(), Some("m1")),
1957                    "m3" => assert_eq!(parent_id.as_deref(), Some("m2")),
1958                    _ => {}
1959                }
1960            }
1961        }
1962    }
1963
1964    #[test]
1965    fn session_branch() {
1966        let mut mgr = SessionManager::in_memory();
1967        // Append 5 messages (m1..m5)
1968        for i in 1..=5 {
1969            mgr.append(make_msg_entry(&format!("m{i}"), &format!("msg {i}")))
1970                .unwrap();
1971        }
1972        assert_eq!(mgr.get_messages().len(), 5);
1973        assert_eq!(mgr.leaf_id(), Some("m5"));
1974
1975        // Navigate back to m3
1976        mgr.navigate("m3").unwrap();
1977        assert_eq!(mgr.leaf_id(), Some("m3"));
1978
1979        // Append 2 new messages on the branch
1980        mgr.append(make_msg_entry("b1", "branch 1")).unwrap();
1981        mgr.append(make_msg_entry("b2", "branch 2")).unwrap();
1982
1983        // get_branch should return: header-less chain of m1, m2, m3, b1, b2
1984        let branch = mgr.get_branch();
1985        let branch_ids: Vec<Option<&str>> = branch.iter().map(|e| e.id()).collect();
1986        assert_eq!(
1987            branch_ids,
1988            vec![Some("m1"), Some("m2"), Some("m3"), Some("b1"), Some("b2")]
1989        );
1990        assert_eq!(mgr.get_messages().len(), 5);
1991
1992        // Navigate back to m5 to verify original branch still works
1993        mgr.navigate("m5").unwrap();
1994        let main_branch = mgr.get_branch();
1995        let main_ids: Vec<Option<&str>> = main_branch.iter().map(|e| e.id()).collect();
1996        assert_eq!(
1997            main_ids,
1998            vec![Some("m1"), Some("m2"), Some("m3"), Some("m4"), Some("m5")]
1999        );
2000    }
2001
2002    #[test]
2003    fn session_fork() {
2004        let tmp = TempDir::new().unwrap();
2005        let session_dir = tmp.path().join("sessions");
2006        let cwd = tmp.path().join("project");
2007
2008        let mut mgr = SessionManager::new(&cwd, &session_dir).unwrap();
2009        for i in 1..=5 {
2010            mgr.append(make_msg_entry(&format!("m{i}"), &format!("msg {i}")))
2011                .unwrap();
2012        }
2013
2014        let fork_path = session_dir.join("forked.jsonl");
2015        let forked = mgr.fork("m3", &fork_path).unwrap();
2016
2017        // Forked session should have header + m1, m2, m3
2018        assert_eq!(forked.get_messages().len(), 3);
2019        assert_eq!(forked.leaf_id(), Some("m3"));
2020        assert!(fork_path.exists());
2021
2022        // Reopen the forked file and verify
2023        let reopened = SessionManager::open(&fork_path).unwrap();
2024        assert_eq!(reopened.get_messages().len(), 3);
2025    }
2026
2027    #[test]
2028    fn session_list() {
2029        let tmp = TempDir::new().unwrap();
2030        let session_dir = tmp.path().join("sessions");
2031        let cwd = tmp.path().join("project");
2032
2033        // Create two sessions
2034        let mut s1 = SessionManager::new(&cwd, &session_dir).unwrap();
2035        s1.append(make_msg_entry("a1", "first session")).unwrap();
2036        s1.set_name("First");
2037
2038        let mut s2 = SessionManager::new(&cwd, &session_dir).unwrap();
2039        s2.append(make_msg_entry("b1", "second session")).unwrap();
2040        s2.append(make_msg_entry("b2", "more stuff")).unwrap();
2041        s2.set_summary("Second session summary");
2042
2043        let sessions = SessionManager::list(&session_dir).unwrap();
2044        assert_eq!(sessions.len(), 2);
2045
2046        // Both should have the right cwd
2047        for s in &sessions {
2048            assert_eq!(s.cwd, cwd.to_string_lossy().to_string());
2049        }
2050
2051        // One has 1 message, the other has 2
2052        let mut counts: Vec<usize> = sessions.iter().map(|s| s.message_count).collect();
2053        counts.sort();
2054        assert_eq!(counts, vec![1, 2]);
2055
2056        // first_message should be set
2057        for s in &sessions {
2058            assert!(s.first_message.is_some());
2059        }
2060
2061        assert!(sessions.iter().any(|s| s.name.as_deref() == Some("First")));
2062        assert!(sessions
2063            .iter()
2064            .any(|s| s.summary.as_deref() == Some("Second session summary")));
2065    }
2066
2067    #[test]
2068    fn session_continue_recent() {
2069        let tmp = TempDir::new().unwrap();
2070        let session_dir = tmp.path().join("sessions");
2071        let cwd_a = tmp.path().join("project-a");
2072        let cwd_b = tmp.path().join("project-b");
2073
2074        // Create a session for cwd_a
2075        let mut s1 = SessionManager::new(&cwd_a, &session_dir).unwrap();
2076        s1.append(make_msg_entry("a1", "hello from a")).unwrap();
2077
2078        // Create a session for cwd_b
2079        let mut s2 = SessionManager::new(&cwd_b, &session_dir).unwrap();
2080        s2.append(make_msg_entry("b1", "hello from b")).unwrap();
2081
2082        // continue_recent for cwd_a should find s1
2083        let continued = SessionManager::continue_recent(&cwd_a, &session_dir)
2084            .unwrap()
2085            .expect("should find a session");
2086        assert_eq!(continued.get_messages().len(), 1);
2087
2088        // continue_recent for a non-existent cwd returns None
2089        let none =
2090            SessionManager::continue_recent(Path::new("/nonexistent"), &session_dir).unwrap();
2091        assert!(none.is_none());
2092    }
2093
2094    #[test]
2095    fn session_name_and_summary_persist_across_reopen() {
2096        let tmp = TempDir::new().unwrap();
2097        let session_dir = tmp.path().join("sessions");
2098        let cwd = tmp.path().join("project");
2099
2100        let mut mgr = SessionManager::new(&cwd, &session_dir).unwrap();
2101        mgr.append(make_msg_entry("m1", "hello world")).unwrap();
2102        mgr.set_name("Debug auth");
2103        mgr.set_summary("Investigating OAuth login failures");
2104
2105        let path = mgr.path().unwrap().to_path_buf();
2106        let reopened = SessionManager::open(&path).unwrap();
2107        assert_eq!(reopened.name(), Some("Debug auth"));
2108        assert_eq!(
2109            reopened.summary(),
2110            Some("Investigating OAuth login failures")
2111        );
2112    }
2113
2114    #[test]
2115    fn session_in_memory() {
2116        let mut mgr = SessionManager::in_memory();
2117        assert!(mgr.path().is_none());
2118
2119        mgr.append(make_msg_entry("m1", "hello")).unwrap();
2120        mgr.append(make_msg_entry("m2", "world")).unwrap();
2121
2122        assert_eq!(mgr.get_messages().len(), 2);
2123        assert_eq!(mgr.entries().len(), 2);
2124    }
2125
2126    #[test]
2127    fn session_malformed_jsonl() {
2128        let tmp = TempDir::new().unwrap();
2129        let path = tmp.path().join("bad.jsonl");
2130
2131        // Write a file with a mix of valid and invalid lines
2132        let content = format!(
2133            "{}\n\
2134             NOT VALID JSON\n\
2135             {}\n\
2136             {{\"type\":\"unknown_variant\",\"foo\":1}}\n\
2137             {}\n",
2138            serde_json::to_string(&SessionEntry::Header {
2139                version: 1,
2140                created_at: 1000,
2141                cwd: "/tmp".into(),
2142            })
2143            .unwrap(),
2144            serde_json::to_string(&SessionEntry::Message {
2145                id: "m1".into(),
2146                parent_id: None,
2147                message: Message::user("hello"),
2148            })
2149            .unwrap(),
2150            serde_json::to_string(&SessionEntry::Message {
2151                id: "m2".into(),
2152                parent_id: Some("m1".into()),
2153                message: Message::user("world"),
2154            })
2155            .unwrap(),
2156        );
2157        std::fs::write(&path, content).unwrap();
2158
2159        // Should succeed, skipping the bad lines
2160        let mgr = SessionManager::open(&path).unwrap();
2161        // Header + 2 valid messages (bad lines skipped)
2162        assert_eq!(mgr.entries().len(), 3);
2163        assert_eq!(mgr.get_messages().len(), 2);
2164    }
2165
2166    #[test]
2167    fn session_get_tree() {
2168        let mut mgr = SessionManager::in_memory();
2169        for i in 1..=3 {
2170            mgr.append(make_msg_entry(&format!("m{i}"), &format!("msg {i}")))
2171                .unwrap();
2172        }
2173        // Branch from m2
2174        mgr.navigate("m2").unwrap();
2175        mgr.append(make_msg_entry("b1", "branch")).unwrap();
2176
2177        let tree = mgr.get_tree();
2178        // Root should be m1 (no parent)
2179        assert_eq!(tree.len(), 1);
2180        assert_eq!(tree[0].entry.id(), Some("m1"));
2181
2182        // m1 -> m2
2183        assert_eq!(tree[0].children.len(), 1);
2184        let m2_node = &tree[0].children[0];
2185        assert_eq!(m2_node.entry.id(), Some("m2"));
2186
2187        // m2 has two children: m3 and b1
2188        assert_eq!(m2_node.children.len(), 2);
2189        let child_ids: Vec<Option<&str>> = m2_node.children.iter().map(|n| n.entry.id()).collect();
2190        assert!(child_ids.contains(&Some("m3")));
2191        assert!(child_ids.contains(&Some("b1")));
2192    }
2193
2194    #[test]
2195    fn append_assistant_turn_persists_canonical_usage_once() {
2196        let tmp = TempDir::new().unwrap();
2197        let session_dir = tmp.path().join("sessions");
2198        let cwd = tmp.path().join("project");
2199        let model = Model {
2200            meta: imp_llm::ModelMeta {
2201                id: "test-model".into(),
2202                provider: "test-provider".into(),
2203                name: "Test Model".into(),
2204                context_window: 8192,
2205                max_output_tokens: 2048,
2206                pricing: ModelPricing {
2207                    input_per_mtok: 1.0,
2208                    output_per_mtok: 2.0,
2209                    cache_read_per_mtok: 0.5,
2210                    cache_write_per_mtok: 1.0,
2211                },
2212                capabilities: Capabilities {
2213                    reasoning: false,
2214                    images: false,
2215                    tool_use: true,
2216                },
2217            },
2218            provider: std::sync::Arc::new(NoopProvider { models: Vec::new() }),
2219        };
2220
2221        let mut mgr = SessionManager::new(&cwd, &session_dir).unwrap();
2222        let message = AssistantMessage {
2223            content: vec![imp_llm::ContentBlock::Text {
2224                text: "done".into(),
2225            }],
2226            usage: Some(imp_llm::Usage {
2227                input_tokens: 100,
2228                output_tokens: 25,
2229                cache_read_tokens: 10,
2230                cache_write_tokens: 5,
2231            }),
2232            stop_reason: imp_llm::StopReason::EndTurn,
2233            timestamp: 123,
2234        };
2235
2236        let (_assistant_id, usage_id) = mgr
2237            .append_assistant_turn(&model, 3, message.clone())
2238            .unwrap();
2239        assert!(usage_id.is_some());
2240
2241        let (_assistant_id_2, usage_id_2) = mgr
2242            .append_assistant_turn(
2243                &model,
2244                4,
2245                AssistantMessage {
2246                    usage: None,
2247                    ..message
2248                },
2249            )
2250            .unwrap();
2251        assert!(usage_id_2.is_none());
2252
2253        let usage_records = mgr.usage_records();
2254        assert_eq!(usage_records.len(), 1);
2255        assert_eq!(usage_records[0].turn_index, Some(3));
2256        assert_eq!(usage_records[0].provider.as_deref(), Some("test-provider"));
2257        assert_eq!(usage_records[0].model.as_deref(), Some("test-model"));
2258    }
2259
2260    #[test]
2261    fn append_checkpoint_record_round_trips_and_lookup_works() {
2262        let tmp = tempfile::tempdir().unwrap();
2263        let cwd = tmp.path().join("project");
2264        let session_dir = tmp.path().join("sessions");
2265        let mut mgr = SessionManager::new(&cwd, &session_dir).unwrap();
2266
2267        let record = SessionCheckpointRecord {
2268            version: CHECKPOINT_RECORD_VERSION,
2269            checkpoint_id: "cp-1".into(),
2270            created_at: 123,
2271            label: Some("before edits".into()),
2272            files: vec!["src/main.rs".into(), "src/lib.rs".into()],
2273        };
2274        mgr.append_checkpoint_record(record.clone()).unwrap();
2275
2276        assert_eq!(mgr.checkpoint_records(), vec![record.clone()]);
2277        assert_eq!(
2278            mgr.find_checkpoint_record("cp-1").unwrap().label.as_deref(),
2279            Some("before edits")
2280        );
2281        assert_eq!(
2282            mgr.find_checkpoint_record("before edits")
2283                .unwrap()
2284                .checkpoint_id,
2285            "cp-1"
2286        );
2287    }
2288
2289    #[test]
2290    fn restore_checkpoint_uses_checkpoint_state() {
2291        let tmp = tempfile::tempdir().unwrap();
2292        let cwd = tmp.path().join("project");
2293        let session_dir = tmp.path().join("sessions");
2294        std::fs::create_dir_all(&cwd).unwrap();
2295        let file = cwd.join("main.rs");
2296        std::fs::write(&file, "original").unwrap();
2297
2298        let checkpoint_state = crate::tools::CheckpointState::new();
2299        let checkpoint = checkpoint_state
2300            .snapshot_paths(std::slice::from_ref(&file), Some("before edits".into()))
2301            .unwrap()
2302            .unwrap();
2303        std::fs::write(&file, "modified").unwrap();
2304
2305        let mut mgr = SessionManager::new(&cwd, &session_dir).unwrap();
2306        mgr.append_checkpoint_record(SessionCheckpointRecord {
2307            version: CHECKPOINT_RECORD_VERSION,
2308            checkpoint_id: checkpoint.id.clone(),
2309            created_at: checkpoint.created_at,
2310            label: checkpoint.label.clone(),
2311            files: checkpoint
2312                .files
2313                .iter()
2314                .map(|path| path.to_string_lossy().to_string())
2315                .collect(),
2316        })
2317        .unwrap();
2318
2319        let restored = mgr
2320            .restore_checkpoint(&checkpoint_state, "before edits")
2321            .unwrap();
2322        assert_eq!(restored, vec![file.clone()]);
2323        assert_eq!(std::fs::read_to_string(&file).unwrap(), "original");
2324    }
2325
2326    #[test]
2327    fn session_navigate_invalid() {
2328        let mut mgr = SessionManager::in_memory();
2329        mgr.append(make_msg_entry("m1", "hello")).unwrap();
2330
2331        let result = mgr.navigate("nonexistent");
2332        assert!(result.is_err());
2333    }
2334}