aether-sessions 0.3.13

Persisted session logs and transcript reconstruction for Aether
Documentation
use acp_utils::notifications::SessionPreviewRole;
use aether_sessions::testing::{
    DEFAULT_CREATED_AT, TestStore, agent_switched, assistant_text, partial_text, session_meta, tool_call, user_message,
    user_message_with,
};
use aether_sessions::{ScanLimits, SessionStore, SessionStoreError};
use llm::ContentBlock;
use std::io::Write;
use std::path::{Path, PathBuf};

#[test]
fn compact_display_is_persisted_without_replacing_model_context() {
    let content = vec![ContentBlock::text("expanded instructions")];
    let id = llm::MessageId::new();
    let event = aether_sessions::SessionEvent::User(aether_sessions::UserEvent::Message {
        message_id: id.clone(),
        content: content.clone(),
        display_content: Some(vec![ContentBlock::text("/plan")]),
    });
    let store = TestStore::new().session("display", std::slice::from_ref(&event));
    let (_, events) = store.store().load("display").unwrap();
    assert_eq!(events, vec![event]);
    assert_eq!(events[0].user_content().as_deref(), Some("/plan"));
    let reconstructed = aether_sessions::context_from_events(&events);
    let llm::ChatMessage::User { message_id, content: restored, .. } = &reconstructed.messages()[0] else {
        panic!("expected user message");
    };
    assert_eq!(message_id, &id);
    assert_eq!(restored, &content);
    assert_eq!(store.store().list()[0].title.as_deref(), Some("/plan"));
    let preview = store.store().preview("display").unwrap();
    assert_eq!(preview.transcript[0].text, "/plan");
    assert_eq!(store.store().search_prompts("/plan", None).unwrap().results[0].prompt, "/plan");
}

#[test]
fn append_and_load_roundtrip_preserves_metadata_and_persisted_events() {
    let store = TestStore::new();
    let meta = session_meta("session-1", DEFAULT_CREATED_AT);
    store.append_meta("session-1", &meta);
    let user = user_message("Hello");
    store.append("session-1", &user);
    store.append("session-1", &partial_text("message-1", "partial"));
    store.append("session-1", &assistant_text("message-1", "Hi there"));

    let (loaded_meta, events) = store.store().load("session-1").expect("session exists");
    assert_eq!(loaded_meta, meta);
    assert_eq!(events, vec![user, assistant_text("message-1", "Hi there")]);
}

#[test]
fn load_ignores_malformed_trailing_event_lines() {
    let store = TestStore::new();
    let meta = serde_json::to_string(&session_meta("session-1", DEFAULT_CREATED_AT)).unwrap();
    let event = user_message("valid");
    let user = serde_json::to_string(&event).unwrap();
    store.write_raw("session-1.jsonl", &format!("{meta}\n{user}\n{{partial json\n"));

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

#[test]
fn list_sorts_sessions_and_extracts_first_user_title() {
    let store = TestStore::new().session("old", &[user_message("old title")]);
    store.append_meta("new", &session_meta("new", "2026-02-01T00:00:00Z"));
    store.append("new", &agent_switched(None, Some("coder")));
    store.append("new", &user_message("new title\nsecond line"));

    let sessions = store.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"));
}

#[test]
fn list_skips_non_session_jsonl_files_and_malformed_metadata() {
    let store = TestStore::new().session("valid", &[]);
    store.write_raw("prompt-history.jsonl", "not a session");
    store.write_raw("malformed.jsonl", "not metadata\n");
    store.write_raw("notes.txt", "ignored");

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

#[test]
fn prompt_search_is_smart_case_unicode_safe_and_retains_recent_entries() {
    let store = TestStore::new().session("session-1", &[]);
    for index in 0..105 {
        store.append("session-1", &user_message(&format!("prompt {index}")));
    }
    store.append("session-1", &user_message("HELLO world"));
    store.append("session-1", &user_message("café"));

    let old = store.store().search_prompts("prompt 0", None).expect("search succeeds");
    assert_eq!(old.results.len(), 0);

    let lower = store.store().search_prompts("hello", None).expect("search succeeds");
    assert_eq!(lower.results.len(), 1);
    let upper = store.store().search_prompts("Hello", None).expect("search succeeds");
    assert_eq!(upper.results.len(), 0);
    let unicode = store.store().search_prompts("fé", None).expect("search succeeds");
    let hit = &unicode.results[0];
    assert_eq!(&hit.prompt[hit.match_start..hit.match_end], "fé");
}

#[test]
fn relocating_updates_metadata_and_derived_prompt_entries() {
    let store = TestStore::new().session("session-1", &[user_message("move me")]);

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

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

#[test]
fn preview_returns_metadata_media_and_tool_counts() {
    let store = TestStore::new().session(
        "session-1",
        &[
            user_message("preview me"),
            assistant_text("message-1", "assistant reply"),
            tool_call("tool-1", "read", "{}"),
        ],
    );

    let preview = store.store().preview("session-1").expect("preview succeeds");
    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");
}

#[test]
fn preview_unknown_session_returns_not_found_store_error() {
    let store = TestStore::new();
    let error = store.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() {
    let store = TestStore::new().session("session-1", &[]);
    for index in 0..5 {
        store.append("session-1", &user_message(&format!("matching prompt {index}")));
    }

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

#[test]
fn preview_marks_transcript_and_scan_limits_as_truncated() {
    let store = TestStore::new().session("session-1", &[]);
    for index in 0..205 {
        store.append("session-1", &user_message(&format!("prompt {index}")));
    }

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

#[test]
fn list_uses_media_prompt_and_truncates_long_titles() {
    let store = TestStore::new().session(
        "session-1",
        &[user_message_with(vec![ContentBlock::Image {
            data: "aW1n".to_string(),
            mime_type: "image/png".to_string(),
        }])],
    );
    let media_title = store.store().list()[0].title.clone();
    assert_eq!(media_title.as_deref(), Some("Media prompt"));

    let store = TestStore::new().session("session-1", &[user_message(&"a".repeat(120))]);
    let sessions = store.store().list();
    let title = sessions[0].title.as_deref().expect("title");
    assert!(title.ends_with('…'));
    assert!(title.len() <= 84);
}

#[test]
fn committed_event_survives_a_derived_index_failure_and_can_be_rebuilt() {
    let store = TestStore::new().session("session-1", &[]);
    std::fs::create_dir(store.path().join("prompt-history.jsonl")).unwrap();

    let event = user_message("repair me");
    store.append("session-1", &event);
    assert_eq!(store.store().load("session-1").expect("session exists").1, vec![event]);

    std::fs::remove_dir(store.path().join("prompt-history.jsonl")).unwrap();
    store.store().rebuild_prompt_history().expect("rebuild succeeds");
    assert_eq!(store.store().search_prompts("repair me", None).expect("search succeeds").results.len(), 1);
}

#[test]
fn blank_runs_and_oversized_lines_consume_preview_budget() {
    let store = TestStore::new().session("session-1", &[]);
    let mut file = std::fs::OpenOptions::new()
        .append(true)
        .open(store.path().join("session-1.jsonl"))
        .expect("session file exists");
    write!(file, "{}", "\n".repeat(ScanLimits::PREVIEW.max_bytes + 1)).unwrap();
    writeln!(file, "{}", serde_json::to_string(&user_message("must not deserialize")).unwrap()).unwrap();

    let preview = store.store().preview("session-1").expect("preview succeeds");
    assert!(preview.truncated);
    assert_eq!(preview.transcript.len(), 0);
}

#[test]
fn empty_and_missing_stores_have_no_sessions_or_prompts() {
    let store = TestStore::new();
    assert_eq!(store.store().list().len(), 0);
    let missing_session = store.store().load("missing").unwrap_err();
    assert!(matches!(missing_session, SessionStoreError::Io(error) if error.kind() == std::io::ErrorKind::NotFound));
    let empty = store.store().search_prompts(" ", None).expect("search succeeds");
    assert_eq!(empty.results.len(), 0);

    let missing = SessionStore::from_path(store.path().join("missing"));
    assert_eq!(missing.list().len(), 0);
}