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    Ok(sessions)
120}
121
122/// Resume a session by reading its header and entries.
123pub fn resume_session(dir: &Path, session_id: &str) -> Result<ResumedSession, SessionCliError> {
124    validate_session_id(session_id)?;
125    let path = dir.join(format!("{session_id}.jsonl"));
126    if !path.exists() {
127        return Err(SessionCliError::NotFound(session_id.into()));
128    }
129
130    let (header, entries, recovery) = opi_agent::session::SessionReader::read_with_recovery(&path)
131        .map_err(|e| SessionCliError::Corrupt(format!("{}: {e}", path.display())))?;
132
133    let skipped_entries = recovery.corrupt_count();
134
135    Ok(ResumedSession {
136        header,
137        entries,
138        path,
139        skipped_entries,
140    })
141}
142
143/// Delete a session file by ID.
144pub fn delete_session(dir: &Path, session_id: &str) -> Result<(), SessionCliError> {
145    validate_session_id(session_id)?;
146    let path = dir.join(format!("{session_id}.jsonl"));
147    if !path.exists() {
148        return Err(SessionCliError::NotFound(session_id.into()));
149    }
150    std::fs::remove_file(&path)?;
151    Ok(())
152}
153
154/// Format a list of session info for stdout display.
155pub fn format_sessions(sessions: &[SessionInfo]) -> String {
156    if sessions.is_empty() {
157        return String::new();
158    }
159
160    let mut lines = Vec::new();
161    for s in sessions {
162        let mut line = format!("{}  {}  {}", s.id, s.timestamp, s.cwd);
163        if let Some(parent) = &s.parent_session {
164            line.push_str(&format!("  (parent: {parent})"));
165        }
166        lines.push(line);
167    }
168    lines.join("\n")
169}
170
171/// Handle session CLI dispatch.
172///
173/// Returns `(handled, Some(ResumedSession))` for `--resume`,
174/// `(true, None)` for list/delete (caller should exit),
175/// `(false, None)` if no session command was given (normal execution continues).
176pub fn handle_session_cli(
177    list: bool,
178    resume: Option<&str>,
179    delete: Option<&str>,
180) -> Result<(bool, Option<ResumedSession>), i32> {
181    let dir = session_dir();
182
183    if list {
184        match list_sessions(&dir) {
185            Ok(sessions) => {
186                let output = format_sessions(&sessions);
187                if !output.is_empty() {
188                    println!("{output}");
189                }
190                Ok((true, None))
191            }
192            Err(e) => {
193                eprintln!("opi: {e}");
194                Err(1)
195            }
196        }
197    } else if let Some(id) = resume {
198        match resume_session(&dir, id) {
199            Ok(session) => {
200                // Print to stderr so it doesn't corrupt NDJSON stdout in --json mode.
201                eprintln!(
202                    "Resuming session {} ({} entries, cwd: {})",
203                    session.header.id,
204                    session.entries.len(),
205                    session.header.cwd,
206                );
207                if session.skipped_entries > 0 {
208                    eprintln!(
209                        "opi: warning: {} corrupt entry/entries skipped in session {}",
210                        session.skipped_entries, session.header.id,
211                    );
212                }
213                Ok((true, Some(session)))
214            }
215            Err(e) => {
216                eprintln!("opi: {e}");
217                Err(1)
218            }
219        }
220    } else if let Some(id) = delete {
221        match delete_session(&dir, id) {
222            Ok(()) => {
223                println!("Deleted session {id}");
224                Ok((true, None))
225            }
226            Err(e) => {
227                eprintln!("opi: {e}");
228                Err(1)
229            }
230        }
231    } else {
232        Ok((false, None))
233    }
234}
235
236/// Reconstruct agent messages from session entries for resume.
237///
238/// Two modes:
239///
240/// 1. **Active-branch mode (with `Leaf` entries).** The session file holds
241///    `leaf` pointer entries that record the active branch tip. When one or
242///    more `Leaf` entries are present, this function uses the last `Leaf`'s
243///    `entry_id` as the tip and walks the parent chain backward via
244///    `parent_id`, collecting only the entries on that branch. This is
245///    required for any session that contains branches — file-order replay
246///    would otherwise interleave messages from sibling branches into the
247///    reconstructed context.
248///
249/// 2. **Legacy linear mode (no `Leaf` entries).** Sessions written by the
250///    current runtime do not yet emit `Leaf` markers; for those the entire
251///    file is one linear branch and we replay every `Message`/`Compaction`
252///    entry in file order.
253///
254/// Compaction entries are honored in both modes by replaying their
255/// semantics: when a `Compaction` entry is encountered, every previously
256/// collected message that precedes the entry whose id equals
257/// `first_kept_entry_id` is dropped, the compaction summary is inserted in
258/// its place, and the kept tail (already persisted before the marker) is
259/// preserved. Messages written after the compaction marker are then
260/// appended as usual.
261///
262/// Defensive fallback: if a `Compaction` entry's `first_kept_entry_id` does
263/// not match any collected entry (corrupt or forward-incompatible session),
264/// the pre-summary buffer is dropped entirely so the summary still appears
265/// and post-compaction entries continue to accumulate.
266pub fn reconstruct_context(
267    entries: &[opi_agent::session::SessionEntry],
268) -> Vec<opi_agent::message::AgentMessage> {
269    let ordered = select_ordered_entries(entries);
270    apply_entries(&ordered)
271}
272
273/// Return session entries ordered by the active branch.
274///
275/// When the session contains `Leaf` pointer entries, the last Leaf's
276/// `entry_id` is used as the branch tip and the parent chain is walked
277/// backward to collect only the active-branch entries (root to tip).
278/// Without Leaves, all non-Leaf entries are returned in file order (legacy
279/// linear sessions). This is the shared ordering logic used by both
280/// `reconstruct_context` (Agent message buffer) and
281/// `SessionCoordinator::open_existing` (compaction buffer).
282pub(crate) fn select_ordered_entries(
283    entries: &[opi_agent::session::SessionEntry],
284) -> Vec<&opi_agent::session::SessionEntry> {
285    use opi_agent::session::SessionEntry;
286
287    let last_leaf_tip: Option<&str> = entries.iter().rev().find_map(|e| match e {
288        SessionEntry::Leaf(l) => Some(l.entry_id.as_str()),
289        _ => None,
290    });
291
292    match last_leaf_tip {
293        Some(tip) => walk_active_branch(entries, tip),
294        None => entries
295            .iter()
296            .filter(|e| !matches!(e, SessionEntry::Leaf(_)))
297            .collect(),
298    }
299}
300
301/// Walk the active branch backward from `tip_entry_id`, returning entries
302/// from root to tip (ancestors first). `Leaf` entries themselves are
303/// excluded from the result — they are pointers, not content.
304///
305/// If the tip id is not found, returns an empty vector; callers fall back
306/// to legacy behavior or treat the resume as empty depending on context.
307fn walk_active_branch<'a>(
308    entries: &'a [opi_agent::session::SessionEntry],
309    tip_entry_id: &str,
310) -> Vec<&'a opi_agent::session::SessionEntry> {
311    use std::collections::HashMap;
312
313    use opi_agent::session::SessionEntry;
314
315    let mut by_id: HashMap<&str, &SessionEntry> = HashMap::new();
316    for entry in entries {
317        let id = match entry {
318            SessionEntry::Message(m) => Some(m.id.as_str()),
319            SessionEntry::Compaction(c) => Some(c.id.as_str()),
320            // Leaf pointers are excluded from the chain; the tip references
321            // a Message/Compaction directly.
322            SessionEntry::Leaf(_) => None,
323            _ => None,
324        };
325        if let Some(id) = id {
326            by_id.insert(id, entry);
327        }
328    }
329
330    let mut chain: Vec<&SessionEntry> = Vec::new();
331    let mut cursor: Option<&str> = Some(tip_entry_id);
332    let mut visited: std::collections::HashSet<&str> = std::collections::HashSet::new();
333    while let Some(id) = cursor {
334        if !visited.insert(id) {
335            // Cycle in parent_id graph: stop walking to avoid an infinite
336            // loop on a corrupt file.
337            break;
338        }
339        let Some(entry) = by_id.get(id).copied() else {
340            break;
341        };
342        chain.push(entry);
343        cursor = match entry {
344            SessionEntry::Message(m) => m.parent_id.as_deref(),
345            SessionEntry::Compaction(c) => c.parent_id.as_deref(),
346            _ => None,
347        };
348    }
349    chain.reverse();
350    chain
351}
352
353/// Apply a sequence of message/compaction entries (in order) into the
354/// runtime `AgentMessage` buffer, honoring compaction summary semantics.
355fn apply_entries(
356    entries: &[&opi_agent::session::SessionEntry],
357) -> Vec<opi_agent::message::AgentMessage> {
358    use opi_agent::message::{AgentMessage, CompactionSummaryMessage};
359    use opi_agent::session::SessionEntry;
360
361    let mut messages: Vec<AgentMessage> = Vec::new();
362    let mut entry_ids: Vec<Option<String>> = Vec::new();
363
364    for entry in entries {
365        match entry {
366            SessionEntry::Message(m) => {
367                messages.push(AgentMessage::Llm(m.message.clone()));
368                entry_ids.push(Some(m.id.clone()));
369            }
370            SessionEntry::Compaction(c) => {
371                let kept_start = entry_ids
372                    .iter()
373                    .position(|id| id.as_deref() == Some(c.first_kept_entry_id.as_str()));
374
375                let (kept_messages, kept_ids): (Vec<_>, Vec<_>) = match kept_start {
376                    Some(idx) => (messages.split_off(idx), entry_ids.split_off(idx)),
377                    None => (Vec::new(), Vec::new()),
378                };
379
380                messages.clear();
381                entry_ids.clear();
382                messages.push(AgentMessage::CompactionSummary(CompactionSummaryMessage {
383                    summary: c.summary.clone(),
384                    first_kept_entry_id: c.first_kept_entry_id.clone(),
385                    tokens_before: c.tokens_before,
386                    tokens_after: c.tokens_after,
387                }));
388                entry_ids.push(None);
389                messages.extend(kept_messages);
390                entry_ids.extend(kept_ids);
391            }
392            SessionEntry::Leaf(_) => {}
393            _ => {}
394        }
395    }
396
397    messages
398}