Skip to main content

modular_agent_core/
session.rs

1//! Session persistence for LLM conversation histories.
2//!
3//! A session is an append-only log of [`SessionEntry`] records — finalized
4//! chat [`Message`]s and non-destructive [`SessionEntry::Compaction`] markers
5//! — identified by a [`SessionMeta`] header. Two [`SessionStore`]
6//! implementations are provided: [`InMemorySessionStore`] (non-persistent)
7//! and [`JsonlSessionStore`] (one JSONL file per session on disk).
8//! [`build_context`] converts a stored entry log into the message list to
9//! send to an LLM, honoring the most recent compaction.
10//!
11//! # Ownership and lifetime
12//!
13//! A `SessionStore` is owned by the component that creates it — typically a
14//! single agent instance — and lives exactly as long as its owner. There is
15//! no global registry and no static state; dropping the owner drops the
16//! store (persisted JSONL files of course remain on disk).
17//!
18//! # Invariants
19//!
20//! 1. **Only finalized messages reach the store.** Callers must append only
21//!    messages with `streaming == false`; partial streaming emissions and
22//!    their id-based dedup/replacement stay in the caller's memory.
23//! 2. **The store API is append-only.** No delete, update, or overwrite
24//!    operations exist. Compaction is recorded as a new entry that changes
25//!    how the context is *built*, never by rewriting history. A crash can
26//!    lose at most the single in-flight entry (appends flush immediately).
27
28#![cfg(feature = "llm")]
29
30use std::collections::BTreeMap;
31use std::path::PathBuf;
32use std::sync::Mutex;
33
34use async_trait::async_trait;
35use serde::{Deserialize, Serialize};
36use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
37
38use crate::error::AgentError;
39use crate::llm::Message;
40
41fn default_version() -> u32 {
42    1
43}
44
45fn new_uuid() -> String {
46    uuid::Uuid::new_v4().to_string()
47}
48
49fn now_rfc3339() -> String {
50    chrono::Utc::now().to_rfc3339()
51}
52
53/// Header metadata identifying one session.
54///
55/// Serialized as the first line of a JSONL session file. Unknown fields are
56/// ignored and optional fields default on deserialization, so files written
57/// by newer versions still parse where possible.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct SessionMeta {
60    /// Unique session id (UUID v4), unique across process restarts.
61    pub id: String,
62
63    /// Schema version of the session file. Currently always 1.
64    #[serde(default = "default_version")]
65    pub version: u32,
66
67    /// Reserved for future session forking; always `None` for now.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub parent_session: Option<String>,
70
71    /// RFC3339 creation timestamp.
72    #[serde(default)]
73    pub created_at: String,
74}
75
76impl SessionMeta {
77    /// Creates a header for a new session with a freshly generated UUID v4
78    /// id and the current time as `created_at`.
79    pub fn new() -> Self {
80        Self {
81            id: new_uuid(),
82            version: 1,
83            parent_session: None,
84            created_at: now_rfc3339(),
85        }
86    }
87}
88
89impl Default for SessionMeta {
90    fn default() -> Self {
91        Self::new()
92    }
93}
94
95/// One record in a session's append-only log.
96///
97/// Serialized as an internally tagged object (`{"type": "message", ...}`).
98/// `parent_id` is reserved for future tree/fork support and is always `None`
99/// for now.
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101#[serde(tag = "type", rename_all = "snake_case")]
102pub enum SessionEntry {
103    /// A finalized chat message.
104    Message {
105        /// Unique entry id (UUID v4).
106        id: String,
107        /// Reserved for future tree/fork support; always `None` for now.
108        #[serde(default, skip_serializing_if = "Option::is_none")]
109        parent_id: Option<String>,
110        /// RFC3339 timestamp of when the entry was recorded.
111        timestamp: String,
112        /// The finalized message (`streaming == false`).
113        message: Message,
114    },
115
116    /// A non-destructive compaction marker: earlier history is summarized
117    /// but never removed from the store.
118    Compaction {
119        /// Unique entry id (UUID v4).
120        id: String,
121        /// Reserved for future tree/fork support; always `None` for now.
122        #[serde(default, skip_serializing_if = "Option::is_none")]
123        parent_id: Option<String>,
124        /// RFC3339 timestamp of when the entry was recorded.
125        timestamp: String,
126        /// Summary of the history preceding `first_kept_id`.
127        summary: String,
128        /// Entry id of the first Message entry kept verbatim in the built
129        /// context.
130        first_kept_id: String,
131        /// Token count of the history before compaction, if known.
132        #[serde(default, skip_serializing_if = "Option::is_none")]
133        tokens_before: Option<u64>,
134    },
135}
136
137impl SessionEntry {
138    /// Creates a Message entry with a freshly generated UUID v4 id and the
139    /// current time as timestamp.
140    pub fn message(message: Message) -> Self {
141        SessionEntry::Message {
142            id: new_uuid(),
143            parent_id: None,
144            timestamp: now_rfc3339(),
145            message,
146        }
147    }
148
149    /// Creates a Compaction entry with a freshly generated UUID v4 id and
150    /// the current time as timestamp.
151    pub fn compaction(summary: String, first_kept_id: String, tokens_before: Option<u64>) -> Self {
152        SessionEntry::Compaction {
153            id: new_uuid(),
154            parent_id: None,
155            timestamp: now_rfc3339(),
156            summary,
157            first_kept_id,
158            tokens_before,
159        }
160    }
161
162    /// The entry's unique id.
163    pub fn id(&self) -> &str {
164        match self {
165            SessionEntry::Message { id, .. } => id,
166            SessionEntry::Compaction { id, .. } => id,
167        }
168    }
169}
170
171/// Append-only storage for session logs.
172///
173/// The trait surface is deliberately append-only (invariant 2 of the
174/// [module docs](self)): implementations must not expose delete, update, or
175/// overwrite operations.
176#[async_trait]
177pub trait SessionStore: Send + Sync {
178    /// Creates a new session from its header and returns the session id.
179    ///
180    /// Errors if a session with `meta.id` already exists.
181    async fn create(&self, meta: SessionMeta) -> Result<String, AgentError>;
182
183    /// Appends one entry to an existing session.
184    ///
185    /// Callers must append only finalized messages (`message.streaming ==
186    /// false`, invariant 1 of the [module docs](self)); partial streaming
187    /// messages belong in the caller's memory, not in the store.
188    async fn append(&self, session_id: &str, entry: SessionEntry) -> Result<(), AgentError>;
189
190    /// Loads all entries of a session in append order.
191    async fn load(&self, session_id: &str) -> Result<Vec<SessionEntry>, AgentError>;
192
193    /// Lists the headers of all sessions in the store.
194    async fn list(&self) -> Result<Vec<SessionMeta>, AgentError>;
195}
196
197fn session_not_found(session_id: &str) -> AgentError {
198    AgentError::Other(format!("Session not found: {session_id}"))
199}
200
201type SessionMap = BTreeMap<String, (SessionMeta, Vec<SessionEntry>)>;
202
203/// Non-persistent [`SessionStore`] holding all sessions in memory.
204///
205/// Owned by whoever creates it; there is no global registry. All contents
206/// are lost when the store is dropped.
207#[derive(Debug, Default)]
208pub struct InMemorySessionStore {
209    sessions: Mutex<SessionMap>,
210}
211
212impl InMemorySessionStore {
213    /// Creates an empty store.
214    pub fn new() -> Self {
215        Self::default()
216    }
217
218    fn lock(&self) -> std::sync::MutexGuard<'_, SessionMap> {
219        // A poisoned lock only means another thread panicked mid-operation;
220        // the map itself is still structurally valid.
221        self.sessions
222            .lock()
223            .unwrap_or_else(|poisoned| poisoned.into_inner())
224    }
225}
226
227#[async_trait]
228impl SessionStore for InMemorySessionStore {
229    async fn create(&self, meta: SessionMeta) -> Result<String, AgentError> {
230        let mut sessions = self.lock();
231        let id = meta.id.clone();
232        if sessions.contains_key(&id) {
233            return Err(AgentError::DuplicateId(id));
234        }
235        sessions.insert(id.clone(), (meta, Vec::new()));
236        Ok(id)
237    }
238
239    async fn append(&self, session_id: &str, entry: SessionEntry) -> Result<(), AgentError> {
240        let mut sessions = self.lock();
241        let (_, entries) = sessions
242            .get_mut(session_id)
243            .ok_or_else(|| session_not_found(session_id))?;
244        entries.push(entry);
245        Ok(())
246    }
247
248    async fn load(&self, session_id: &str) -> Result<Vec<SessionEntry>, AgentError> {
249        let sessions = self.lock();
250        let (_, entries) = sessions
251            .get(session_id)
252            .ok_or_else(|| session_not_found(session_id))?;
253        Ok(entries.clone())
254    }
255
256    async fn list(&self) -> Result<Vec<SessionMeta>, AgentError> {
257        let sessions = self.lock();
258        Ok(sessions.values().map(|(meta, _)| meta.clone()).collect())
259    }
260}
261
262/// Persistent [`SessionStore`] writing one JSONL file per session.
263///
264/// Each session `<id>` lives in `<dir>/<id>.jsonl`: line 1 is the
265/// [`SessionMeta`] header as JSON, and every following line is one
266/// [`SessionEntry`] as JSON. Appends open the file in append mode, write a
267/// single line, and flush immediately, so a crash loses at most the
268/// in-flight entry. When loading, a partially written final line (a
269/// crash-truncated tail without a trailing newline) is discarded and
270/// truncated from the file, so later appends stay line-aligned instead of
271/// fusing onto the fragment; an unparseable complete line is an error.
272#[derive(Debug, Clone)]
273pub struct JsonlSessionStore {
274    dir: PathBuf,
275}
276
277impl JsonlSessionStore {
278    /// Creates a store rooted at `dir`. The directory is created lazily on
279    /// the first [`SessionStore::create`] call.
280    pub fn new(dir: impl Into<PathBuf>) -> Self {
281        Self { dir: dir.into() }
282    }
283
284    fn session_path(&self, session_id: &str) -> PathBuf {
285        self.dir.join(format!("{session_id}.jsonl"))
286    }
287}
288
289#[async_trait]
290impl SessionStore for JsonlSessionStore {
291    async fn create(&self, meta: SessionMeta) -> Result<String, AgentError> {
292        tokio::fs::create_dir_all(&self.dir)
293            .await
294            .map_err(|e| AgentError::IoError(format!("Failed to create session dir: {e}")))?;
295        let id = meta.id.clone();
296        let path = self.session_path(&id);
297        // create_new makes the existence check atomic with file creation.
298        let mut file = tokio::fs::OpenOptions::new()
299            .write(true)
300            .create_new(true)
301            .open(&path)
302            .await
303            .map_err(|e| {
304                if e.kind() == std::io::ErrorKind::AlreadyExists {
305                    AgentError::DuplicateId(id.clone())
306                } else {
307                    AgentError::IoError(format!("Failed to create session file: {e}"))
308                }
309            })?;
310        let mut line = serde_json::to_string(&meta).map_err(|e| {
311            AgentError::SerializationError(format!("Failed to serialize meta: {e}"))
312        })?;
313        line.push('\n');
314        file.write_all(line.as_bytes())
315            .await
316            .map_err(|e| AgentError::IoError(format!("Failed to write session header: {e}")))?;
317        file.flush()
318            .await
319            .map_err(|e| AgentError::IoError(format!("Failed to flush session file: {e}")))?;
320        Ok(id)
321    }
322
323    async fn append(&self, session_id: &str, entry: SessionEntry) -> Result<(), AgentError> {
324        let path = self.session_path(session_id);
325        let mut file = tokio::fs::OpenOptions::new()
326            .append(true)
327            .open(&path)
328            .await
329            .map_err(|e| {
330                if e.kind() == std::io::ErrorKind::NotFound {
331                    session_not_found(session_id)
332                } else {
333                    AgentError::IoError(format!("Failed to open session file: {e}"))
334                }
335            })?;
336        let mut line = serde_json::to_string(&entry).map_err(|e| {
337            AgentError::SerializationError(format!("Failed to serialize entry: {e}"))
338        })?;
339        line.push('\n');
340        file.write_all(line.as_bytes())
341            .await
342            .map_err(|e| AgentError::IoError(format!("Failed to append session entry: {e}")))?;
343        file.flush()
344            .await
345            .map_err(|e| AgentError::IoError(format!("Failed to flush session file: {e}")))?;
346        Ok(())
347    }
348
349    async fn load(&self, session_id: &str) -> Result<Vec<SessionEntry>, AgentError> {
350        let path = self.session_path(session_id);
351        let content = tokio::fs::read_to_string(&path).await.map_err(|e| {
352            if e.kind() == std::io::ErrorKind::NotFound {
353                session_not_found(session_id)
354            } else {
355                AgentError::IoError(format!("Failed to read session file: {e}"))
356            }
357        })?;
358        // Every successful append ends with a newline, so content after the
359        // last '\n' is a crash-truncated in-flight entry. Discard it and
360        // truncate the file so later appends stay line-aligned instead of
361        // fusing onto the fragment.
362        let complete_len = if content.ends_with('\n') {
363            content.len()
364        } else {
365            content.rfind('\n').map(|i| i + 1).unwrap_or(0)
366        };
367        if complete_len < content.len() {
368            log::warn!("Discarding crash-truncated final line in session {session_id}");
369            let file = tokio::fs::OpenOptions::new()
370                .write(true)
371                .open(&path)
372                .await
373                .map_err(|e| {
374                    AgentError::IoError(format!("Failed to open session file for repair: {e}"))
375                })?;
376            file.set_len(complete_len as u64).await.map_err(|e| {
377                AgentError::IoError(format!("Failed to truncate session file: {e}"))
378            })?;
379        }
380        let mut lines = content[..complete_len].lines();
381        let Some(header) = lines.next() else {
382            return Err(AgentError::JsonParseError(format!(
383                "Session file for {session_id} is empty (missing header line)"
384            )));
385        };
386        serde_json::from_str::<SessionMeta>(header).map_err(|e| {
387            AgentError::JsonParseError(format!("Invalid session header for {session_id}: {e}"))
388        })?;
389        let mut entries = Vec::new();
390        for (i, line) in lines.enumerate() {
391            let entry = serde_json::from_str::<SessionEntry>(line).map_err(|e| {
392                AgentError::JsonParseError(format!(
393                    "Invalid entry at line {} of session {session_id}: {e}",
394                    i + 2
395                ))
396            })?;
397            entries.push(entry);
398        }
399        Ok(entries)
400    }
401
402    async fn list(&self) -> Result<Vec<SessionMeta>, AgentError> {
403        let mut read_dir = match tokio::fs::read_dir(&self.dir).await {
404            Ok(rd) => rd,
405            // No directory yet simply means no sessions have been created.
406            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
407            Err(e) => {
408                return Err(AgentError::IoError(format!(
409                    "Failed to read session dir: {e}"
410                )));
411            }
412        };
413        let mut metas = Vec::new();
414        loop {
415            let entry = match read_dir.next_entry().await {
416                Ok(Some(entry)) => entry,
417                Ok(None) => break,
418                Err(e) => {
419                    return Err(AgentError::IoError(format!(
420                        "Failed to read session dir entry: {e}"
421                    )));
422                }
423            };
424            let path = entry.path();
425            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
426                continue;
427            }
428            // Session files grow without bound; only the header line is
429            // needed here, so never read the whole file.
430            let file = match tokio::fs::File::open(&path).await {
431                Ok(file) => file,
432                Err(e) => {
433                    log::warn!("Skipping unreadable session file {}: {e}", path.display());
434                    continue;
435                }
436            };
437            let mut header = String::new();
438            match tokio::io::BufReader::new(file).read_line(&mut header).await {
439                Ok(0) => {
440                    log::warn!("Skipping empty session file {}", path.display());
441                    continue;
442                }
443                Ok(_) => {}
444                Err(e) => {
445                    log::warn!("Skipping unreadable session file {}: {e}", path.display());
446                    continue;
447                }
448            }
449            match serde_json::from_str::<SessionMeta>(header.trim_end()) {
450                Ok(meta) => metas.push(meta),
451                Err(e) => {
452                    log::warn!(
453                        "Skipping session file with invalid header {}: {e}",
454                        path.display()
455                    );
456                }
457            }
458        }
459        Ok(metas)
460    }
461}
462
463/// Builds the LLM context from a session's entry log.
464///
465/// With no [`SessionEntry::Compaction`] entry, returns all Message entries'
466/// messages in append order.
467///
468/// Otherwise the *last* Compaction entry wins: the result starts with a user
469/// message whose text is `"[Conversation summary]\n"` followed by the
470/// compaction's `summary`, then contains every Message entry from the one
471/// whose entry id equals `first_kept_id` through the end of the log. If no
472/// Message entry matches `first_kept_id`, the summary message is instead
473/// followed by all Message entries positioned *after* that Compaction entry.
474///
475/// Entries before the cut are dropped from the returned context only — the
476/// store keeps the full history (invariant 2 of the [module docs](self)).
477pub fn build_context(entries: &[SessionEntry]) -> Vec<Message> {
478    let last_compaction = entries.iter().enumerate().rev().find_map(|(i, e)| match e {
479        SessionEntry::Compaction {
480            summary,
481            first_kept_id,
482            ..
483        } => Some((i, summary, first_kept_id)),
484        _ => None,
485    });
486
487    let Some((compaction_index, summary, first_kept_id)) = last_compaction else {
488        return entries.iter().filter_map(entry_message).collect();
489    };
490
491    let mut context = vec![Message::user(format!("[Conversation summary]\n{summary}"))];
492    let start = entries
493        .iter()
494        .position(|e| matches!(e, SessionEntry::Message { id, .. } if id == first_kept_id))
495        .unwrap_or(compaction_index + 1);
496    context.extend(entries[start..].iter().filter_map(entry_message));
497    context
498}
499
500fn entry_message(entry: &SessionEntry) -> Option<Message> {
501    match entry {
502        SessionEntry::Message { message, .. } => Some(message.clone()),
503        _ => None,
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    fn message_entry(id: &str, text: &str) -> SessionEntry {
512        SessionEntry::Message {
513            id: id.to_string(),
514            parent_id: None,
515            timestamp: now_rfc3339(),
516            message: Message::user(text.to_string()),
517        }
518    }
519
520    fn compaction_entry(id: &str, summary: &str, first_kept_id: &str) -> SessionEntry {
521        SessionEntry::Compaction {
522            id: id.to_string(),
523            parent_id: None,
524            timestamp: now_rfc3339(),
525            summary: summary.to_string(),
526            first_kept_id: first_kept_id.to_string(),
527            tokens_before: Some(1000),
528        }
529    }
530
531    // InMemorySessionStore tests
532
533    #[tokio::test]
534    async fn test_in_memory_round_trip() {
535        let store = InMemorySessionStore::new();
536        let meta = SessionMeta::new();
537        let id = store.create(meta.clone()).await.unwrap();
538        assert_eq!(id, meta.id);
539
540        let e1 = message_entry("e1", "hello");
541        let e2 = message_entry("e2", "world");
542        store.append(&id, e1.clone()).await.unwrap();
543        store.append(&id, e2.clone()).await.unwrap();
544
545        let entries = store.load(&id).await.unwrap();
546        assert_eq!(entries, vec![e1, e2]);
547
548        let metas = store.list().await.unwrap();
549        assert_eq!(metas, vec![meta]);
550    }
551
552    #[tokio::test]
553    async fn test_in_memory_duplicate_create_errors() {
554        let store = InMemorySessionStore::new();
555        let meta = SessionMeta::new();
556        store.create(meta.clone()).await.unwrap();
557        assert!(store.create(meta).await.is_err());
558    }
559
560    #[tokio::test]
561    async fn test_in_memory_unknown_session_errors() {
562        let store = InMemorySessionStore::new();
563        assert!(store.load("missing").await.is_err());
564        assert!(
565            store
566                .append("missing", message_entry("e1", "x"))
567                .await
568                .is_err()
569        );
570    }
571
572    // JsonlSessionStore tests
573
574    #[tokio::test]
575    async fn test_jsonl_replay_and_list_with_new_store() {
576        let dir = tempfile::tempdir().unwrap();
577        let store = JsonlSessionStore::new(dir.path());
578        let meta = SessionMeta::new();
579        let id = store.create(meta.clone()).await.unwrap();
580
581        let e1 = message_entry("e1", "hello");
582        let e2 = SessionEntry::compaction("summary".to_string(), "e1".to_string(), Some(42));
583        store.append(&id, e1.clone()).await.unwrap();
584        store.append(&id, e2.clone()).await.unwrap();
585
586        // A brand-new store over the same directory must replay identically.
587        let reopened = JsonlSessionStore::new(dir.path());
588        let entries = reopened.load(&id).await.unwrap();
589        assert_eq!(entries, vec![e1, e2]);
590
591        let metas = reopened.list().await.unwrap();
592        assert_eq!(metas, vec![meta]);
593    }
594
595    #[tokio::test]
596    async fn test_jsonl_duplicate_create_errors() {
597        let dir = tempfile::tempdir().unwrap();
598        let store = JsonlSessionStore::new(dir.path());
599        let meta = SessionMeta::new();
600        store.create(meta.clone()).await.unwrap();
601        assert!(store.create(meta).await.is_err());
602    }
603
604    #[tokio::test]
605    async fn test_jsonl_truncated_final_line_is_discarded_and_repaired() {
606        let dir = tempfile::tempdir().unwrap();
607        let store = JsonlSessionStore::new(dir.path());
608        let meta = SessionMeta::new();
609        let id = store.create(meta).await.unwrap();
610        let e1 = message_entry("e1", "hello");
611        store.append(&id, e1.clone()).await.unwrap();
612
613        // Simulate a crash-truncated tail.
614        let path = dir.path().join(format!("{id}.jsonl"));
615        let mut content = std::fs::read_to_string(&path).unwrap();
616        let intact_len = content.len();
617        content.push_str("{\"type\":\"message\",\"id\":\"e2");
618        std::fs::write(&path, content).unwrap();
619
620        let entries = store.load(&id).await.unwrap();
621        assert_eq!(entries, vec![e1.clone()]);
622
623        // load() truncated the partial tail away...
624        let repaired = std::fs::read_to_string(&path).unwrap();
625        assert_eq!(repaired.len(), intact_len);
626        assert!(repaired.ends_with('\n'));
627
628        // ...so appends after the resume do not fuse onto the fragment.
629        let e2 = message_entry("e2", "world");
630        store.append(&id, e2.clone()).await.unwrap();
631        let e3 = message_entry("e3", "again");
632        store.append(&id, e3.clone()).await.unwrap();
633        let entries = store.load(&id).await.unwrap();
634        assert_eq!(entries, vec![e1, e2, e3]);
635    }
636
637    #[tokio::test]
638    async fn test_jsonl_unparseable_complete_final_line_errors() {
639        let dir = tempfile::tempdir().unwrap();
640        let store = JsonlSessionStore::new(dir.path());
641        let meta = SessionMeta::new();
642        let id = store.create(meta).await.unwrap();
643        store
644            .append(&id, message_entry("e1", "hello"))
645            .await
646            .unwrap();
647
648        // A garbage line that ends with a newline is real corruption, not a
649        // crash-truncated tail, and must not be silently dropped.
650        let path = dir.path().join(format!("{id}.jsonl"));
651        let mut content = std::fs::read_to_string(&path).unwrap();
652        content.push_str("not json\n");
653        std::fs::write(&path, content).unwrap();
654
655        assert!(store.load(&id).await.is_err());
656    }
657
658    #[tokio::test]
659    async fn test_jsonl_unparseable_middle_line_errors() {
660        let dir = tempfile::tempdir().unwrap();
661        let store = JsonlSessionStore::new(dir.path());
662        let meta = SessionMeta::new();
663        let id = store.create(meta).await.unwrap();
664        store
665            .append(&id, message_entry("e1", "hello"))
666            .await
667            .unwrap();
668
669        let path = dir.path().join(format!("{id}.jsonl"));
670        let content = std::fs::read_to_string(&path).unwrap();
671        let lines: Vec<&str> = content.lines().collect();
672        // header, garbage, valid entry
673        let corrupted = format!("{}\nnot json\n{}\n", lines[0], lines[1]);
674        std::fs::write(&path, corrupted).unwrap();
675
676        assert!(store.load(&id).await.is_err());
677    }
678
679    #[tokio::test]
680    async fn test_jsonl_unknown_session_errors_and_empty_dir_lists_nothing() {
681        let dir = tempfile::tempdir().unwrap();
682        let store = JsonlSessionStore::new(dir.path().join("never-created"));
683        assert!(store.load("missing").await.is_err());
684        assert!(
685            store
686                .append("missing", message_entry("e1", "x"))
687                .await
688                .is_err()
689        );
690        assert_eq!(store.list().await.unwrap(), vec![]);
691    }
692
693    // build_context tests
694
695    #[test]
696    fn test_build_context_no_compaction() {
697        let entries = vec![message_entry("e1", "a"), message_entry("e2", "b")];
698        let context = build_context(&entries);
699        assert_eq!(context.len(), 2);
700        assert_eq!(context[0].text(), "a");
701        assert_eq!(context[1].text(), "b");
702    }
703
704    #[test]
705    fn test_build_context_with_compaction_drops_prefix() {
706        let entries = vec![
707            message_entry("e1", "old1"),
708            message_entry("e2", "old2"),
709            message_entry("e3", "kept"),
710            message_entry("e4", "tail"),
711            compaction_entry("c1", "the summary", "e3"),
712        ];
713        let context = build_context(&entries);
714        assert_eq!(context.len(), 3);
715        assert_eq!(context[0].role, "user");
716        assert_eq!(context[0].text(), "[Conversation summary]\nthe summary");
717        assert_eq!(context[1].text(), "kept");
718        assert_eq!(context[2].text(), "tail");
719    }
720
721    #[test]
722    fn test_build_context_last_compaction_wins() {
723        let entries = vec![
724            message_entry("e1", "a"),
725            compaction_entry("c1", "first summary", "e1"),
726            message_entry("e2", "b"),
727            message_entry("e3", "c"),
728            compaction_entry("c2", "second summary", "e3"),
729            message_entry("e4", "d"),
730        ];
731        let context = build_context(&entries);
732        assert_eq!(context.len(), 3);
733        assert_eq!(context[0].text(), "[Conversation summary]\nsecond summary");
734        assert_eq!(context[1].text(), "c");
735        assert_eq!(context[2].text(), "d");
736    }
737
738    #[test]
739    fn test_build_context_first_kept_id_not_found_falls_back() {
740        let entries = vec![
741            message_entry("e1", "old"),
742            compaction_entry("c1", "the summary", "nonexistent"),
743            message_entry("e2", "after1"),
744            message_entry("e3", "after2"),
745        ];
746        let context = build_context(&entries);
747        assert_eq!(context.len(), 3);
748        assert_eq!(context[0].text(), "[Conversation summary]\nthe summary");
749        assert_eq!(context[1].text(), "after1");
750        assert_eq!(context[2].text(), "after2");
751    }
752
753    // Serde tests
754
755    #[test]
756    fn test_session_entry_message_serde_round_trip() {
757        let entry = message_entry("e1", "hello");
758        let json = serde_json::to_value(&entry).unwrap();
759        assert_eq!(json["type"], serde_json::json!("message"));
760        // parent_id is None and must not emit a key.
761        assert!(json.get("parent_id").is_none());
762        let restored: SessionEntry = serde_json::from_value(json).unwrap();
763        assert_eq!(restored, entry);
764    }
765
766    #[test]
767    fn test_session_entry_compaction_serde_round_trip() {
768        let entry = compaction_entry("c1", "summary", "e1");
769        let json = serde_json::to_value(&entry).unwrap();
770        assert_eq!(json["type"], serde_json::json!("compaction"));
771        assert_eq!(json["tokens_before"], serde_json::json!(1000));
772        let restored: SessionEntry = serde_json::from_value(json).unwrap();
773        assert_eq!(restored, entry);
774    }
775
776    #[test]
777    fn test_session_entry_unknown_field_ignored() {
778        let json = serde_json::json!({
779            "type": "message",
780            "id": "e1",
781            "timestamp": "2026-07-19T00:00:00+00:00",
782            "message": { "role": "user", "content": "hi" },
783            "some_future_field": true,
784        });
785        let entry: SessionEntry = serde_json::from_value(json).unwrap();
786        assert_eq!(entry.id(), "e1");
787    }
788
789    #[test]
790    fn test_session_meta_serde_round_trip_and_forward_compat() {
791        let meta = SessionMeta::new();
792        let json = serde_json::to_value(&meta).unwrap();
793        // parent_session is None and must not emit a key.
794        assert!(json.get("parent_session").is_none());
795        let restored: SessionMeta = serde_json::from_value(json).unwrap();
796        assert_eq!(restored, meta);
797
798        // Unknown fields are ignored; missing optional fields default.
799        let json = serde_json::json!({
800            "id": "s1",
801            "some_future_field": "x",
802        });
803        let meta: SessionMeta = serde_json::from_value(json).unwrap();
804        assert_eq!(meta.id, "s1");
805        assert_eq!(meta.version, 1);
806        assert_eq!(meta.parent_session, None);
807        assert_eq!(meta.created_at, "");
808    }
809
810    #[test]
811    fn test_generated_ids_are_unique() {
812        let m1 = SessionMeta::new();
813        let m2 = SessionMeta::new();
814        assert_ne!(m1.id, m2.id);
815        let e1 = SessionEntry::message(Message::user("a".to_string()));
816        let e2 = SessionEntry::message(Message::user("a".to_string()));
817        assert_ne!(e1.id(), e2.id());
818    }
819}