story-kit: merge 351_story_bot_reset_command_to_clear_conversation_context
This commit is contained in:
@@ -928,6 +928,32 @@ async fn on_room_message(
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for the reset command, which requires async access to the shared
|
||||
// conversation history and cannot be handled by the sync command registry.
|
||||
if super::reset::extract_reset_command(
|
||||
&user_message,
|
||||
&ctx.bot_name,
|
||||
ctx.bot_user_id.as_str(),
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
slog!("[matrix-bot] Handling reset command from {sender}");
|
||||
let response = super::reset::handle_reset(
|
||||
&ctx.bot_name,
|
||||
&incoming_room_id,
|
||||
&ctx.history,
|
||||
&ctx.project_root,
|
||||
)
|
||||
.await;
|
||||
let html = markdown_to_html(&response);
|
||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Spawn a separate task so the Matrix sync loop is not blocked while we
|
||||
// wait for the LLM response (which can take several seconds).
|
||||
tokio::spawn(async move {
|
||||
|
||||
@@ -136,6 +136,11 @@ pub fn commands() -> &'static [BotCommand] {
|
||||
description: "Remove a work item from the pipeline: `delete <number>`",
|
||||
handler: handle_delete_fallback,
|
||||
},
|
||||
BotCommand {
|
||||
name: "reset",
|
||||
description: "Clear the current Claude Code session and start fresh",
|
||||
handler: handle_reset_fallback,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -252,6 +257,16 @@ fn handle_delete_fallback(_ctx: &CommandContext) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Fallback handler for the `reset` command when it is not intercepted by
|
||||
/// the async handler in `on_room_message`. In practice this is never called —
|
||||
/// reset is detected and handled before `try_handle_command` is invoked.
|
||||
/// The entry exists in the registry only so `help` lists it.
|
||||
///
|
||||
/// Returns `None` to prevent the LLM from receiving "reset" as a prompt.
|
||||
fn handle_reset_fallback(_ctx: &CommandContext) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod commands;
|
||||
mod config;
|
||||
pub mod delete;
|
||||
pub mod htop;
|
||||
pub mod reset;
|
||||
pub mod start;
|
||||
pub mod notifications;
|
||||
pub mod transport_impl;
|
||||
|
||||
170
server/src/matrix/reset.rs
Normal file
170
server/src/matrix/reset.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user