1#![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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct SessionMeta {
60 pub id: String,
62
63 #[serde(default = "default_version")]
65 pub version: u32,
66
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub parent_session: Option<String>,
70
71 #[serde(default)]
73 pub created_at: String,
74}
75
76impl SessionMeta {
77 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101#[serde(tag = "type", rename_all = "snake_case")]
102pub enum SessionEntry {
103 Message {
105 id: String,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 parent_id: Option<String>,
110 timestamp: String,
112 message: Message,
114 },
115
116 Compaction {
119 id: String,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 parent_id: Option<String>,
124 timestamp: String,
126 summary: String,
128 first_kept_id: String,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 tokens_before: Option<u64>,
134 },
135}
136
137impl SessionEntry {
138 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 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 pub fn id(&self) -> &str {
164 match self {
165 SessionEntry::Message { id, .. } => id,
166 SessionEntry::Compaction { id, .. } => id,
167 }
168 }
169}
170
171#[async_trait]
177pub trait SessionStore: Send + Sync {
178 async fn create(&self, meta: SessionMeta) -> Result<String, AgentError>;
182
183 async fn append(&self, session_id: &str, entry: SessionEntry) -> Result<(), AgentError>;
189
190 async fn load(&self, session_id: &str) -> Result<Vec<SessionEntry>, AgentError>;
192
193 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#[derive(Debug, Default)]
208pub struct InMemorySessionStore {
209 sessions: Mutex<SessionMap>,
210}
211
212impl InMemorySessionStore {
213 pub fn new() -> Self {
215 Self::default()
216 }
217
218 fn lock(&self) -> std::sync::MutexGuard<'_, SessionMap> {
219 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#[derive(Debug, Clone)]
273pub struct JsonlSessionStore {
274 dir: PathBuf,
275}
276
277impl JsonlSessionStore {
278 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 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 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 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 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
463pub 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 #[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 #[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 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 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 let repaired = std::fs::read_to_string(&path).unwrap();
625 assert_eq!(repaired.len(), intact_len);
626 assert!(repaired.ends_with('\n'));
627
628 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 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 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 #[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 #[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 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 assert!(json.get("parent_session").is_none());
795 let restored: SessionMeta = serde_json::from_value(json).unwrap();
796 assert_eq!(restored, meta);
797
798 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}