capo-agent 0.6.0

Coding-agent library built on motosan-agent-loop. Composable, embeddable.
Documentation
#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]

//! `--continue` and `--session <prefix-or-path>` resolution.

use std::path::PathBuf;

use motosan_agent_loop::{FileSessionStore, SessionStore};

use crate::error::{AppError, Result};
use crate::session::{paths::SessionPaths, SessionId};

/// Stateless namespace for session resolution.
pub struct SessionLookup;

impl SessionLookup {
    /// `--continue / -c` — most recent session in `paths.bucket_dir`,
    /// ranked by `motosan SessionMeta::updated_at_ms` descending.
    ///
    /// Errors if the bucket is empty or doesn't exist.
    pub async fn most_recent(paths: &SessionPaths) -> Result<SessionId> {
        if !paths.bucket_dir.exists() {
            return Err(AppError::Config(format!(
                "no sessions yet for this directory (looked in {})",
                paths.bucket_dir.display()
            )));
        }
        let store = FileSessionStore::new(paths.bucket_dir.clone());
        let metas = store
            .list_meta()
            .await
            .map_err(|err| AppError::Config(format!("list_meta failed: {err}")))?;
        // `FileSessionStore::list_meta` already sorts by updated_at_ms DESC.
        let first = metas
            .into_iter()
            .next()
            .ok_or_else(|| AppError::Config("no sessions found".into()))?;
        Ok(SessionId::from_string(first.session_id))
    }

    /// `--session <prefix-or-path>` — disambiguate:
    /// - Starts with `/`, `~`, or `.` → treat as a path to a `.jsonl` file.
    /// - Else → ULID prefix. Looks in `paths.bucket_dir` first; if no
    ///   match there, scans all sibling buckets under `paths.sessions_root()`.
    ///   Errors on 0 or >1 matches.
    pub async fn resolve(paths: &SessionPaths, prefix_or_path: &str) -> Result<SessionId> {
        if prefix_or_path.starts_with('/')
            || prefix_or_path.starts_with('~')
            || prefix_or_path.starts_with('.')
        {
            return resolve_path(prefix_or_path);
        }
        resolve_prefix(paths, prefix_or_path).await
    }
}

fn resolve_path(input: &str) -> Result<SessionId> {
    let expanded = expand_tilde(input);
    if !expanded.exists() {
        return Err(AppError::Config(format!(
            "session file not found: {}",
            expanded.display()
        )));
    }
    let stem = expanded
        .file_stem()
        .and_then(|s| s.to_str())
        .ok_or_else(|| {
            AppError::Config(format!("session path has no stem: {}", expanded.display()))
        })?;
    Ok(SessionId::from_string(stem.to_string()))
}

fn expand_tilde(input: &str) -> PathBuf {
    if let Some(rest) = input.strip_prefix("~/") {
        if let Ok(home) = std::env::var("HOME") {
            return PathBuf::from(home).join(rest);
        }
    }
    PathBuf::from(input)
}

async fn resolve_prefix(paths: &SessionPaths, prefix: &str) -> Result<SessionId> {
    // First pass: current bucket only.
    if paths.bucket_dir.exists() {
        let store = FileSessionStore::new(paths.bucket_dir.clone());
        let ids = store
            .list()
            .await
            .map_err(|err| AppError::Config(format!("list failed: {err}")))?;
        let matches: Vec<String> = ids
            .into_iter()
            .filter(|id| id.starts_with(prefix))
            .collect();
        match matches.len() {
            0 => {} // fall through to all-buckets scan
            1 => {
                let only = matches
                    .into_iter()
                    .next()
                    .ok_or_else(|| AppError::Config("internal: vec drained".into()))?;
                return Ok(SessionId::from_string(only));
            }
            n => {
                return Err(AppError::Config(format!(
                    "session prefix {prefix:?} matches {n} sessions in this directory"
                )));
            }
        }
    }

    // Second pass: scan every bucket under sessions/.
    let sessions_root = paths.sessions_root();
    if !sessions_root.exists() {
        return Err(AppError::Config(format!(
            "session prefix {prefix:?}: no sessions exist (root {} missing)",
            sessions_root.display()
        )));
    }

    let mut all_matches: Vec<String> = Vec::new();
    let iter = std::fs::read_dir(&sessions_root).map_err(|err| {
        AppError::Config(format!(
            "failed to read sessions root {}: {err}",
            sessions_root.display()
        ))
    })?;
    for entry in iter {
        let entry = entry.map_err(|err| AppError::Config(format!("readdir error: {err}")))?;
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        let store = FileSessionStore::new(path);
        let ids = store
            .list()
            .await
            .map_err(|err| AppError::Config(format!("list failed: {err}")))?;
        for id in ids {
            if id.starts_with(prefix) {
                all_matches.push(id);
            }
        }
    }

    match all_matches.len() {
        0 => Err(AppError::Config(format!(
            "session prefix {prefix:?} matches no sessions"
        ))),
        1 => {
            let only = all_matches
                .into_iter()
                .next()
                .ok_or_else(|| AppError::Config("internal: vec drained".into()))?;
            Ok(SessionId::from_string(only))
        }
        n => Err(AppError::Config(format!(
            "session prefix {prefix:?} matches {n} sessions across all buckets"
        ))),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use motosan_agent_loop::{Message, SessionEntry};
    use tempfile::tempdir;

    async fn seed_session(paths: &SessionPaths, id: &str) {
        paths.ensure_bucket().unwrap();
        let store = FileSessionStore::new(paths.bucket_dir.clone());
        store
            .append_entry(id, &SessionEntry::message(Message::user("hi")))
            .await
            .unwrap();
        store.flush(id).await.unwrap();
    }

    #[tokio::test]
    async fn most_recent_returns_latest_by_updated_at() {
        let tmp = tempdir().unwrap();
        let agent_dir = tmp.path().to_path_buf();
        let cwd = PathBuf::from("/tmp/x");
        let paths = SessionPaths::for_cwd(agent_dir, &cwd);

        seed_session(&paths, "01OLD000000000000000000000").await;
        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
        seed_session(&paths, "01NEW000000000000000000000").await;

        let most_recent = SessionLookup::most_recent(&paths).await.unwrap();
        assert_eq!(most_recent.as_str(), "01NEW000000000000000000000");
    }

    #[tokio::test]
    async fn most_recent_errors_on_empty_bucket() {
        let tmp = tempdir().unwrap();
        let paths = SessionPaths::for_cwd(tmp.path().to_path_buf(), &PathBuf::from("/x"));
        let err = SessionLookup::most_recent(&paths).await.unwrap_err();
        assert!(format!("{err}").contains("no sessions"));
    }

    #[tokio::test]
    async fn resolve_prefix_unique_match_in_bucket_succeeds() {
        let tmp = tempdir().unwrap();
        let paths = SessionPaths::for_cwd(tmp.path().to_path_buf(), &PathBuf::from("/x"));
        seed_session(&paths, "01ABCDEFGHIJKLMNOPQRSTUVWX").await;

        let id = SessionLookup::resolve(&paths, "01ABC").await.unwrap();
        assert_eq!(id.as_str(), "01ABCDEFGHIJKLMNOPQRSTUVWX");
    }

    #[tokio::test]
    async fn resolve_prefix_ambiguous_match_errors() {
        let tmp = tempdir().unwrap();
        let paths = SessionPaths::for_cwd(tmp.path().to_path_buf(), &PathBuf::from("/x"));
        seed_session(&paths, "01ABCD0000000000000000000A").await;
        seed_session(&paths, "01ABCD0000000000000000000B").await;

        let err = SessionLookup::resolve(&paths, "01ABCD").await.unwrap_err();
        assert!(format!("{err}").contains("matches 2 sessions"));
    }

    #[tokio::test]
    async fn resolve_path_starting_with_slash_reads_from_filesystem() {
        let tmp = tempdir().unwrap();
        let agent_dir = tmp.path().to_path_buf();
        let paths = SessionPaths::for_cwd(agent_dir, &PathBuf::from("/x"));
        seed_session(&paths, "01XYZWVUTSRQPONMLKJIHGFEDC").await;

        let file = paths.bucket_dir.join("01XYZWVUTSRQPONMLKJIHGFEDC.jsonl");
        assert!(file.exists());
        let id = SessionLookup::resolve(&paths, file.to_str().unwrap())
            .await
            .unwrap();
        assert_eq!(id.as_str(), "01XYZWVUTSRQPONMLKJIHGFEDC");
    }
}