Skip to main content

opi_coding_agent/
session_cli.rs

1//! Session management CLI operations (task 2.7).
2//!
3//! Provides list/resume/delete operations for session JSONL files stored in
4//! the platform-specific sessions directory (S9.2).
5
6use std::path::{Path, PathBuf};
7
8use thiserror::Error;
9
10/// Errors from session CLI operations.
11#[derive(Debug, Error)]
12pub enum SessionCliError {
13    #[error("session not found: {0}")]
14    NotFound(String),
15    #[error("session directory error: {0}")]
16    Io(#[from] std::io::Error),
17    #[error("session file corrupt: {0}")]
18    Corrupt(String),
19}
20
21/// Summary of a session for display purposes.
22#[derive(Debug, Clone)]
23pub struct SessionInfo {
24    pub id: String,
25    pub timestamp: String,
26    pub cwd: String,
27    pub parent_session: Option<String>,
28}
29
30/// Result of a resume operation.
31pub struct ResumedSession {
32    pub header: opi_agent::session::SessionHeader,
33    pub entries: Vec<opi_agent::session::SessionEntry>,
34    /// Filesystem path of the resumed session JSONL file. Used by the harness
35    /// to open the file in append mode instead of creating a new session.
36    pub path: PathBuf,
37    /// Number of corrupt/unparseable entries skipped during load.
38    pub skipped_entries: usize,
39}
40
41/// Return the platform-specific session storage directory (S9.2).
42///
43/// Checks `OPI_SESSIONS_DIR` env var first (for testing), then falls back to
44/// the platform default.
45///
46/// Unix: `~/.local/share/opi/sessions/`
47/// Windows: `%LOCALAPPDATA%\opi\sessions\`
48pub fn session_dir() -> PathBuf {
49    if let Ok(dir) = std::env::var("OPI_SESSIONS_DIR") {
50        return PathBuf::from(dir);
51    }
52    if cfg!(windows) {
53        std::env::var("LOCALAPPDATA")
54            .map(|p| PathBuf::from(p).join("opi").join("sessions"))
55            .unwrap_or_else(|_| PathBuf::from(".opi").join("sessions"))
56    } else {
57        dirs_home()
58            .map(|h| h.join(".local").join("share").join("opi").join("sessions"))
59            .unwrap_or_else(|| PathBuf::from(".opi").join("sessions"))
60    }
61}
62
63fn dirs_home() -> Option<PathBuf> {
64    std::env::var("HOME").ok().map(PathBuf::from)
65}
66
67/// Validate that a session ID is safe to use as a filename component.
68/// Rejects empty strings, path separators, and `..` traversal.
69fn validate_session_id(id: &str) -> Result<(), SessionCliError> {
70    if id.is_empty() || id.contains('/') || id.contains('\\') || id.contains("..") {
71        return Err(SessionCliError::NotFound(id.into()));
72    }
73    Ok(())
74}
75
76/// List all sessions in the given directory.
77///
78/// Returns session metadata parsed from the first line (header) of each `.jsonl`
79/// file. Corrupt or unreadable files are silently skipped.
80pub fn list_sessions(dir: &Path) -> Result<Vec<SessionInfo>, SessionCliError> {
81    if !dir.exists() {
82        return Ok(vec![]);
83    }
84
85    let mut sessions = Vec::new();
86    let entries = std::fs::read_dir(dir)?;
87
88    for entry in entries {
89        let entry = entry?;
90        let path = entry.path();
91
92        if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
93            continue;
94        }
95
96        let content = match std::fs::read_to_string(&path) {
97            Ok(c) => c,
98            Err(_) => continue,
99        };
100
101        let first_line = match content.lines().next() {
102            Some(line) => line,
103            None => continue,
104        };
105
106        let header: opi_agent::session::SessionHeader = match serde_json::from_str(first_line) {
107            Ok(h) => h,
108            Err(_) => continue,
109        };
110
111        sessions.push(SessionInfo {
112            id: header.id,
113            timestamp: header.timestamp,
114            cwd: header.cwd,
115            parent_session: header.parent_session,
116        });
117    }
118
119    sessions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
120    Ok(sessions)
121}
122
123/// Resume a session by reading its header and entries.
124pub fn resume_session(dir: &Path, session_id: &str) -> Result<ResumedSession, SessionCliError> {
125    validate_session_id(session_id)?;
126    let path = dir.join(format!("{session_id}.jsonl"));
127    if !path.exists() {
128        return Err(SessionCliError::NotFound(session_id.into()));
129    }
130
131    let (header, entries, recovery) = opi_agent::session::SessionReader::read_with_recovery(&path)
132        .map_err(|e| SessionCliError::Corrupt(format!("{}: {e}", path.display())))?;
133
134    let skipped_entries = recovery.corrupt_count();
135
136    Ok(ResumedSession {
137        header,
138        entries,
139        path,
140        skipped_entries,
141    })
142}
143
144/// Delete a session file by ID.
145pub fn delete_session(dir: &Path, session_id: &str) -> Result<(), SessionCliError> {
146    validate_session_id(session_id)?;
147    let path = dir.join(format!("{session_id}.jsonl"));
148    if !path.exists() {
149        return Err(SessionCliError::NotFound(session_id.into()));
150    }
151    std::fs::remove_file(&path)?;
152    Ok(())
153}
154
155/// Format a list of session info for stdout display.
156pub fn format_sessions(sessions: &[SessionInfo]) -> String {
157    if sessions.is_empty() {
158        return String::new();
159    }
160
161    let mut lines = Vec::new();
162    for s in sessions {
163        let mut line = format!("{}  {}  {}", s.id, s.timestamp, s.cwd);
164        if let Some(parent) = &s.parent_session {
165            line.push_str(&format!("  (parent: {parent})"));
166        }
167        lines.push(line);
168    }
169    lines.join("\n")
170}
171
172/// Handle session CLI dispatch.
173///
174/// Returns `(handled, Some(ResumedSession))` for `--resume`,
175/// `(true, None)` for list/delete (caller should exit),
176/// `(false, None)` if no session command was given (normal execution continues).
177pub fn handle_session_cli(
178    list: bool,
179    resume: Option<&str>,
180    delete: Option<&str>,
181) -> Result<(bool, Option<ResumedSession>), i32> {
182    let dir = session_dir();
183
184    if list {
185        match list_sessions(&dir) {
186            Ok(sessions) => {
187                let output = format_sessions(&sessions);
188                if !output.is_empty() {
189                    println!("{output}");
190                }
191                Ok((true, None))
192            }
193            Err(e) => {
194                eprintln!("opi: {e}");
195                Err(1)
196            }
197        }
198    } else if let Some(id) = resume {
199        match resume_session(&dir, id) {
200            Ok(session) => {
201                // Print to stderr so it doesn't corrupt NDJSON stdout in --json mode.
202                eprintln!(
203                    "Resuming session {} ({} entries, cwd: {})",
204                    session.header.id,
205                    session.entries.len(),
206                    session.header.cwd,
207                );
208                if session.skipped_entries > 0 {
209                    eprintln!(
210                        "opi: warning: {} corrupt entry/entries skipped in session {}",
211                        session.skipped_entries, session.header.id,
212                    );
213                }
214                Ok((true, Some(session)))
215            }
216            Err(e) => {
217                eprintln!("opi: {e}");
218                Err(1)
219            }
220        }
221    } else if let Some(id) = delete {
222        match delete_session(&dir, id) {
223            Ok(()) => {
224                println!("Deleted session {id}");
225                Ok((true, None))
226            }
227            Err(e) => {
228                eprintln!("opi: {e}");
229                Err(1)
230            }
231        }
232    } else {
233        Ok((false, None))
234    }
235}
236
237/// Reconstruct agent messages from session entries for resume.
238///
239/// Two modes:
240///
241/// 1. **Active-branch mode (with `Leaf` entries).** The session file holds
242///    `leaf` pointer entries that record the active branch tip. When one or
243///    more `Leaf` entries are present, this function uses the last `Leaf`'s
244///    `entry_id` as the tip and walks the parent chain backward via
245///    `parent_id`, collecting only the entries on that branch. This is
246///    required for any session that contains branches — file-order replay
247///    would otherwise interleave messages from sibling branches into the
248///    reconstructed context.
249///
250/// 2. **Legacy linear mode (no `Leaf` entries).** Sessions written by the
251///    current runtime do not yet emit `Leaf` markers; for those the entire
252///    file is one linear branch and we replay every `Message`/`Compaction`
253///    entry in file order.
254///
255/// Compaction entries are honored in both modes by replaying their
256/// semantics: when a `Compaction` entry is encountered, every previously
257/// collected message that precedes the entry whose id equals
258/// `first_kept_entry_id` is dropped, the compaction summary is inserted in
259/// its place, and the kept tail (already persisted before the marker) is
260/// preserved. Messages written after the compaction marker are then
261/// appended as usual.
262///
263/// Defensive fallback: if a `Compaction` entry's `first_kept_entry_id` does
264/// not match any collected entry (corrupt or forward-incompatible session),
265/// the pre-summary buffer is dropped entirely so the summary still appears
266/// and post-compaction entries continue to accumulate.
267pub fn reconstruct_context(
268    entries: &[opi_agent::session::SessionEntry],
269) -> Vec<opi_agent::message::AgentMessage> {
270    let ordered = select_ordered_entries(entries);
271    apply_entries(&ordered)
272}
273
274/// Return session entries ordered by the active branch.
275///
276/// When the session contains `Leaf` pointer entries, the last Leaf's
277/// `entry_id` is used as the branch tip and the parent chain is walked
278/// backward to collect only the active-branch entries (root to tip).
279/// Without Leaves, all non-Leaf entries are returned in file order (legacy
280/// linear sessions). This is the shared ordering logic used by both
281/// `reconstruct_context` (Agent message buffer) and
282/// `SessionCoordinator::open_existing` (compaction buffer).
283pub(crate) fn select_ordered_entries(
284    entries: &[opi_agent::session::SessionEntry],
285) -> Vec<&opi_agent::session::SessionEntry> {
286    use opi_agent::session::SessionEntry;
287
288    let last_leaf_tip: Option<&str> = entries.iter().rev().find_map(|e| match e {
289        SessionEntry::Leaf(l) => Some(l.entry_id.as_str()),
290        _ => None,
291    });
292
293    match last_leaf_tip {
294        Some(tip) => walk_active_branch(entries, tip),
295        None => entries
296            .iter()
297            .filter(|e| !matches!(e, SessionEntry::Leaf(_)))
298            .collect(),
299    }
300}
301
302/// Walk the active branch backward from `tip_entry_id`, returning entries
303/// from root to tip (ancestors first). `Leaf` entries themselves are
304/// excluded from the result — they are pointers, not content.
305///
306/// If the tip id is not found, returns an empty vector; callers fall back
307/// to legacy behavior or treat the resume as empty depending on context.
308fn walk_active_branch<'a>(
309    entries: &'a [opi_agent::session::SessionEntry],
310    tip_entry_id: &str,
311) -> Vec<&'a opi_agent::session::SessionEntry> {
312    use std::collections::HashMap;
313
314    use opi_agent::session::SessionEntry;
315
316    let mut by_id: HashMap<&str, &SessionEntry> = HashMap::new();
317    for entry in entries {
318        let id = match entry {
319            SessionEntry::Message(m) => Some(m.id.as_str()),
320            SessionEntry::Compaction(c) => Some(c.id.as_str()),
321            // Leaf pointers are excluded from the chain; the tip references
322            // a Message/Compaction directly.
323            SessionEntry::Leaf(_) => None,
324            _ => None,
325        };
326        if let Some(id) = id {
327            by_id.insert(id, entry);
328        }
329    }
330
331    let mut chain: Vec<&SessionEntry> = Vec::new();
332    let mut cursor: Option<&str> = Some(tip_entry_id);
333    let mut visited: std::collections::HashSet<&str> = std::collections::HashSet::new();
334    while let Some(id) = cursor {
335        if !visited.insert(id) {
336            // Cycle in parent_id graph: stop walking to avoid an infinite
337            // loop on a corrupt file.
338            break;
339        }
340        let Some(entry) = by_id.get(id).copied() else {
341            break;
342        };
343        chain.push(entry);
344        cursor = match entry {
345            SessionEntry::Message(m) => m.parent_id.as_deref(),
346            SessionEntry::Compaction(c) => c.parent_id.as_deref(),
347            _ => None,
348        };
349    }
350    chain.reverse();
351    chain
352}
353
354/// Apply a sequence of message/compaction entries (in order) into the
355/// runtime `AgentMessage` buffer, honoring compaction summary semantics.
356fn apply_entries(
357    entries: &[&opi_agent::session::SessionEntry],
358) -> Vec<opi_agent::message::AgentMessage> {
359    use opi_agent::message::{AgentMessage, CompactionSummaryMessage};
360    use opi_agent::session::SessionEntry;
361
362    let mut messages: Vec<AgentMessage> = Vec::new();
363    let mut entry_ids: Vec<Option<String>> = Vec::new();
364
365    for entry in entries {
366        match entry {
367            SessionEntry::Message(m) => {
368                messages.push(AgentMessage::Llm(m.message.clone()));
369                entry_ids.push(Some(m.id.clone()));
370            }
371            SessionEntry::Compaction(c) => {
372                let kept_start = entry_ids
373                    .iter()
374                    .position(|id| id.as_deref() == Some(c.first_kept_entry_id.as_str()));
375
376                let (kept_messages, kept_ids): (Vec<_>, Vec<_>) = match kept_start {
377                    Some(idx) => (messages.split_off(idx), entry_ids.split_off(idx)),
378                    None => (Vec::new(), Vec::new()),
379                };
380
381                messages.clear();
382                entry_ids.clear();
383                messages.push(AgentMessage::CompactionSummary(CompactionSummaryMessage {
384                    summary: c.summary.clone(),
385                    first_kept_entry_id: c.first_kept_entry_id.clone(),
386                    tokens_before: c.tokens_before,
387                    tokens_after: c.tokens_after,
388                }));
389                entry_ids.push(None);
390                messages.extend(kept_messages);
391                entry_ids.extend(kept_ids);
392            }
393            SessionEntry::Leaf(_) => {}
394            _ => {}
395        }
396    }
397
398    messages
399}