tea-session 0.1.0

Append-only session log, replay, and storage contracts for tea-rs
Documentation
use crate::common;

use common::{SESSION_ID, creation, envelope};
use serde_json::json;
use std::str::FromStr;
use tea_protocol::{ModelId, SessionId, SessionSequence};
use tea_session::{
    AppendTransaction, InMemorySessionStore, SessionReducer, SessionStore, SessionStoreErrorCode,
    StoredSession, apply_transaction_in_place,
};

#[tokio::test(flavor = "current_thread")]
async fn create_append_load_and_replay_are_equivalent() {
    let store = InMemorySessionStore::new();
    let session_id = SessionId::from_str(SESSION_ID).unwrap();
    let created = store
        .append(AppendTransaction::new(session_id, None, vec![creation()]))
        .await
        .unwrap();
    assert_eq!(created.previous_sequence(), None);
    assert_eq!(created.current_sequence(), SessionSequence::new(0));

    let changed = envelope(
        1,
        "configuration_changed",
        json!({"model":{"providerId":"openai","modelId":"openai/gpt-5"}}),
    );
    let outcome = store
        .append(AppendTransaction::new(
            session_id,
            Some(SessionSequence::new(0)),
            vec![changed],
        ))
        .await
        .unwrap();
    assert_eq!(outcome.current_sequence(), SessionSequence::new(1));

    let loaded = store.load(session_id).await.unwrap();
    assert_eq!(loaded.records().len(), 2);
    assert_eq!(
        loaded.state(),
        &SessionReducer::replay(loaded.records().to_vec()).unwrap()
    );
    assert_eq!(
        loaded.state().configuration().model_id(),
        Some(&ModelId::from_str("openai/gpt-5").unwrap())
    );
}

#[tokio::test(flavor = "current_thread")]
async fn expected_sequence_and_creation_conflicts_are_stable() {
    let store = InMemorySessionStore::new();
    let session_id = SessionId::from_str(SESSION_ID).unwrap();
    store
        .append(AppendTransaction::new(session_id, None, vec![creation()]))
        .await
        .unwrap();

    let duplicate = store
        .append(AppendTransaction::new(session_id, None, vec![creation()]))
        .await
        .unwrap_err();
    assert_eq!(
        duplicate.code(),
        SessionStoreErrorCode::SessionAlreadyExists
    );

    let stale = store
        .append(AppendTransaction::new(
            session_id,
            Some(SessionSequence::new(9)),
            vec![envelope(
                1,
                "configuration_changed",
                json!({"model":{"providerId":"openai","modelId":"openai/gpt-5"}}),
            )],
        ))
        .await
        .unwrap_err();
    assert_eq!(stale.code(), SessionStoreErrorCode::SequenceConflict);

    let missing_id = SessionId::from_str("0195a0b1-5e3a-7d72-a902-c4e85d828bf2").unwrap();
    let mut missing_creation = serde_json::to_value(creation()).unwrap();
    missing_creation["sessionId"] = json!(missing_id.to_string());
    let missing_creation = serde_json::from_value(missing_creation).unwrap();
    let missing = store
        .append(AppendTransaction::new(
            missing_id,
            Some(SessionSequence::new(0)),
            vec![missing_creation],
        ))
        .await
        .unwrap_err();
    assert_eq!(missing.code(), SessionStoreErrorCode::SessionNotFound);
}

#[tokio::test(flavor = "current_thread")]
async fn invalid_batch_rolls_back_all_records() {
    let store = InMemorySessionStore::new();
    let session_id = SessionId::from_str(SESSION_ID).unwrap();
    store
        .append(AppendTransaction::new(session_id, None, vec![creation()]))
        .await
        .unwrap();
    let first = envelope(
        1,
        "configuration_changed",
        json!({"model":{"providerId":"openai","modelId":"openai/gpt-5"}}),
    );
    let gap = envelope(3, "configuration_changed", json!({"profileId":"coding"}));
    assert!(
        store
            .append(AppendTransaction::new(
                session_id,
                Some(SessionSequence::new(0)),
                vec![first, gap],
            ))
            .await
            .is_err()
    );
    let loaded = store.load(session_id).await.unwrap();
    assert_eq!(loaded.records().len(), 1);
    assert_eq!(loaded.state().tail_sequence(), SessionSequence::new(0));
}

#[test]
fn incremental_apply_fails_closed_for_an_inconsistent_cache() {
    let session_id = SessionId::from_str(SESSION_ID).unwrap();
    let mut stored = StoredSession::from_durable_facts(vec![creation()], vec![], vec![]).unwrap();
    stored.reducer = SessionReducer::new();
    let transaction = AppendTransaction::new(
        session_id,
        Some(SessionSequence::new(0)),
        vec![envelope(
            1,
            "configuration_changed",
            json!({"profileId":"coding"}),
        )],
    );

    let error = apply_transaction_in_place(&transaction, &mut stored, true, |_| false).unwrap_err();

    assert_eq!(error.code(), SessionStoreErrorCode::CorruptionDetected);
}

#[test]
fn store_is_object_safe_send_and_sync() {
    fn assert_store<T: SessionStore + Send + Sync>() {}
    assert_store::<InMemorySessionStore>();
    let _object: Box<dyn SessionStore> = Box::new(InMemorySessionStore::new());
}