recursive-agent 0.8.3

A Rust coding-agent platform: kernel, tools, multi-agent orchestration, HTTP API, MCP, and TUI
Documentation
//! Transcript entry serialization types and conversion to runtime [`Message`]s.
//!
//! Split from `session.rs` during the Goal 221 module refactor.

use serde::{Deserialize, Serialize};

/// A single JSONL line representing one message in the transcript.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranscriptEntry {
    /// Stable UUID v4 for this message (g155). Empty string for pre-g155 entries.
    #[serde(default)]
    pub uuid: String,
    /// UUID of the parent message in the conversation chain (g155).
    /// `None` for the root message (first message in a session or chain branch).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent_uuid: Option<String>,
    /// For tool-result messages, the UUID of the assistant message that issued
    /// the tool call (g155). Enables correct attribution in multi-agent trees.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source_tool_assistant_uuid: Option<String>,
    pub id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent_id: Option<String>,
    pub role: String,
    pub content: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tool_calls: Vec<crate::llm::ToolCall>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tool_call_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reasoning_content: Option<String>,
    /// Token usage for this message (non-None for assistant messages, g156).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub usage: Option<super::UsageMeta>,
    pub timestamp: String,
    /// Goal-153: per-call audit metadata. Only populated on `role: "tool"`
    /// messages (i.e. tool results). Never sent to providers — see
    /// [`entry_to_message`] which drops this field.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub audit: Option<crate::tools::AuditMeta>,
}

/// A compact-boundary system entry written to the JSONL when cross-turn
/// compaction fires (g157). Parsed by `SessionReader::load_transcript`
/// to skip pre-boundary messages on resume.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct CompactBoundaryEntry {
    #[serde(rename = "type")]
    pub(crate) entry_type: String,
    pub(crate) subtype: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) turn: Option<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) compacted_count: Option<usize>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) summary_uuid: Option<String>,
    pub(crate) timestamp: String,
}

/// One entry of the **full** on-disk history, as exposed to UI / export
/// paths via [`super::reader::SessionReader::load_full_history`].
///
/// Unlike [`super::reader::SessionReader::load_messages`] (which discards
/// everything before the last `compact_boundary` for the model seed),
/// `load_full_history` keeps every line so the user can see the whole
/// conversation. Each cross-turn compaction shows up here as a
/// [`CompactBoundary`](Self::CompactBoundary) marker that the TUI renders
/// as a single `⊕ Conversation compacted: …` line.
///
/// The message variant carries the full [`TranscriptEntry`] (with
/// `id` / `uuid` / `timestamp` / `usage` / `audit`), not the trimmed
/// [`crate::message::Message`], so `session export` preserves every
/// persisted field.
///
/// The message is boxed to keep the enum small (a `TranscriptEntry` is
/// ~400 bytes vs 16 for the boundary marker; clippy::large-enum-variant).
#[derive(Debug, Clone)]
pub enum LoadedEntry {
    /// A regular conversational message (full persisted shape).
    Message(Box<TranscriptEntry>),
    /// A cross-turn compaction boundary. `removed` is how many older
    /// messages were folded into the summary that follows this marker.
    CompactBoundary { turn: Option<u32>, removed: usize },
}

/// Convert a persisted [`TranscriptEntry`] into a runtime [`Message`].
///
/// Drops persistence-only fields (`id`, `parent_id`, `timestamp`,
/// and — once g153 lands — `audit`). The result is what
/// `run_resumed` expects as its `seed` argument and what provider
/// adapters serialise onto the wire.
///
/// This is the **isolation point** between the persisted shape
/// (`TranscriptEntry`) and the LLM wire shape (`Message`):
/// provider adapters never see persistence-only fields. An unknown
/// `role` string maps to `Role::User` defensively so a corrupted
/// transcript can't panic the resume handler — in practice this
/// never fires because the writer only ever emits the four known
/// roles.
pub fn entry_to_message(entry: TranscriptEntry) -> crate::message::Message {
    use crate::message::Role;
    let role = match entry.role.as_str() {
        "system" => Role::System,
        "user" => Role::User,
        "assistant" => Role::Assistant,
        "tool" => Role::Tool,
        _ => Role::User,
    };
    crate::message::Message {
        role,
        content: entry.content,
        tool_calls: entry.tool_calls,
        tool_call_id: entry.tool_call_id,
        reasoning_content: entry.reasoning_content,
        is_compaction_summary: false,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::message::Role;

    fn make_entry(role: &str) -> TranscriptEntry {
        TranscriptEntry {
            uuid: String::new(),
            parent_uuid: None,
            source_tool_assistant_uuid: None,
            id: "test-id".into(),
            parent_id: None,
            role: role.to_string(),
            content: "hello".into(),
            tool_calls: vec![],
            tool_call_id: None,
            reasoning_content: None,
            usage: None,
            timestamp: "2026-01-01T00:00:00Z".into(),
            audit: None,
        }
    }

    #[test]
    fn entry_to_message_system_role() {
        assert_eq!(entry_to_message(make_entry("system")).role, Role::System);
    }

    #[test]
    fn entry_to_message_user_role() {
        assert_eq!(entry_to_message(make_entry("user")).role, Role::User);
    }

    #[test]
    fn entry_to_message_assistant_role() {
        assert_eq!(
            entry_to_message(make_entry("assistant")).role,
            Role::Assistant
        );
    }

    #[test]
    fn entry_to_message_tool_role() {
        assert_eq!(entry_to_message(make_entry("tool")).role, Role::Tool);
    }

    #[test]
    fn entry_to_message_unknown_role_maps_to_user() {
        assert_eq!(entry_to_message(make_entry("bogus")).role, Role::User);
    }

    #[test]
    fn entry_to_message_is_not_compaction_summary() {
        let msg = entry_to_message(make_entry("assistant"));
        assert!(
            !msg.is_compaction_summary,
            "entry_to_message must always produce is_compaction_summary=false"
        );
    }

    #[test]
    fn entry_to_message_propagates_content() {
        let mut e = make_entry("user");
        e.content = "unique-payload-xyz".into();
        let msg = entry_to_message(e);
        assert_eq!(msg.content, "unique-payload-xyz");
    }
}