Skip to main content

motosan_agent_loop/session/
entry.rs

1//! Session entry — the unit of persistent storage.
2//!
3//! The store operates on `SessionEntry`, not `Message` directly. An entry
4//! is either a `Message` (LLM-facing conversation content) or a `Custom`
5//! entry (app-defined sidecar data that, by default, the LLM never sees).
6
7use serde::{Deserialize, Serialize};
8
9use crate::message::Message;
10
11/// Opaque identifier for a persisted entry. Generated by the store on
12/// append.
13pub type EntryId = String;
14
15/// A single persisted entry in a session log.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(tag = "type", rename_all = "snake_case")]
18pub enum SessionEntry {
19    /// A conversation message visible to the LLM.
20    Message {
21        #[serde(flatten)]
22        message: Message,
23    },
24    /// App-defined sidecar entry.
25    Custom {
26        /// App-defined namespace.
27        kind: String,
28        /// Opaque JSON payload.
29        payload: serde_json::Value,
30    },
31}
32
33impl SessionEntry {
34    /// Construct a message entry.
35    pub fn message(message: Message) -> Self {
36        Self::Message { message }
37    }
38
39    /// Construct a custom entry.
40    pub fn custom(kind: impl Into<String>, payload: serde_json::Value) -> Self {
41        Self::Custom {
42            kind: kind.into(),
43            payload,
44        }
45    }
46
47    /// Borrow the inner `Message`, if this entry is a message.
48    pub fn as_message(&self) -> Option<&Message> {
49        match self {
50            Self::Message { message } => Some(message),
51            Self::Custom { .. } => None,
52        }
53    }
54
55    /// The `kind` of a custom entry, or `None` for message entries.
56    pub fn custom_kind(&self) -> Option<&str> {
57        match self {
58            Self::Custom { kind, .. } => Some(kind.as_str()),
59            Self::Message { .. } => None,
60        }
61    }
62}
63
64/// A persisted entry together with its log-position metadata.
65///
66/// This is the record `SessionStore::load_entries` yields and the unit the
67/// projection consumes. `parent_id` describes *log position* — "the entry
68/// this one continues from" — not message content, which is why it lives on
69/// this record and not inside [`SessionEntry`].
70///
71/// - `parent_id == None` ⇒ continues the positional predecessor (the entry
72///   on the immediately preceding log line). `None` at line 0 means root.
73/// - `parent_id == Some(id)` ⇒ continues entry `id`, anywhere earlier in the
74///   log. This is a **branch point**.
75///
76/// Invariant: only branch points carry `Some`. Every other entry's parent is
77/// its positional predecessor, hence `None`. A never-branched session has
78/// `None` on every line, so `parent_id` is omitted from the serialized form
79/// (`skip_serializing_if`) and the file is byte-identical to a pre-0.20 log.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct StoredEntry {
82    /// Opaque id of this entry.
83    pub id: EntryId,
84    /// The entry this one continues from. See the type docs.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub parent_id: Option<EntryId>,
87    /// The persisted payload.
88    pub entry: SessionEntry,
89}
90
91impl StoredEntry {
92    /// A linear entry — `parent_id` is `None` (continues the previous line).
93    pub fn new(id: EntryId, entry: SessionEntry) -> Self {
94        Self {
95            id,
96            parent_id: None,
97            entry,
98        }
99    }
100
101    /// An entry with an explicit parent. Pass `Some(..)` to create a branch
102    /// point; `None` is equivalent to [`StoredEntry::new`].
103    pub fn with_parent(id: EntryId, parent_id: Option<EntryId>, entry: SessionEntry) -> Self {
104        Self {
105            id,
106            parent_id,
107            entry,
108        }
109    }
110}
111
112/// Generate a new `EntryId`.
113pub fn new_entry_id() -> EntryId {
114    ulid::Ulid::new().to_string()
115}
116
117/// Generate a deterministic `EntryId` from a positional index and content.
118///
119/// Used for legacy (pre-0.15) session entries that were stored without
120/// explicit IDs.  The same (index, content) pair always produces the same
121/// ID, which is critical for compaction markers that reference entry IDs
122/// across restarts.
123pub fn deterministic_entry_id(index: usize, content: &str) -> EntryId {
124    use siphasher::sip::SipHasher13;
125    use std::hash::{Hash, Hasher};
126
127    let mut hasher = SipHasher13::new();
128    index.hash(&mut hasher);
129    content.hash(&mut hasher);
130    format!("legacy-{index:08x}-{:016x}", hasher.finish())
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::message::Message;
137    use serde_json::json;
138
139    #[test]
140    fn message_entry_round_trips_json() {
141        let entry = SessionEntry::message(Message::user("hi"));
142        let s = serde_json::to_string(&entry).unwrap();
143        let parsed: SessionEntry = serde_json::from_str(&s).unwrap();
144        assert!(matches!(parsed, SessionEntry::Message { .. }));
145        assert_eq!(parsed.as_message().unwrap().text(), "hi");
146    }
147
148    #[test]
149    fn session_entry_wrapping_tagged_message_has_both_type_and_role() {
150        let entry = SessionEntry::message(Message::user("hi"));
151        let s = serde_json::to_string(&entry).unwrap();
152        assert!(s.contains("\"type\":\"message\""), "entry tag missing: {s}");
153        assert!(s.contains("\"role\":\"user\""), "message tag missing: {s}");
154        let back: SessionEntry = serde_json::from_str(&s).unwrap();
155        assert!(matches!(back, SessionEntry::Message { .. }));
156        assert_eq!(back.as_message().unwrap().text(), "hi");
157    }
158
159    #[test]
160    fn custom_entry_round_trips_json() {
161        let entry = SessionEntry::custom(
162            "compaction",
163            json!({ "summary": "earlier stuff", "first_kept_entry_id": "01ABC" }),
164        );
165        let s = serde_json::to_string(&entry).unwrap();
166        let parsed: SessionEntry = serde_json::from_str(&s).unwrap();
167        assert_eq!(parsed.custom_kind(), Some("compaction"));
168        match parsed {
169            SessionEntry::Custom { payload, .. } => {
170                assert_eq!(payload["summary"], "earlier stuff");
171            }
172            _ => panic!("expected Custom"),
173        }
174    }
175
176    #[test]
177    fn message_entry_does_not_report_custom_kind() {
178        let e = SessionEntry::message(Message::assistant("ok"));
179        assert!(e.custom_kind().is_none());
180    }
181
182    #[test]
183    fn custom_entry_does_not_report_message() {
184        let e = SessionEntry::custom("foo", json!({}));
185        assert!(e.as_message().is_none());
186    }
187
188    #[test]
189    fn new_entry_id_is_ulid_like() {
190        let a = new_entry_id();
191        let b = new_entry_id();
192        assert_ne!(a, b);
193        assert_eq!(a.len(), 26);
194        assert_eq!(b.len(), 26);
195    }
196
197    #[test]
198    fn deterministic_entry_id_is_stable() {
199        assert_eq!(
200            deterministic_entry_id(1, "{\"role\":\"User\",\"content\":\"hello\"}"),
201            "legacy-00000001-2dccd4ce84ec3aec"
202        );
203    }
204
205    #[test]
206    fn stored_entry_omits_parent_id_when_none() {
207        let se = StoredEntry::new(
208            "01ABC".to_string(),
209            SessionEntry::message(Message::user("hi")),
210        );
211        let json = serde_json::to_string(&se).unwrap();
212        assert!(
213            !json.contains("parent_id"),
214            "a flat entry must not serialize parent_id: {json}"
215        );
216        let back: StoredEntry = serde_json::from_str(&json).unwrap();
217        assert_eq!(back.id, "01ABC");
218        assert!(back.parent_id.is_none());
219        assert_eq!(back.entry.as_message().unwrap().text(), "hi");
220    }
221
222    #[test]
223    fn stored_entry_round_trips_parent_id() {
224        let se = StoredEntry::with_parent(
225            "child".to_string(),
226            Some("parent".to_string()),
227            SessionEntry::message(Message::user("hi")),
228        );
229        let json = serde_json::to_string(&se).unwrap();
230        assert!(json.contains("\"parent_id\":\"parent\""), "{json}");
231        let back: StoredEntry = serde_json::from_str(&json).unwrap();
232        assert_eq!(back.parent_id.as_deref(), Some("parent"));
233    }
234
235    #[test]
236    fn stored_entry_deserializes_legacy_line_without_parent_id() {
237        // A pre-0.20 entry line carried only `id` + `entry`. It must load
238        // with parent_id == None (a pure linear chain).
239        let entry = SessionEntry::message(Message::user("hi"));
240        let line = serde_json::json!({
241            "id": "x",
242            "entry": serde_json::to_value(&entry).unwrap(),
243        })
244        .to_string();
245        let se: StoredEntry = serde_json::from_str(&line).unwrap();
246        assert!(se.parent_id.is_none());
247        assert_eq!(se.entry.as_message().unwrap().text(), "hi");
248    }
249}