//! 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(".storkit").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(".storkit").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:?}" ); } }