Skip to main content

recursive/session/
serialize.rs

1//! Transcript entry serialization types and conversion to runtime [`Message`]s.
2//!
3//! Split from `session.rs` during the Goal 221 module refactor.
4
5use serde::{Deserialize, Serialize};
6
7/// A single JSONL line representing one message in the transcript.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct TranscriptEntry {
10    /// Stable UUID v4 for this message (g155). Empty string for pre-g155 entries.
11    #[serde(default)]
12    pub uuid: String,
13    /// UUID of the parent message in the conversation chain (g155).
14    /// `None` for the root message (first message in a session or chain branch).
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub parent_uuid: Option<String>,
17    /// For tool-result messages, the UUID of the assistant message that issued
18    /// the tool call (g155). Enables correct attribution in multi-agent trees.
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub source_tool_assistant_uuid: Option<String>,
21    pub id: String,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub parent_id: Option<String>,
24    pub role: String,
25    pub content: String,
26    #[serde(default, skip_serializing_if = "Vec::is_empty")]
27    pub tool_calls: Vec<crate::llm::ToolCall>,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub tool_call_id: Option<String>,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub reasoning_content: Option<String>,
32    /// Token usage for this message (non-None for assistant messages, g156).
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub usage: Option<super::UsageMeta>,
35    pub timestamp: String,
36    /// Goal-153: per-call audit metadata. Only populated on `role: "tool"`
37    /// messages (i.e. tool results). Never sent to providers — see
38    /// [`entry_to_message`] which drops this field.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub audit: Option<crate::tools::AuditMeta>,
41}
42
43/// A compact-boundary system entry written to the JSONL when cross-turn
44/// compaction fires (g157). Parsed by `SessionReader::load_transcript`
45/// to skip pre-boundary messages on resume.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub(crate) struct CompactBoundaryEntry {
48    #[serde(rename = "type")]
49    pub(crate) entry_type: String,
50    pub(crate) subtype: String,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub(crate) turn: Option<u32>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub(crate) compacted_count: Option<usize>,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub(crate) summary_uuid: Option<String>,
57    pub(crate) timestamp: String,
58}
59
60/// One entry of the **full** on-disk history, as exposed to UI / export
61/// paths via [`super::reader::SessionReader::load_full_history`].
62///
63/// Unlike [`super::reader::SessionReader::load_messages`] (which discards
64/// everything before the last `compact_boundary` for the model seed),
65/// `load_full_history` keeps every line so the user can see the whole
66/// conversation. Each cross-turn compaction shows up here as a
67/// [`CompactBoundary`](Self::CompactBoundary) marker that the TUI renders
68/// as a single `⊕ Conversation compacted: …` line.
69///
70/// The message variant carries the full [`TranscriptEntry`] (with
71/// `id` / `uuid` / `timestamp` / `usage` / `audit`), not the trimmed
72/// [`crate::message::Message`], so `session export` preserves every
73/// persisted field.
74///
75/// The message is boxed to keep the enum small (a `TranscriptEntry` is
76/// ~400 bytes vs 16 for the boundary marker; clippy::large-enum-variant).
77#[derive(Debug, Clone)]
78pub enum LoadedEntry {
79    /// A regular conversational message (full persisted shape).
80    Message(Box<TranscriptEntry>),
81    /// A cross-turn compaction boundary. `removed` is how many older
82    /// messages were folded into the summary that follows this marker.
83    CompactBoundary { turn: Option<u32>, removed: usize },
84}
85
86/// Convert a persisted [`TranscriptEntry`] into a runtime [`Message`].
87///
88/// Drops persistence-only fields (`id`, `parent_id`, `timestamp`,
89/// and — once g153 lands — `audit`). The result is what
90/// `run_resumed` expects as its `seed` argument and what provider
91/// adapters serialise onto the wire.
92///
93/// This is the **isolation point** between the persisted shape
94/// (`TranscriptEntry`) and the LLM wire shape (`Message`):
95/// provider adapters never see persistence-only fields. An unknown
96/// `role` string maps to `Role::User` defensively so a corrupted
97/// transcript can't panic the resume handler — in practice this
98/// never fires because the writer only ever emits the four known
99/// roles.
100pub fn entry_to_message(entry: TranscriptEntry) -> crate::message::Message {
101    use crate::message::Role;
102    let role = match entry.role.as_str() {
103        "system" => Role::System,
104        "user" => Role::User,
105        "assistant" => Role::Assistant,
106        "tool" => Role::Tool,
107        _ => Role::User,
108    };
109    crate::message::Message {
110        role,
111        content: entry.content,
112        tool_calls: entry.tool_calls,
113        tool_call_id: entry.tool_call_id,
114        reasoning_content: entry.reasoning_content,
115        is_compaction_summary: false,
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::message::Role;
123
124    fn make_entry(role: &str) -> TranscriptEntry {
125        TranscriptEntry {
126            uuid: String::new(),
127            parent_uuid: None,
128            source_tool_assistant_uuid: None,
129            id: "test-id".into(),
130            parent_id: None,
131            role: role.to_string(),
132            content: "hello".into(),
133            tool_calls: vec![],
134            tool_call_id: None,
135            reasoning_content: None,
136            usage: None,
137            timestamp: "2026-01-01T00:00:00Z".into(),
138            audit: None,
139        }
140    }
141
142    #[test]
143    fn entry_to_message_system_role() {
144        assert_eq!(entry_to_message(make_entry("system")).role, Role::System);
145    }
146
147    #[test]
148    fn entry_to_message_user_role() {
149        assert_eq!(entry_to_message(make_entry("user")).role, Role::User);
150    }
151
152    #[test]
153    fn entry_to_message_assistant_role() {
154        assert_eq!(
155            entry_to_message(make_entry("assistant")).role,
156            Role::Assistant
157        );
158    }
159
160    #[test]
161    fn entry_to_message_tool_role() {
162        assert_eq!(entry_to_message(make_entry("tool")).role, Role::Tool);
163    }
164
165    #[test]
166    fn entry_to_message_unknown_role_maps_to_user() {
167        assert_eq!(entry_to_message(make_entry("bogus")).role, Role::User);
168    }
169
170    #[test]
171    fn entry_to_message_is_not_compaction_summary() {
172        let msg = entry_to_message(make_entry("assistant"));
173        assert!(
174            !msg.is_compaction_summary,
175            "entry_to_message must always produce is_compaction_summary=false"
176        );
177    }
178
179    #[test]
180    fn entry_to_message_propagates_content() {
181        let mut e = make_entry("user");
182        e.content = "unique-payload-xyz".into();
183        let msg = entry_to_message(e);
184        assert_eq!(msg.content, "unique-payload-xyz");
185    }
186}