aether-sessions 0.1.0

Persisted session logs and transcript reconstruction for Aether
Documentation
use acp_utils::notifications::SessionPreviewRole;
use aether_core::events::{AgentEvent, MessageEvent, ToolEvent};
use aether_sessions::{ScanLimits, SessionEvent, SessionMeta, SessionStore, SessionStoreError, UserEvent};
use llm::ContentBlock;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};

type TestResult = Result<(), Box<dyn std::error::Error>>;

fn meta(id: &str, created_at: &str) -> SessionMeta {
    SessionMeta {
        session_id: id.to_string(),
        cwd: PathBuf::from("/tmp/project"),
        model: "test-model".to_string(),
        selected_mode: Some("planner".to_string()),
        created_at: created_at.to_string(),
    }
}

fn user_message(text: &str) -> SessionEvent {
    SessionEvent::User(UserEvent::Message { content: vec![ContentBlock::text(text)] })
}

fn assistant_message(text: &str) -> SessionEvent {
    SessionEvent::Agent(AgentEvent::Message(MessageEvent::Text {
        message_id: "message-1".to_string(),
        chunk: text.to_string(),
        is_complete: true,
    }))
}

fn temp_store() -> (tempfile::TempDir, SessionStore) {
    let directory = tempfile::tempdir().expect("temporary session directory");
    let store = SessionStore::from_path(directory.path().to_path_buf());
    (directory, store)
}

#[test]
fn append_and_load_roundtrip_preserves_metadata_and_persisted_events() -> TestResult {
    let (_directory, store) = temp_store();
    let session_meta = meta("session-1", "2026-01-01T00:00:00Z");
    let user = user_message("Hello");
    let assistant = assistant_message("Hi there");
    let transient = SessionEvent::Agent(AgentEvent::Message(MessageEvent::Text {
        message_id: "message-1".to_string(),
        chunk: "partial".to_string(),
        is_complete: false,
    }));

    store.append_meta("session-1", &session_meta)?;
    store.append_event("session-1", &user)?;
    store.append_event("session-1", &transient)?;
    store.append_event("session-1", &assistant)?;

    let (loaded_meta, events) = store.load("session-1").expect("session exists");
    assert_eq!(loaded_meta, session_meta);
    assert_eq!(events, vec![user, assistant]);
    Ok(())
}

#[test]
fn load_ignores_malformed_trailing_event_lines() -> TestResult {
    let (directory, store) = temp_store();
    let session_meta = meta("session-1", "2026-01-01T00:00:00Z");
    let mut file = File::create(directory.path().join("session-1.jsonl"))?;
    writeln!(file, "{}", serde_json::to_string(&session_meta)?)?;
    writeln!(file, "{}", serde_json::to_string(&user_message("valid"))?)?;
    writeln!(file, "{{partial json")?;

    let (_, events) = store.load("session-1").expect("metadata is valid");
    assert_eq!(events, vec![user_message("valid")]);
    Ok(())
}

#[test]
fn list_sorts_sessions_and_extracts_first_user_title() -> TestResult {
    let (_directory, store) = temp_store();
    store.append_meta("old", &meta("old", "2026-01-01T00:00:00Z"))?;
    store.append_event("old", &user_message("old title"))?;
    store.append_meta("new", &meta("new", "2026-02-01T00:00:00Z"))?;
    store.append_event(
        "new",
        &SessionEvent::Control(aether_sessions::SessionControlEvent::AgentSwitched {
            from: None,
            to: Some("coder".to_string()),
        }),
    )?;
    store.append_event("new", &user_message("new title\nsecond line"))?;

    let sessions = store.list();
    assert_eq!(sessions.iter().map(|session| session.meta.session_id.as_str()).collect::<Vec<_>>(), ["new", "old"]);
    assert_eq!(sessions[0].title.as_deref(), Some("new title"));
    Ok(())
}

#[test]
fn list_skips_non_session_jsonl_files_and_malformed_metadata() -> TestResult {
    let (directory, store) = temp_store();
    store.append_meta("valid", &meta("valid", "2026-01-01T00:00:00Z"))?;
    std::fs::write(directory.path().join("prompt-history.jsonl"), "not a session")?;
    std::fs::write(directory.path().join("malformed.jsonl"), "not metadata\n")?;
    std::fs::write(directory.path().join("notes.txt"), "ignored")?;

    let sessions = store.list();
    assert_eq!(sessions.len(), 1);
    assert_eq!(sessions[0].meta.session_id, "valid");
    Ok(())
}

#[test]
fn prompt_search_is_smart_case_unicode_safe_and_retains_recent_entries() -> TestResult {
    let (_directory, store) = temp_store();
    store.append_meta("session-1", &meta("session-1", "2026-01-01T00:00:00Z"))?;
    for index in 0..105 {
        store.append_event("session-1", &user_message(&format!("prompt {index}")))?;
    }
    store.append_event("session-1", &user_message("HELLO world"))?;
    store.append_event("session-1", &user_message("café"))?;

    let old = store.search_prompts("prompt 0", None)?;
    assert!(old.results.is_empty());

    let lower = store.search_prompts("hello", None)?;
    assert_eq!(lower.results.len(), 1);
    let upper = store.search_prompts("Hello", None)?;
    assert!(upper.results.is_empty());
    let unicode = store.search_prompts("", None)?;
    let hit = &unicode.results[0];
    assert_eq!(&hit.prompt[hit.match_start..hit.match_end], "");
    Ok(())
}

#[test]
fn relocating_updates_metadata_and_derived_prompt_entries() -> TestResult {
    let (_directory, store) = temp_store();
    store.append_meta("session-1", &meta("session-1", "2026-01-01T00:00:00Z"))?;
    store.append_event("session-1", &user_message("move me"))?;

    store.relocate("session-1", Path::new("/tmp/new-project"))?;

    assert_eq!(store.session_cwd("session-1"), Some(PathBuf::from("/tmp/new-project")));
    let response = store.search_prompts("move me", None)?;
    assert_eq!(response.results[0].cwd, PathBuf::from("/tmp/new-project"));
    Ok(())
}

#[test]
fn preview_returns_metadata_media_and_tool_counts() -> TestResult {
    let (_directory, store) = temp_store();
    let session_meta = meta("session-1", "2026-01-01T00:00:00Z");
    store.append_meta("session-1", &session_meta)?;
    store.append_event("session-1", &user_message("preview me"))?;
    store.append_event("session-1", &assistant_message("assistant reply"))?;
    store.append_event(
        "session-1",
        &SessionEvent::Agent(AgentEvent::Tool(ToolEvent::Call {
            request: llm::ToolCallRequest {
                id: "tool-1".to_string(),
                name: "read".to_string(),
                arguments: "{}".to_string(),
            },
        })),
    )?;

    let preview = store.preview("session-1")?;
    assert_eq!(preview.session_id, "session-1");
    assert_eq!(preview.tool_call_count, 1);
    assert_eq!(preview.transcript[0].role, SessionPreviewRole::User);
    assert_eq!(preview.transcript[0].text, "preview me");
    Ok(())
}

#[test]
fn preview_unknown_session_returns_not_found_store_error() {
    let (_directory, store) = temp_store();
    let error = store.preview("missing").unwrap_err();
    assert!(matches!(error, SessionStoreError::Io(error) if error.kind() == std::io::ErrorKind::NotFound));
}

#[test]
fn prompt_search_applies_result_limit_and_reports_truncation() -> TestResult {
    let (_directory, store) = temp_store();
    store.append_meta("session-1", &meta("session-1", "2026-01-01T00:00:00Z"))?;
    for index in 0..5 {
        store.append_event("session-1", &user_message(&format!("matching prompt {index}")))?;
    }

    let response = store.search_prompts("matching", Some(2))?;
    assert_eq!(response.results.len(), 2);
    assert!(response.truncated);
    Ok(())
}

#[test]
fn preview_marks_transcript_and_scan_limits_as_truncated() -> TestResult {
    let (_directory, store) = temp_store();
    store.append_meta("session-1", &meta("session-1", "2026-01-01T00:00:00Z"))?;
    for index in 0..205 {
        store.append_event("session-1", &user_message(&format!("prompt {index}")))?;
    }

    let preview = store.preview("session-1")?;
    assert_eq!(preview.transcript.len(), 8);
    assert!(preview.truncated);
    Ok(())
}

#[test]
fn list_uses_media_prompt_and_truncates_long_titles() -> TestResult {
    let (_directory, store) = temp_store();
    store.append_meta("session-1", &meta("session-1", "2026-01-01T00:00:00Z"))?;
    store.append_event(
        "session-1",
        &SessionEvent::User(UserEvent::Message {
            content: vec![ContentBlock::Image { data: "aW1n".to_string(), mime_type: "image/png".to_string() }],
        }),
    )?;
    let media_title = store.list()[0].title.clone();
    assert_eq!(media_title.as_deref(), Some("Media prompt"));

    let (_directory, store) = temp_store();
    store.append_meta("session-1", &meta("session-1", "2026-01-01T00:00:00Z"))?;
    store.append_event("session-1", &user_message(&"a".repeat(120)))?;
    let sessions = store.list();
    let title = sessions[0].title.as_deref().expect("title");
    assert!(title.ends_with(''));
    assert!(title.len() <= 84);
    Ok(())
}

#[test]
fn committed_event_survives_a_derived_index_failure_and_can_be_rebuilt() -> TestResult {
    let (directory, store) = temp_store();
    store.append_meta("session-1", &meta("session-1", "2026-01-01T00:00:00Z"))?;
    std::fs::create_dir(directory.path().join("prompt-history.jsonl"))?;

    store.append_event("session-1", &user_message("repair me"))?;
    assert_eq!(store.load("session-1")?.1, vec![user_message("repair me")]);

    std::fs::remove_dir(directory.path().join("prompt-history.jsonl"))?;
    store.rebuild_prompt_history()?;
    assert_eq!(store.search_prompts("repair me", None)?.results.len(), 1);
    Ok(())
}

#[test]
fn blank_runs_and_oversized_lines_consume_preview_budget() -> TestResult {
    let (directory, store) = temp_store();
    store.append_meta("session-1", &meta("session-1", "2026-01-01T00:00:00Z"))?;
    let mut file = std::fs::OpenOptions::new().append(true).open(directory.path().join("session-1.jsonl"))?;
    write!(file, "{}", "\n".repeat(ScanLimits::PREVIEW.max_bytes + 1))?;
    writeln!(file, "{}", serde_json::to_string(&user_message("must not deserialize"))?)?;

    let preview = store.preview("session-1")?;
    assert!(preview.truncated);
    assert!(preview.transcript.is_empty());
    Ok(())
}

#[test]
fn empty_and_missing_stores_have_no_sessions_or_prompts() -> TestResult {
    let (directory, store) = temp_store();
    assert!(store.list().is_empty());
    let missing_session = store.load("missing").unwrap_err();
    assert!(matches!(missing_session, SessionStoreError::Io(error) if error.kind() == std::io::ErrorKind::NotFound));
    let empty = store.search_prompts(" ", None)?;
    assert!(empty.results.is_empty());

    let missing = SessionStore::from_path(directory.path().join("missing"));
    assert!(missing.list().is_empty());
    Ok(())
}