//! Matrix conversation history — per-room message history for LLM context. use crate::chat::history::{load_chat_history, save_chat_history}; use matrix_sdk::ruma::OwnedRoomId; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex as TokioMutex; /// Role of a participant in the conversation history. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ConversationRole { /// A message sent by a Matrix room participant. User, /// A response generated by the bot / LLM. Assistant, } /// A single turn in the per-room conversation history. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ConversationEntry { pub role: ConversationRole, /// Matrix user ID (e.g. `@alice:example.com`). Empty for assistant turns. pub sender: String, pub content: String, } /// Per-room state: conversation entries plus the Claude Code session ID for /// structured conversation resumption. #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct RoomConversation { /// Claude Code session ID used to resume multi-turn conversations so the /// LLM receives prior turns as structured API messages rather than a /// flattened text prefix. #[serde(skip_serializing_if = "Option::is_none")] pub session_id: Option, /// Rolling conversation entries (used for turn counting and persistence). pub entries: Vec, /// A distilled digest produced by the `compact` command, waiting to be /// injected as background context into the next spawned session's /// prompt. Cleared immediately after the first turn that uses it, so it /// is never re-injected. #[serde(skip_serializing_if = "Option::is_none")] pub pending_seed: Option, /// Timestamp (ms since Unix epoch) of the last time this room was sent a /// "consider running `compact`" suggestion, used to rate-limit repeat /// suggestions. #[serde(skip_serializing_if = "Option::is_none")] pub last_compact_suggested_at_ms: Option, } /// Per-room conversation state, keyed by room ID (serialised as string). /// /// Wrapped in `Arc>` so it can be shared across concurrent /// event-handler tasks without blocking the sync loop. pub type ConversationHistory = Arc>>; /// Path to the persisted conversation history file relative to project root. pub(super) const HISTORY_FILE: &str = ".huskies/matrix_history.json"; /// Load conversation history from disk, returning an empty map on any error. pub fn load_history(project_root: &std::path::Path) -> HashMap { let string_map = load_chat_history(project_root, HISTORY_FILE, "matrix-bot"); string_map .into_iter() .filter_map(|(k, v)| k.parse::().ok().map(|room_id| (room_id, v))) .collect() } /// Save conversation history to disk. Errors are logged but not propagated. pub fn save_history( project_root: &std::path::Path, history: &HashMap, ) { let string_map: HashMap = history .iter() .map(|(k, v)| (k.to_string(), v.clone())) .collect(); save_chat_history(project_root, HISTORY_FILE, "matrix-bot", &string_map); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn history_trims_to_configured_size() { let history: ConversationHistory = Arc::new(TokioMutex::new(HashMap::new())); let room_id: OwnedRoomId = "!test:example.com".parse().unwrap(); let history_size = 4usize; // keep at most 4 entries // Add 6 entries (3 user + 3 assistant turns). { let mut guard = history.lock().await; let conv = guard.entry(room_id.clone()).or_default(); conv.session_id = Some("test-session".to_string()); for i in 0..3usize { conv.entries.push(ConversationEntry { role: ConversationRole::User, sender: "@user:example.com".to_string(), content: format!("msg {i}"), }); conv.entries.push(ConversationEntry { role: ConversationRole::Assistant, sender: String::new(), content: format!("reply {i}"), }); if conv.entries.len() > history_size { let excess = conv.entries.len() - history_size; conv.entries.drain(..excess); conv.session_id = None; } } } let guard = history.lock().await; let conv = guard.get(&room_id).unwrap(); assert_eq!( conv.entries.len(), history_size, "history must be trimmed to history_size" ); // The oldest entries (msg 0 / reply 0) should have been dropped. assert!( conv.entries.iter().all(|e| !e.content.contains("msg 0")), "oldest entries must be dropped" ); // Session ID must be cleared when trimming occurs. assert!( conv.session_id.is_none(), "session_id must be cleared on trim to start a fresh session" ); } #[tokio::test] async fn each_room_has_independent_history() { let history: ConversationHistory = Arc::new(TokioMutex::new(HashMap::new())); let room_a: OwnedRoomId = "!room_a:example.com".parse().unwrap(); let room_b: OwnedRoomId = "!room_b:example.com".parse().unwrap(); { let mut guard = history.lock().await; guard .entry(room_a.clone()) .or_default() .entries .push(ConversationEntry { role: ConversationRole::User, sender: "@alice:example.com".to_string(), content: "Room A message".to_string(), }); guard .entry(room_b.clone()) .or_default() .entries .push(ConversationEntry { role: ConversationRole::User, sender: "@bob:example.com".to_string(), content: "Room B message".to_string(), }); } let guard = history.lock().await; let conv_a = guard.get(&room_a).unwrap(); let conv_b = guard.get(&room_b).unwrap(); assert_eq!(conv_a.entries.len(), 1); assert_eq!(conv_b.entries.len(), 1); assert_eq!(conv_a.entries[0].content, "Room A message"); assert_eq!(conv_b.entries[0].content, "Room B message"); } #[test] fn save_and_load_history_round_trip() { let dir = tempfile::tempdir().unwrap(); let story_kit_dir = dir.path().join(".huskies"); std::fs::create_dir_all(&story_kit_dir).unwrap(); let room_id: OwnedRoomId = "!persist:example.com".parse().unwrap(); let mut map: HashMap = HashMap::new(); let conv = map.entry(room_id.clone()).or_default(); conv.session_id = Some("session-abc".to_string()); conv.entries.push(ConversationEntry { role: ConversationRole::User, sender: "@alice:example.com".to_string(), content: "hello".to_string(), }); conv.entries.push(ConversationEntry { role: ConversationRole::Assistant, sender: String::new(), content: "hi there!".to_string(), }); save_history(dir.path(), &map); let loaded = load_history(dir.path()); let loaded_conv = loaded.get(&room_id).expect("room must exist after load"); assert_eq!(loaded_conv.session_id.as_deref(), Some("session-abc")); assert_eq!(loaded_conv.entries.len(), 2); assert_eq!(loaded_conv.entries[0].role, ConversationRole::User); assert_eq!(loaded_conv.entries[0].sender, "@alice:example.com"); assert_eq!(loaded_conv.entries[0].content, "hello"); assert_eq!(loaded_conv.entries[1].role, ConversationRole::Assistant); assert_eq!(loaded_conv.entries[1].content, "hi there!"); } #[test] fn load_history_returns_empty_on_missing_file() { let dir = tempfile::tempdir().unwrap(); let loaded = load_history(dir.path()); assert!(loaded.is_empty()); } #[test] fn load_history_returns_empty_on_corrupt_file() { let dir = tempfile::tempdir().unwrap(); let story_kit_dir = dir.path().join(".huskies"); std::fs::create_dir_all(&story_kit_dir).unwrap(); std::fs::write(dir.path().join(HISTORY_FILE), "not valid json").unwrap(); let loaded = load_history(dir.path()); assert!(loaded.is_empty()); } #[tokio::test] async fn session_id_preserved_within_history_size() { let history: ConversationHistory = Arc::new(TokioMutex::new(HashMap::new())); let room_id: OwnedRoomId = "!session:example.com".parse().unwrap(); { let mut guard = history.lock().await; let conv = guard.entry(room_id.clone()).or_default(); conv.session_id = Some("sess-1".to_string()); conv.entries.push(ConversationEntry { role: ConversationRole::User, sender: "@alice:example.com".to_string(), content: "hello".to_string(), }); conv.entries.push(ConversationEntry { role: ConversationRole::Assistant, sender: String::new(), content: "hi".to_string(), }); // No trimming needed (2 entries, well under any reasonable limit). } let guard = history.lock().await; let conv = guard.get(&room_id).unwrap(); assert_eq!( conv.session_id.as_deref(), Some("sess-1"), "session_id must be preserved when no trimming occurs" ); } #[tokio::test] async fn multi_user_entries_preserve_sender() { let history: ConversationHistory = Arc::new(TokioMutex::new(HashMap::new())); let room_id: OwnedRoomId = "!multi:example.com".parse().unwrap(); { let mut guard = history.lock().await; let conv = guard.entry(room_id.clone()).or_default(); conv.entries.push(ConversationEntry { role: ConversationRole::User, sender: "@alice:example.com".to_string(), content: "from alice".to_string(), }); conv.entries.push(ConversationEntry { role: ConversationRole::Assistant, sender: String::new(), content: "reply to alice".to_string(), }); conv.entries.push(ConversationEntry { role: ConversationRole::User, sender: "@bob:example.com".to_string(), content: "from bob".to_string(), }); } let guard = history.lock().await; let conv = guard.get(&room_id).unwrap(); assert_eq!(conv.entries[0].sender, "@alice:example.com"); assert_eq!(conv.entries[2].sender, "@bob:example.com"); } }