diff --git a/server/src/matrix/bot.rs b/server/src/matrix/bot.rs index f392044..786de48 100644 --- a/server/src/matrix/bot.rs +++ b/server/src/matrix/bot.rs @@ -888,6 +888,46 @@ async fn on_room_message( return; } + // Check for the start command, which requires async agent ops and cannot + // be handled by the sync command registry. + if let Some(start_cmd) = super::start::extract_start_command( + &user_message, + &ctx.bot_name, + ctx.bot_user_id.as_str(), + ) { + let response = match start_cmd { + super::start::StartCommand::Start { + story_number, + agent_hint, + } => { + slog!( + "[matrix-bot] Handling start command from {sender}: story {story_number} agent={agent_hint:?}" + ); + super::start::handle_start( + &ctx.bot_name, + &story_number, + agent_hint.as_deref(), + &ctx.project_root, + &ctx.agents, + ) + .await + } + super::start::StartCommand::BadArgs => { + format!( + "Usage: `{} start ` or `{} start opus`", + ctx.bot_name, ctx.bot_name + ) + } + }; + 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 { diff --git a/server/src/matrix/commands/mod.rs b/server/src/matrix/commands/mod.rs index 51ab0fc..23cdf1a 100644 --- a/server/src/matrix/commands/mod.rs +++ b/server/src/matrix/commands/mod.rs @@ -120,6 +120,11 @@ pub fn commands() -> &'static [BotCommand] { description: "Show implementation summary for a merged story: `overview `", handler: overview::handle_overview, }, + BotCommand { + name: "start", + description: "Start a coder on a story: `start ` or `start opus`", + handler: handle_start_fallback, + }, BotCommand { name: "delete", description: "Remove a work item from the pipeline: `delete `", @@ -221,6 +226,16 @@ fn handle_htop_fallback(_ctx: &CommandContext) -> Option { None } +/// Fallback handler for the `start` command when it is not intercepted by +/// the async handler in `on_room_message`. In practice this is never called — +/// start 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 "start" as a prompt. +fn handle_start_fallback(_ctx: &CommandContext) -> Option { + None +} + /// Fallback handler for the `delete` command when it is not intercepted by /// the async handler in `on_room_message`. In practice this is never called — /// delete is detected and handled before `try_handle_command` is invoked. diff --git a/server/src/matrix/mod.rs b/server/src/matrix/mod.rs index 7231ce7..433adfa 100644 --- a/server/src/matrix/mod.rs +++ b/server/src/matrix/mod.rs @@ -20,6 +20,7 @@ pub mod commands; mod config; pub mod delete; pub mod htop; +pub mod start; pub mod notifications; pub mod transport_impl; diff --git a/server/src/matrix/start.rs b/server/src/matrix/start.rs new file mode 100644 index 0000000..30a18ed --- /dev/null +++ b/server/src/matrix/start.rs @@ -0,0 +1,349 @@ +//! Start command: start a coder agent on a story. +//! +//! `{bot_name} start {number}` finds the story by number, selects the default +//! coder agent, and starts it. +//! +//! `{bot_name} start {number} opus` starts `coder-opus` (or any agent whose +//! name ends with the supplied hint, e.g. `coder-{hint}`). + +use crate::agents::AgentPool; +use std::path::Path; + +/// A parsed start command from a Matrix message body. +#[derive(Debug, PartialEq)] +pub enum StartCommand { + /// Start the story with this number using the (optional) agent hint. + Start { + story_number: String, + /// Optional agent name hint (e.g. `"opus"` → resolved to `"coder-opus"`). + agent_hint: Option, + }, + /// The user typed `start` but without a valid numeric argument. + BadArgs, +} + +/// Parse a start command from a raw Matrix message body. +/// +/// Strips the bot mention prefix and checks whether the first word is `start`. +/// Returns `None` when the message is not a start command at all. +pub fn extract_start_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, args) = match trimmed.split_once(char::is_whitespace) { + Some((c, a)) => (c, a.trim()), + None => (trimmed, ""), + }; + + if !cmd.eq_ignore_ascii_case("start") { + return None; + } + + // Split args into story number and optional agent hint. + let (number_str, hint_str) = match args.split_once(char::is_whitespace) { + Some((n, h)) => (n.trim(), h.trim()), + None => (args, ""), + }; + + if !number_str.is_empty() && number_str.chars().all(|c| c.is_ascii_digit()) { + let agent_hint = if hint_str.is_empty() { + None + } else { + Some(hint_str.to_string()) + }; + Some(StartCommand::Start { + story_number: number_str.to_string(), + agent_hint, + }) + } else { + Some(StartCommand::BadArgs) + } +} + +/// Handle a start command asynchronously. +/// +/// Finds the work item by `story_number` across all pipeline stages, resolves +/// the agent name from `agent_hint`, and calls `agents.start_agent`. +/// Returns a markdown-formatted response string. +pub async fn handle_start( + bot_name: &str, + story_number: &str, + agent_hint: Option<&str>, + project_root: &Path, + agents: &AgentPool, +) -> String { + const STAGES: &[&str] = &[ + "1_backlog", + "2_current", + "3_qa", + "4_merge", + "5_done", + "6_archived", + ]; + + // Find the story file across all pipeline stages. + let mut found: Option<(std::path::PathBuf, String)> = None; // (path, story_id) + 'outer: for stage in STAGES { + let dir = project_root.join(".story_kit").join("work").join(stage); + if !dir.exists() { + continue; + } + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + if let Some(stem) = path + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + { + let file_num = stem + .split('_') + .next() + .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) + .unwrap_or("") + .to_string(); + if file_num == story_number { + found = Some((path, stem)); + break 'outer; + } + } + } + } + } + + let (path, story_id) = match found { + Some(f) => f, + None => { + return format!( + "No story, bug, or spike with number **{story_number}** found." + ); + } + }; + + // Read the human-readable name from front matter for the response. + let story_name = std::fs::read_to_string(&path) + .ok() + .and_then(|contents| { + crate::io::story_metadata::parse_front_matter(&contents) + .ok() + .and_then(|m| m.name) + }) + .unwrap_or_else(|| story_id.clone()); + + // Resolve agent name: try "coder-{hint}" first, then the hint as-is. + let resolved_agent: Option = agent_hint.map(|hint| { + let with_prefix = format!("coder-{hint}"); + // We'll pass the prefixed form; start_agent validates against config. + // If coder- prefix is already there, don't double-prefix. + if hint.starts_with("coder-") { + hint.to_string() + } else { + with_prefix + } + }); + + crate::slog!( + "[matrix-bot] start command: starting story {story_id} with agent={resolved_agent:?} (bot={bot_name})" + ); + + match agents + .start_agent(project_root, &story_id, resolved_agent.as_deref(), None) + .await + { + Ok(info) => { + format!( + "Started **{story_name}** with agent **{}**.", + info.agent_name + ) + } + Err(e) => { + format!("Failed to start **{story_name}**: {e}") + } + } +} + +/// Strip the bot mention prefix from a raw Matrix message body. +/// +/// Mirrors the logic in `commands::strip_bot_mention` and `delete::strip_mention`. +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::*; + + // -- extract_start_command ----------------------------------------------- + + #[test] + fn extract_with_full_user_id() { + let cmd = + extract_start_command("@timmy:home.local start 331", "Timmy", "@timmy:home.local"); + assert_eq!( + cmd, + Some(StartCommand::Start { + story_number: "331".to_string(), + agent_hint: None + }) + ); + } + + #[test] + fn extract_with_display_name() { + let cmd = extract_start_command("Timmy start 42", "Timmy", "@timmy:home.local"); + assert_eq!( + cmd, + Some(StartCommand::Start { + story_number: "42".to_string(), + agent_hint: None + }) + ); + } + + #[test] + fn extract_with_localpart() { + let cmd = extract_start_command("@timmy start 7", "Timmy", "@timmy:home.local"); + assert_eq!( + cmd, + Some(StartCommand::Start { + story_number: "7".to_string(), + agent_hint: None + }) + ); + } + + #[test] + fn extract_with_agent_hint() { + let cmd = extract_start_command("Timmy start 331 opus", "Timmy", "@timmy:home.local"); + assert_eq!( + cmd, + Some(StartCommand::Start { + story_number: "331".to_string(), + agent_hint: Some("opus".to_string()) + }) + ); + } + + #[test] + fn extract_case_insensitive_command() { + let cmd = extract_start_command("Timmy START 99", "Timmy", "@timmy:home.local"); + assert_eq!( + cmd, + Some(StartCommand::Start { + story_number: "99".to_string(), + agent_hint: None + }) + ); + } + + #[test] + fn extract_no_args_is_bad_args() { + let cmd = extract_start_command("Timmy start", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, Some(StartCommand::BadArgs)); + } + + #[test] + fn extract_non_numeric_arg_is_bad_args() { + let cmd = extract_start_command("Timmy start foo", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, Some(StartCommand::BadArgs)); + } + + #[test] + fn extract_non_start_command_returns_none() { + let cmd = extract_start_command("Timmy help", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, None); + } + + // -- handle_start (integration-style, uses temp filesystem) -------------- + + #[tokio::test] + async fn handle_start_returns_not_found_for_unknown_number() { + let tmp = tempfile::tempdir().unwrap(); + let project_root = tmp.path(); + for stage in &["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"] { + std::fs::create_dir_all(project_root.join(".story_kit").join("work").join(stage)) + .unwrap(); + } + let agents = std::sync::Arc::new(crate::agents::AgentPool::new_test(3000)); + let response = handle_start("Timmy", "999", None, project_root, &agents).await; + assert!( + response.contains("No story") && response.contains("999"), + "unexpected response: {response}" + ); + } + + #[test] + fn start_command_is_registered() { + use crate::matrix::commands::commands; + let found = commands().iter().any(|c| c.name == "start"); + assert!(found, "start command must be in the registry"); + } + + #[test] + fn start_command_appears_in_help() { + let result = crate::matrix::commands::tests::try_cmd_addressed( + "Timmy", + "@timmy:homeserver.local", + "@timmy help", + ); + let output = result.unwrap(); + assert!( + output.contains("start"), + "help should list start command: {output}" + ); + } + + #[test] + fn start_command_falls_through_to_none_in_registry() { + // The start handler in the registry returns None (handled async in bot.rs). + let result = crate::matrix::commands::tests::try_cmd_addressed( + "Timmy", + "@timmy:homeserver.local", + "@timmy start 42", + ); + assert!( + result.is_none(), + "start should not produce a sync response (handled async): {result:?}" + ); + } +}