//! Reset command: clear the current Claude Code session for a room. //! //! `{bot_name} reset` drops the stored session ID and conversation history for //! the current room so the next message starts a brand-new Claude Code session //! with clean context. File-system memories (auto-memory directory) are not //! affected — only the in-memory/persisted conversation state is cleared. use crate::matrix::bot::{ConversationHistory, RoomConversation}; use matrix_sdk::ruma::OwnedRoomId; use std::path::Path; /// A parsed reset command. #[derive(Debug, PartialEq)] pub struct ResetCommand; /// Parse a reset command from a raw message body. /// /// Strips the bot mention prefix and checks whether the command word is /// `reset`. Returns `None` when the message is not a reset command at all. pub fn extract_reset_command( message: &str, bot_name: &str, bot_user_id: &str, ) -> Option { let stripped = strip_mention(message, bot_name, bot_user_id); let trimmed = stripped .trim() .trim_start_matches(|c: char| !c.is_alphanumeric()); let cmd = match trimmed.split_once(char::is_whitespace) { Some((c, _)) => c, None => trimmed, }; if cmd.eq_ignore_ascii_case("reset") { Some(ResetCommand) } else { None } } /// Handle a reset command: clear the session ID and conversation entries for /// the given room, persist the updated history, and return a confirmation. pub async fn handle_reset( bot_name: &str, room_id: &OwnedRoomId, history: &ConversationHistory, project_root: &Path, ) -> String { { let mut guard = history.lock().await; let conv = guard.entry(room_id.clone()).or_insert_with(RoomConversation::default); conv.session_id = None; conv.entries.clear(); crate::matrix::bot::save_history(project_root, &guard); } crate::slog!("[matrix-bot] reset command: cleared session for room {room_id} (bot={bot_name})"); "Session reset. Starting fresh — previous context has been cleared.".to_string() } /// Strip the bot mention prefix from a raw Matrix message body. fn strip_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str { let trimmed = message.trim(); if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) { return rest; } if let Some(localpart) = bot_user_id.split(':').next() && let Some(rest) = strip_prefix_ci(trimmed, localpart) { return rest; } if let Some(rest) = strip_prefix_ci(trimmed, bot_name) { return rest; } trimmed } fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> { if text.len() < prefix.len() { return None; } if !text[..prefix.len()].eq_ignore_ascii_case(prefix) { return None; } let rest = &text[prefix.len()..]; match rest.chars().next() { None => Some(rest), Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None, _ => Some(rest), } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn extract_with_display_name() { let cmd = extract_reset_command("Timmy reset", "Timmy", "@timmy:home.local"); assert_eq!(cmd, Some(ResetCommand)); } #[test] fn extract_with_full_user_id() { let cmd = extract_reset_command("@timmy:home.local reset", "Timmy", "@timmy:home.local"); assert_eq!(cmd, Some(ResetCommand)); } #[test] fn extract_with_localpart() { let cmd = extract_reset_command("@timmy reset", "Timmy", "@timmy:home.local"); assert_eq!(cmd, Some(ResetCommand)); } #[test] fn extract_case_insensitive() { let cmd = extract_reset_command("Timmy RESET", "Timmy", "@timmy:home.local"); assert_eq!(cmd, Some(ResetCommand)); } #[test] fn extract_non_reset_returns_none() { let cmd = extract_reset_command("Timmy help", "Timmy", "@timmy:home.local"); assert_eq!(cmd, None); } #[test] fn extract_ignores_extra_args() { // "reset" with trailing text is still a reset command let cmd = extract_reset_command("Timmy reset everything", "Timmy", "@timmy:home.local"); assert_eq!(cmd, Some(ResetCommand)); } #[tokio::test] async fn handle_reset_clears_session_and_entries() { use crate::matrix::bot::{ConversationEntry, ConversationRole}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex as TokioMutex; let room_id: OwnedRoomId = "!test:example.com".parse().unwrap(); let history: ConversationHistory = Arc::new(TokioMutex::new({ let mut m = HashMap::new(); m.insert(room_id.clone(), RoomConversation { session_id: Some("old-session-id".to_string()), entries: vec![ConversationEntry { role: ConversationRole::User, sender: "@alice:example.com".to_string(), content: "previous message".to_string(), }], }); m })); let tmp = tempfile::tempdir().unwrap(); let response = handle_reset("Timmy", &room_id, &history, tmp.path()).await; assert!(response.contains("reset"), "response should mention reset: {response}"); let guard = history.lock().await; let conv = guard.get(&room_id).unwrap(); assert!(conv.session_id.is_none(), "session_id should be cleared"); assert!(conv.entries.is_empty(), "entries should be cleared"); } }