sac-cli 0.1.0

Terminal-based AI coding agent — fork of NAC with extended backend support and context management
Documentation
use std::path::{Path, PathBuf};

use anyhow::{anyhow, Context, Result};
use rusqlite::{params, OptionalExtension};
use serde::{Deserialize, Serialize};

use crate::model::{detect_backend, BackendKind, ReasoningEffort};
use crate::sandbox::SandboxSpec;
use crate::types::Message;

mod codec;
mod db;
mod snapshot;
mod summary;

pub use db::{
    archive_messages, create_session, list_sessions, load_last_session, load_session,
    messages_json_byte_size, save_session,
};
pub use snapshot::{new_snapshot, refresh_snapshot};

use codec::*;
use summary::*;

#[derive(Debug, Clone)]
pub struct SessionSnapshot {
    pub session_id: String,
    pub cwd: PathBuf,
    pub store_path: PathBuf,
    pub model: String,
    pub base_url: String,
    pub backend: BackendKind,
    pub reasoning_effort: Option<ReasoningEffort>,
    pub sandbox_spec: Option<SandboxSpec>,
    pub messages: Vec<Message>,
    pub last_response_duration_ms: Option<u64>,
    pub previous_response_duration_ms: Option<u64>,
    pub response_durations_ms: Option<Vec<Option<u64>>>,
    pub timeline_json: Option<String>,
    pub created_at: String,
    pub updated_at: String,
}

#[derive(Debug, Clone)]
pub struct SessionSummary {
    pub session_id: String,
    pub cwd: PathBuf,
    pub model: String,
    pub backend: BackendKind,
    pub visible_message_count: usize,
    pub last_user_prompt: Option<String>,
    pub sandboxed: bool,
    pub created_at: String,
    pub updated_at: String,
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_env_lock;
    use crate::types::Message;

    fn temp_store_path(label: &str) -> PathBuf {
        let unique = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .expect("time went backwards")
            .as_nanos();
        std::env::temp_dir()
            .join(format!("sac_sessions_test_{}_{}", label, unique))
            .join("store.db")
    }

    #[test]
    fn create_and_load_session_round_trip() {
        let _guard = test_env_lock();
        let store_path = temp_store_path("round_trip");

        let mut snapshot = new_snapshot(
            "session-1".to_string(),
            PathBuf::from("/repo"),
            store_path.clone(),
            "model-a".to_string(),
            "https://api.openai.com/v1".to_string(),
            BackendKind::OpenAiResponses,
            Some(ReasoningEffort::Xhigh),
            None,
            vec![Message::User {
                content: "hello".to_string(),
            }],
        );
        snapshot.last_response_duration_ms = Some(12_345);
        snapshot.previous_response_duration_ms = Some(6_789);
        snapshot.response_durations_ms = Some(vec![Some(1_000), None, Some(12_345)]);
        create_session(&snapshot).unwrap();
        let loaded = load_session(&store_path, "session-1").unwrap();
        assert_eq!(loaded.session_id, "session-1");
        assert_eq!(loaded.cwd, PathBuf::from("/repo"));
        assert_eq!(loaded.messages.len(), 1);
        assert_eq!(loaded.last_response_duration_ms, Some(12_345));
        assert_eq!(loaded.previous_response_duration_ms, Some(6_789));
        assert_eq!(
            loaded.response_durations_ms,
            Some(vec![Some(1_000), None, Some(12_345)])
        );

        let _ = std::fs::remove_dir_all(store_path.parent().unwrap());
    }

    #[test]
    fn load_session_migrates_legacy_schema_without_duration_history() {
        let _guard = test_env_lock();
        let store_path = temp_store_path("legacy_duration_schema");
        std::fs::create_dir_all(store_path.parent().unwrap()).unwrap();
        let messages_json = serde_json::to_string(&vec![Message::User {
            content: "hello".to_string(),
        }])
        .unwrap();

        {
            let conn = rusqlite::Connection::open(&store_path).unwrap();
            conn.execute_batch(
                "CREATE TABLE sessions (
                    session_id TEXT PRIMARY KEY,
                    cwd TEXT NOT NULL,
                    store_path TEXT NOT NULL,
                    model TEXT NOT NULL,
                    base_url TEXT NOT NULL,
                    backend TEXT,
                    reasoning_effort TEXT,
                    sandbox_json TEXT,
                    messages_json TEXT NOT NULL,
                    last_response_duration_ms INTEGER,
                    previous_response_duration_ms INTEGER,
                    created_at TEXT NOT NULL,
                    updated_at TEXT NOT NULL
                );",
            )
            .unwrap();
            conn.execute(
                "INSERT INTO sessions (
                    session_id, cwd, store_path, model, base_url, backend, reasoning_effort,
                    sandbox_json, messages_json, last_response_duration_ms,
                    previous_response_duration_ms, created_at, updated_at
                ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
                rusqlite::params![
                    "legacy-session",
                    "/repo",
                    store_path.display().to_string(),
                    "model-a",
                    "https://api.openai.com/v1",
                    "openai-responses",
                    "xhigh",
                    Option::<String>::None,
                    messages_json,
                    12_345_u64,
                    6_789_u64,
                    "2026-01-01 00:00:00.000000000",
                    "2026-01-01 00:00:01.000000000",
                ],
            )
            .unwrap();
        }

        let loaded = load_session(&store_path, "legacy-session").unwrap();
        assert_eq!(loaded.session_id, "legacy-session");
        assert_eq!(loaded.last_response_duration_ms, Some(12_345));
        assert_eq!(loaded.previous_response_duration_ms, Some(6_789));
        assert_eq!(loaded.response_durations_ms, None);

        let _ = std::fs::remove_dir_all(store_path.parent().unwrap());
    }

    #[test]
    fn load_last_session_returns_most_recent() {
        let _guard = test_env_lock();
        let store_path = temp_store_path("latest");

        let first = new_snapshot(
            "session-1".to_string(),
            PathBuf::from("/repo-one"),
            store_path.clone(),
            "model-a".to_string(),
            "https://api.openai.com/v1".to_string(),
            BackendKind::OpenAiResponses,
            Some(ReasoningEffort::Xhigh),
            None,
            Vec::new(),
        );
        create_session(&first).unwrap();

        let second = new_snapshot(
            "session-2".to_string(),
            PathBuf::from("/repo-two"),
            store_path.clone(),
            "model-b".to_string(),
            "https://api.fireworks.ai/inference/v1".to_string(),
            BackendKind::FireworksChat,
            None,
            None,
            vec![Message::User {
                content: "latest".to_string(),
            }],
        );
        save_session(&second).unwrap();

        let loaded = load_last_session(&store_path).unwrap();
        assert_eq!(loaded.session_id, "session-2");

        let _ = std::fs::remove_dir_all(store_path.parent().unwrap());
    }

    #[test]
    fn list_sessions_returns_summaries_in_updated_order() {
        let _guard = test_env_lock();
        let store_path = temp_store_path("list");

        let first = new_snapshot(
            "session-1".to_string(),
            PathBuf::from("/repo-one"),
            store_path.clone(),
            "model-a".to_string(),
            "https://api.openai.com/v1".to_string(),
            BackendKind::OpenAiResponses,
            None,
            None,
            vec![
                Message::System {
                    content: "system".to_string(),
                },
                Message::User {
                    content: "first prompt".to_string(),
                },
            ],
        );
        create_session(&first).unwrap();

        let second = new_snapshot(
            "session-2".to_string(),
            PathBuf::from("/repo-two"),
            store_path.clone(),
            "model-b".to_string(),
            "https://api.fireworks.ai/inference/v1".to_string(),
            BackendKind::FireworksChat,
            None,
            Some(SandboxSpec {
                image: "python:3.13".to_string(),
                workdir: PathBuf::from("/workspace"),
                mounts: Vec::new(),
                gpu_devices: Vec::new(),
                shm_size: Some("0".to_string()),
            }),
            vec![
                Message::System {
                    content: "system".to_string(),
                },
                Message::User {
                    content: "latest prompt".to_string(),
                },
                Message::Assistant {
                    content: Some("reply".to_string()),
                    reasoning_text: None,
                    reasoning_details: None,
                    tool_calls: None,
                },
            ],
        );
        save_session(&second).unwrap();

        let sessions = list_sessions(&store_path).unwrap();
        assert_eq!(sessions.len(), 2);
        assert_eq!(sessions[0].session_id, "session-2");
        assert_eq!(sessions[0].visible_message_count, 2);
        assert_eq!(
            sessions[0].last_user_prompt.as_deref(),
            Some("latest prompt")
        );
        assert!(sessions[0].sandboxed);

        let _ = std::fs::remove_dir_all(store_path.parent().unwrap());
    }
}