tea-session 0.1.0

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

use common::{FORK_BRANCH_ID, ROOT_BRANCH_ID, branched_creation, envelope_on_branch, user_message};
use serde_json::json;
use std::str::FromStr;
use tea_protocol::{BranchId, ModelId, ProfileId, ReasoningEffort};
use tea_session::{SessionReducer, SessionReplayError};

fn branch_record(
    sequence: usize,
    source_branch: &str,
    new_branch: &str,
    from_record: &str,
) -> tea_protocol::RecordEnvelope {
    envelope_on_branch(
        sequence,
        "branch_created",
        json!({
            "sourceBranchId":source_branch,
            "branchId":new_branch,
            "fromRecordId":from_record
        }),
        new_branch,
    )
}

#[test]
fn fork_clones_source_projection_without_rewriting_parent() {
    let creation = branched_creation();
    let first: tea_protocol::RecordEnvelope = serde_json::from_value({
        let mut value = serde_json::to_value(user_message(1)).unwrap();
        value["branchId"] = json!(ROOT_BRANCH_ID);
        value
    })
    .unwrap();
    let configured = envelope_on_branch(
        2,
        "configuration_changed",
        json!({"model":{"providerId":"openai","modelId":"openai/gpt-5"}}),
        ROOT_BRANCH_ID,
    );
    let fork = branch_record(
        3,
        ROOT_BRANCH_ID,
        FORK_BRANCH_ID,
        &first.record_id().to_string(),
    );
    let activate = envelope_on_branch(
        4,
        "active_branch_changed",
        json!({"branchId":FORK_BRANCH_ID}),
        FORK_BRANCH_ID,
    );
    let fork_config = envelope_on_branch(
        5,
        "configuration_changed",
        json!({"profileId":"fork-profile"}),
        FORK_BRANCH_ID,
    );
    let state = SessionReducer::replay([
        creation,
        first.clone(),
        configured.clone(),
        fork,
        activate,
        fork_config,
    ])
    .unwrap();

    let root = BranchId::from_str(ROOT_BRANCH_ID).unwrap();
    let fork = BranchId::from_str(FORK_BRANCH_ID).unwrap();
    assert_eq!(state.active_branch_id(), Some(fork));
    assert_eq!(state.messages().len(), 1);
    assert_eq!(state.configuration().model_id(), None);
    assert_eq!(
        state.configuration().profile_id(),
        &ProfileId::from_str("fork-profile").unwrap()
    );
    assert_eq!(
        state.branches()[&root].leaf_record_id(),
        configured.record_id()
    );
    assert_eq!(state.branches()[&fork].from_record_id(), first.record_id());
}

#[test]
fn branch_references_and_active_scope_fail_closed() {
    let creation = branched_creation();
    let unknown_source = branch_record(
        1,
        FORK_BRANCH_ID,
        "0195a0b1-5e60-7f10-b111-0aa7aa000040",
        &creation.record_id().to_string(),
    );
    assert!(matches!(
        SessionReducer::replay([creation.clone(), unknown_source]),
        Err(SessionReplayError::InvalidReference {
            reference: "source_branch"
        })
    ));

    let inactive_write = envelope_on_branch(
        1,
        "configuration_changed",
        json!({"model":{"providerId":"openai","modelId":"openai/gpt-5"}}),
        FORK_BRANCH_ID,
    );
    assert!(matches!(
        SessionReducer::replay([creation.clone(), inactive_write]),
        Err(SessionReplayError::InvalidReference {
            reference: "inactive_branch"
        })
    ));

    let unknown_active = envelope_on_branch(
        1,
        "active_branch_changed",
        json!({"branchId":FORK_BRANCH_ID}),
        FORK_BRANCH_ID,
    );
    assert!(matches!(
        SessionReducer::replay([creation, unknown_active]),
        Err(SessionReplayError::InvalidReference {
            reference: "active_branch_change"
        })
    ));
}

#[test]
fn fork_point_must_belong_to_source_history_and_branch_id_is_unique() {
    let creation = branched_creation();
    let config = envelope_on_branch(
        1,
        "configuration_changed",
        json!({"model":{"providerId":"openai","modelId":"openai/gpt-5"}}),
        ROOT_BRANCH_ID,
    );
    let outside = branch_record(
        2,
        ROOT_BRANCH_ID,
        FORK_BRANCH_ID,
        "0195a0b1-5e61-7a10-b111-0aa7aa000041",
    );
    assert!(matches!(
        SessionReducer::replay([creation.clone(), config.clone(), outside]),
        Err(SessionReplayError::InvalidReference {
            reference: "fork_point_outside_source_branch"
        })
    ));

    let fork = branch_record(
        2,
        ROOT_BRANCH_ID,
        FORK_BRANCH_ID,
        &config.record_id().to_string(),
    );
    let duplicate = branch_record(
        3,
        ROOT_BRANCH_ID,
        FORK_BRANCH_ID,
        &config.record_id().to_string(),
    );
    assert!(matches!(
        SessionReducer::replay([creation, config, fork, duplicate]),
        Err(SessionReplayError::DuplicateEntity { entity: "branch" })
    ));
}

#[test]
fn fork_rejects_pending_or_incomplete_tool_state() {
    let creation = branched_creation();
    let assistant: tea_protocol::RecordEnvelope = serde_json::from_value({
        let mut value = serde_json::to_value(common::assistant_tool_message(1)).unwrap();
        value["branchId"] = json!(ROOT_BRANCH_ID);
        value
    })
    .unwrap();
    let fork = branch_record(
        2,
        ROOT_BRANCH_ID,
        FORK_BRANCH_ID,
        &assistant.record_id().to_string(),
    );
    assert!(matches!(
        SessionReducer::replay([creation, assistant, fork]),
        Err(SessionReplayError::InvalidTransition {
            transition: "unsafe_fork_point"
        })
    ));
}

#[test]
fn root_branch_configuration_is_materialized() {
    let config = envelope_on_branch(
        1,
        "configuration_changed",
        json!({"model":{"providerId":"openai","modelId":"openai/gpt-5"}}),
        ROOT_BRANCH_ID,
    );
    let state = SessionReducer::replay([branched_creation(), config]).unwrap();
    assert_eq!(
        state.configuration().model_id(),
        Some(&ModelId::from_str("openai/gpt-5").unwrap())
    );
}

#[test]
fn fork_retains_reasoning_selected_before_the_fork_point() {
    let configured = envelope_on_branch(
        1,
        "configuration_changed",
        json!({
            "model":{"providerId":"openai","modelId":"openai/gpt-5"},
            "reasoningEffort":"high"
        }),
        ROOT_BRANCH_ID,
    );
    let fork = branch_record(
        2,
        ROOT_BRANCH_ID,
        FORK_BRANCH_ID,
        &configured.record_id().to_string(),
    );
    let activate = envelope_on_branch(
        3,
        "active_branch_changed",
        json!({"branchId":FORK_BRANCH_ID}),
        FORK_BRANCH_ID,
    );

    let state = SessionReducer::replay([branched_creation(), configured, fork, activate]).unwrap();
    assert_eq!(
        state.active_branch_id(),
        Some(FORK_BRANCH_ID.parse().unwrap())
    );
    assert_eq!(
        state.configuration().reasoning_effort(),
        Some(ReasoningEffort::High)
    );
}

#[test]
fn nested_fork_replays_branch_creation_as_a_historical_fork_point() {
    const SECOND_FORK_BRANCH_ID: &str = "0195a0b1-5e60-7f10-b111-0aa7aa000040";

    let first: tea_protocol::RecordEnvelope = serde_json::from_value({
        let mut value = serde_json::to_value(user_message(1)).unwrap();
        value["branchId"] = json!(ROOT_BRANCH_ID);
        value
    })
    .unwrap();
    let configured = envelope_on_branch(
        2,
        "configuration_changed",
        json!({"model":{"providerId":"openai","modelId":"openai/gpt-5"}}),
        ROOT_BRANCH_ID,
    );
    let first_fork = branch_record(
        3,
        ROOT_BRANCH_ID,
        FORK_BRANCH_ID,
        &first.record_id().to_string(),
    );
    let activate_first = envelope_on_branch(
        4,
        "active_branch_changed",
        json!({"branchId":FORK_BRANCH_ID}),
        FORK_BRANCH_ID,
    );
    let configure_first = envelope_on_branch(
        5,
        "configuration_changed",
        json!({"profileId":"first-fork-profile"}),
        FORK_BRANCH_ID,
    );
    let second_fork = branch_record(
        6,
        FORK_BRANCH_ID,
        SECOND_FORK_BRANCH_ID,
        &first_fork.record_id().to_string(),
    );
    let activate_second = envelope_on_branch(
        7,
        "active_branch_changed",
        json!({"branchId":SECOND_FORK_BRANCH_ID}),
        SECOND_FORK_BRANCH_ID,
    );

    let state = SessionReducer::replay([
        branched_creation(),
        first,
        configured,
        first_fork,
        activate_first,
        configure_first,
        second_fork,
        activate_second,
    ])
    .unwrap();

    assert_eq!(
        state.active_branch_id(),
        Some(SECOND_FORK_BRANCH_ID.parse().unwrap())
    );
    assert_eq!(state.messages().len(), 1);
    assert_eq!(state.configuration().model_id(), None);
    assert_eq!(
        state.configuration().profile_id(),
        &ProfileId::from_str("minimal-assistant").unwrap()
    );
}