Files
storkit/server/src/matrix/reset.rs

171 lines
5.7 KiB
Rust

//! 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<ResetCommand> {
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");
}
}