Skip to main content

opi_agent/
session_branch.rs

1//! Session branch reconstruction (task 4.9).
2//!
3//! Reconstructs the branch tree from session JSONL entries without modifying
4//! the append-only session file. A [`SessionTree`] is built from
5//! [`SessionEntry`] data using the `parent_id` graph and `Leaf` pointer
6//! entries.
7
8use std::collections::{HashMap, HashSet};
9
10use crate::session::SessionEntry;
11
12/// Metadata for a single branch within a session tree.
13#[derive(Debug, Clone)]
14pub struct BranchInfo {
15    /// The entry ID at the tip (leaf-most entry) of this branch.
16    pub tip_id: String,
17    /// Number of edges from the root entry to the tip.
18    pub depth: usize,
19    /// Number of Message/Compaction entries on this branch.
20    pub entry_count: usize,
21    /// Timestamp of the tip entry.
22    pub timestamp: String,
23    /// Short summary from the last message on the branch (first 80 chars).
24    pub summary: Option<String>,
25    /// Entry ID where this branch forks off from the trunk.
26    /// `None` for the trunk (first/root branch).
27    pub fork_point: Option<String>,
28}
29
30/// A reconstructed session tree with branch metadata.
31///
32/// Built from session entries via [`SessionTree::from_entries`]. The tree
33/// identifies distinct branches in the conversation by detecting nodes with
34/// multiple children in the `parent_id` graph. The "trunk" is the first
35/// branch (root to the first fork point or to the tip if no fork exists).
36/// Each child branch starts from a fork point and extends to its own tip.
37///
38/// The **active branch** is determined by the last `Leaf` entry's
39/// `entry_id`. Without leaves, the active branch is the trunk.
40#[derive(Debug, Clone)]
41pub struct SessionTree {
42    branches: Vec<BranchInfo>,
43    leaf_tip: Option<String>,
44    /// Map from entry ID to entry metadata (for branch containment lookups).
45    entries_by_id: HashMap<String, EntryMeta>,
46}
47
48/// Lightweight metadata extracted from a session entry.
49#[derive(Debug, Clone)]
50struct EntryMeta {
51    parent_id: Option<String>,
52    timestamp: String,
53    summary: Option<String>,
54}
55
56impl SessionTree {
57    /// Reconstruct the session tree from a slice of session entries.
58    ///
59    /// This is a read-only operation; it does not modify session storage.
60    pub fn from_entries(entries: &[SessionEntry]) -> Self {
61        let mut meta_by_id: HashMap<String, EntryMeta> = HashMap::new();
62        let mut children: HashMap<String, Vec<String>> = HashMap::new();
63        let mut last_leaf_tip: Option<String> = None;
64
65        for entry in entries {
66            match entry {
67                SessionEntry::Message(m) => {
68                    let summary = extract_message_summary(&m.message);
69                    let meta = EntryMeta {
70                        parent_id: m.parent_id.clone(),
71                        timestamp: m.timestamp.clone(),
72                        summary,
73                    };
74                    if let Some(ref pid) = m.parent_id {
75                        children.entry(pid.clone()).or_default().push(m.id.clone());
76                    }
77                    meta_by_id.insert(m.id.clone(), meta);
78                }
79                SessionEntry::Compaction(c) => {
80                    let meta = EntryMeta {
81                        parent_id: c.parent_id.clone(),
82                        timestamp: c.timestamp.clone(),
83                        summary: Some(c.summary.clone()),
84                    };
85                    if let Some(ref pid) = c.parent_id {
86                        children.entry(pid.clone()).or_default().push(c.id.clone());
87                    }
88                    meta_by_id.insert(c.id.clone(), meta);
89                }
90                SessionEntry::Leaf(l) => {
91                    last_leaf_tip = Some(l.entry_id.clone());
92                }
93                #[allow(unreachable_patterns)]
94                _ => {}
95            }
96        }
97        for child_ids in children.values_mut() {
98            child_ids.sort();
99        }
100
101        // Find roots: entries whose parent is None or whose parent doesn't
102        // exist in the graph. These are the starting points for branch walks.
103        let all_ids: HashSet<&str> = meta_by_id.keys().map(|s| s.as_str()).collect();
104        let has_valid_parent: HashSet<&str> = meta_by_id
105            .iter()
106            .filter(|(_, meta)| {
107                meta.parent_id
108                    .as_deref()
109                    .is_some_and(|pid| all_ids.contains(pid))
110            })
111            .map(|(id, _)| id.as_str())
112            .collect();
113
114        let mut roots: Vec<&str> = all_ids
115            .iter()
116            .filter(|id| !has_valid_parent.contains(*id))
117            .copied()
118            .collect();
119        roots.sort_unstable();
120
121        // Walk from each root to discover branches.
122        let mut branches: Vec<BranchInfo> = Vec::new();
123        let mut visited: HashSet<String> = HashSet::new();
124
125        for &root_id in &roots {
126            discover_branches(
127                root_id,
128                None,
129                0,
130                &meta_by_id,
131                &children,
132                &mut branches,
133                &mut visited,
134            );
135        }
136
137        // Fallback: if no roots found but we have orphaned entries.
138        if roots.is_empty() && !meta_by_id.is_empty() {
139            let mut ids = meta_by_id.keys().collect::<Vec<_>>();
140            ids.sort();
141            for id in ids {
142                let meta = meta_by_id.get(id).expect("sorted id exists");
143                if visited.insert(id.clone()) {
144                    branches.push(BranchInfo {
145                        tip_id: id.clone(),
146                        depth: 0,
147                        entry_count: 1,
148                        timestamp: meta.timestamp.clone(),
149                        summary: meta.summary.clone(),
150                        fork_point: None,
151                    });
152                }
153            }
154        }
155
156        Self {
157            branches,
158            leaf_tip: last_leaf_tip,
159            entries_by_id: meta_by_id,
160        }
161    }
162
163    /// Return the list of discovered branches.
164    pub fn branches(&self) -> &[BranchInfo] {
165        &self.branches
166    }
167
168    /// Return the entry ID at the tip of the active branch.
169    ///
170    /// Determined by the last `Leaf` entry's `entry_id`. If no leaf entries
171    /// exist (legacy linear sessions), falls back to the trunk branch tip.
172    /// Returns `None` only when the session has no content entries.
173    pub fn active_tip(&self) -> Option<&str> {
174        // If a leaf tip exists and is a valid entry, use it.
175        if let Some(ref tip) = self.leaf_tip
176            && self.entries_by_id.contains_key(tip.as_str())
177        {
178            return Some(tip.as_str());
179        }
180        // Fall back to the trunk (first branch) tip.
181        self.branches.first().map(|b| b.tip_id.as_str())
182    }
183
184    /// Return the index of the active branch in the branches list.
185    ///
186    /// If no leaf entries exist, returns index 0 (trunk).
187    pub fn active_branch_index(&self) -> Option<usize> {
188        match &self.leaf_tip {
189            Some(tip) if self.entries_by_id.contains_key(tip.as_str()) => self
190                .branches
191                .iter()
192                .position(|b| b.tip_id == *tip)
193                .or_else(|| {
194                    self.branches
195                        .iter()
196                        .position(|b| self.tip_is_on_branch(tip, &b.tip_id))
197                }),
198            _ => {
199                if self.branches.is_empty() {
200                    None
201                } else {
202                    Some(0)
203                }
204            }
205        }
206    }
207
208    /// Return the branch at the given index, or `None` if out of bounds.
209    pub fn branch_at(&self, index: usize) -> Option<&BranchInfo> {
210        self.branches.get(index)
211    }
212
213    /// Check whether `candidate_id` is on the branch ending at `tip_id`.
214    fn tip_is_on_branch(&self, candidate_id: &str, tip_id: &str) -> bool {
215        let mut visited = HashSet::new();
216        let mut cursor = Some(tip_id);
217        while let Some(id) = cursor {
218            if id == candidate_id {
219                return true;
220            }
221            if !visited.insert(id) {
222                break;
223            }
224            cursor = self
225                .entries_by_id
226                .get(id)
227                .and_then(|m| m.parent_id.as_deref());
228        }
229        false
230    }
231}
232
233/// Walk from `start_id` to its tip, recursing at fork points.
234fn discover_branches(
235    start_id: &str,
236    fork_point: Option<&str>,
237    base_depth: usize,
238    meta_by_id: &HashMap<String, EntryMeta>,
239    children: &HashMap<String, Vec<String>>,
240    branches: &mut Vec<BranchInfo>,
241    visited: &mut HashSet<String>,
242) {
243    let mut cursor = start_id.to_owned();
244    let mut depth = base_depth;
245    let mut entry_count = 0;
246
247    loop {
248        entry_count += 1;
249
250        let child_count = children.get(&cursor).map(|v| v.len()).unwrap_or(0);
251
252        if child_count == 0 {
253            // Tip of this branch.
254            if visited.insert(cursor.clone()) {
255                let m = meta_by_id.get(&cursor);
256                branches.push(BranchInfo {
257                    tip_id: cursor,
258                    depth,
259                    entry_count,
260                    timestamp: m.map(|m| m.timestamp.clone()).unwrap_or_default(),
261                    summary: m.and_then(|m| m.summary.clone()),
262                    fork_point: fork_point.map(|s| s.to_owned()),
263                });
264            }
265            return;
266        }
267
268        if child_count == 1 {
269            // Single child — continue along the chain.
270            depth += 1;
271            cursor = children.get(&cursor).unwrap()[0].clone();
272        } else {
273            // Fork point — record trunk up to here, recurse into children.
274            if visited.insert(cursor.clone()) {
275                let m = meta_by_id.get(&cursor);
276                branches.push(BranchInfo {
277                    tip_id: cursor.clone(),
278                    depth,
279                    entry_count,
280                    timestamp: m.map(|m| m.timestamp.clone()).unwrap_or_default(),
281                    summary: m.and_then(|m| m.summary.clone()),
282                    fork_point: fork_point.map(|s| s.to_owned()),
283                });
284            }
285            for child in children.get(&cursor).unwrap() {
286                discover_branches(
287                    child,
288                    Some(&cursor),
289                    depth + 1,
290                    meta_by_id,
291                    children,
292                    branches,
293                    visited,
294                );
295            }
296            return;
297        }
298    }
299}
300
301/// Extract a short summary from a message (first 80 chars of text content).
302fn extract_message_summary(message: &opi_ai::message::Message) -> Option<String> {
303    use opi_ai::message::{AssistantContent, InputContent, Message};
304
305    match message {
306        Message::User(u) => u.content.iter().find_map(|c| match c {
307            InputContent::Text { text } => {
308                let summary = if text.len() > 80 {
309                    format!("{}...", &text[..text.floor_char_boundary(80)])
310                } else {
311                    text.clone()
312                };
313                Some(summary)
314            }
315            _ => None,
316        }),
317        Message::Assistant(a) => a.content.iter().find_map(|c| match c {
318            AssistantContent::Text { text } => {
319                let summary = if text.len() > 80 {
320                    format!("{}...", &text[..text.floor_char_boundary(80)])
321                } else {
322                    text.clone()
323                };
324                Some(summary)
325            }
326            _ => None,
327        }),
328        _ => None,
329    }
330}