motosan_agent_loop/session/
entry.rs1use serde::{Deserialize, Serialize};
8
9use crate::message::Message;
10
11pub type EntryId = String;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(tag = "type", rename_all = "snake_case")]
18pub enum SessionEntry {
19 Message {
21 #[serde(flatten)]
22 message: Message,
23 },
24 Custom {
26 kind: String,
28 payload: serde_json::Value,
30 },
31}
32
33impl SessionEntry {
34 pub fn message(message: Message) -> Self {
36 Self::Message { message }
37 }
38
39 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 pub fn as_message(&self) -> Option<&Message> {
49 match self {
50 Self::Message { message } => Some(message),
51 Self::Custom { .. } => None,
52 }
53 }
54
55 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#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct StoredEntry {
82 pub id: EntryId,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub parent_id: Option<EntryId>,
87 pub entry: SessionEntry,
89}
90
91impl StoredEntry {
92 pub fn new(id: EntryId, entry: SessionEntry) -> Self {
94 Self {
95 id,
96 parent_id: None,
97 entry,
98 }
99 }
100
101 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
112pub fn new_entry_id() -> EntryId {
114 ulid::Ulid::new().to_string()
115}
116
117pub 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 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}