aether-sessions 0.1.0

Persisted session logs and transcript reconstruction for Aether
Documentation
use aether_core::events::{AgentEvent, ContextEvent, MessageEvent, StreamState, ToolEvent, TurnEvent, TurnOutcome};
use aether_sessions::{
    SessionControlEvent, SessionEvent, UserEvent, context_from_events, conversation_messages_from_events,
};

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

fn complete_text(id: &str, content: &str) -> SessionEvent {
    SessionEvent::Agent(AgentEvent::Message(MessageEvent::Text {
        message_id: id.into(),
        chunk: content.into(),
        is_complete: true,
    }))
}

fn ended() -> SessionEvent {
    SessionEvent::Agent(AgentEvent::Turn(TurnEvent::Ended { outcome: TurnOutcome::Completed }))
}

fn tool_result(id: &str, name: &str, result: &str) -> SessionEvent {
    SessionEvent::Agent(AgentEvent::Tool(ToolEvent::Result {
        result: llm::ToolCallResult { id: id.into(), name: name.into(), arguments: "{}".into(), result: result.into() },
        result_meta: None,
    }))
}

#[test]
fn reconstructs_conversation_and_ignores_control_events() {
    let messages = conversation_messages_from_events(&[
        user("Hello"),
        SessionEvent::Control(SessionControlEvent::AgentSwitched { from: None, to: Some("coder".into()) }),
        complete_text("message-1", "Hi there!"),
        ended(),
    ]);

    assert_eq!(messages.len(), 2);
    assert!(matches!(messages[0], llm::ChatMessage::User { .. }));
    assert!(matches!(messages[1], llm::ChatMessage::Assistant { .. }));
}

#[test]
fn reconstructs_successful_tool_calls() {
    let context = context_from_events(&[
        user("Read Cargo.toml"),
        SessionEvent::Agent(AgentEvent::Tool(ToolEvent::Call {
            request: llm::ToolCallRequest { id: "call-1".into(), name: "read_file".into(), arguments: "{}".into() },
        })),
        tool_result("call-1", "read_file", "file contents"),
        complete_text("message-1", "Here is the file"),
        ended(),
    ]);

    assert_eq!(context.message_count(), 3);
    assert!(
        matches!(&context.messages()[1], llm::ChatMessage::Assistant { content, tool_calls, .. } if content == "Here is the file" && tool_calls.len() == 1)
    );
    assert!(context.messages()[2].is_tool_result());
}

#[test]
fn reconstructs_tools_failures_and_context_boundaries() {
    let events = [
        user("Read missing.txt"),
        SessionEvent::Agent(AgentEvent::Tool(ToolEvent::Error {
            error: llm::ToolCallError {
                id: "call-1".into(),
                name: "read_file".into(),
                arguments: Some(r#"{"path":"missing.txt"}"#.into()),
                error: "file not found".into(),
            },
        })),
        ended(),
        SessionEvent::Agent(AgentEvent::Context(ContextEvent::Cleared)),
        user("Start fresh"),
    ];
    let context = context_from_events(&events);
    let before_clear = context_from_events(&events[..3]);

    assert_eq!(before_clear.message_count(), 3);
    assert!(
        matches!(before_clear.messages()[2], llm::ChatMessage::ToolCallResult(Err(ref error)) if error.error == "file not found")
    );
    assert_eq!(context.message_count(), 1);
    assert!(matches!(context.messages()[0], llm::ChatMessage::User { .. }));
}

#[test]
fn empty_or_contentless_turns_do_not_add_messages() {
    assert_eq!(context_from_events(&[]).message_count(), 0);
    assert_eq!(context_from_events(&[ended()]).message_count(), 0);
}

#[test]
fn completed_turns_reset_the_accumulator() {
    let context = context_from_events(&[
        complete_text("message-1", "Turn 1"),
        ended(),
        complete_text("message-2", "Turn 2"),
        ended(),
    ]);

    assert_eq!(context.message_count(), 2);
}

#[test]
fn compaction_replaces_prior_messages_with_a_summary() {
    let context = context_from_events(&[
        user("Hello"),
        complete_text("message-1", "Hi!"),
        ended(),
        SessionEvent::Agent(AgentEvent::Context(ContextEvent::CompactionResult {
            summary: "Earlier we greeted each other.".into(),
            messages_removed: 2,
        })),
        user("What did we talk about?"),
    ]);

    assert_eq!(context.message_count(), 2);
    assert!(context.messages()[0].is_summary());
}

#[test]
fn complete_messages_are_reconstructed_but_streaming_chunks_are_not() {
    let context = context_from_events(&[
        SessionEvent::Agent(AgentEvent::text("partial", "partial", StreamState::Partial)),
        SessionEvent::Agent(AgentEvent::Message(MessageEvent::Text {
            message_id: "complete".into(),
            chunk: "complete".into(),
            is_complete: true,
        })),
        ended(),
    ]);

    assert_eq!(context.message_count(), 1);
    assert!(matches!(&context.messages()[0], llm::ChatMessage::Assistant { content, .. } if content == "complete"));
}