//! Bot-level command registry for the Matrix bot. //! //! Commands registered here are handled directly by the bot without invoking //! the LLM. The registry is the single source of truth — the `help` command //! iterates it automatically so new commands appear in the help output as soon //! as they are added. use crate::agents::{AgentPool, AgentStatus}; use crate::config::ProjectConfig; use matrix_sdk::ruma::OwnedRoomId; use std::collections::{HashMap, HashSet}; use std::path::Path; use std::sync::{Arc, Mutex}; use super::config::save_ambient_rooms; /// A bot-level command that is handled without LLM invocation. pub struct BotCommand { /// The command keyword (e.g., `"help"`). Always lowercase. pub name: &'static str, /// Short description shown in help output. pub description: &'static str, /// Handler that produces the response text (Markdown), or `None` to fall /// through to the LLM (e.g. when a command requires direct addressing but /// the message arrived via ambient mode). pub handler: fn(&CommandContext) -> Option, } /// Dispatch parameters passed to `try_handle_command`. /// /// Groups all the caller-supplied context needed to dispatch and execute bot /// commands. Construct one per incoming message and pass it alongside the raw /// message body. pub struct CommandDispatch<'a> { /// The bot's display name (e.g., "Timmy"). pub bot_name: &'a str, /// The bot's full Matrix user ID (e.g., `"@timmy:homeserver.local"`). pub bot_user_id: &'a str, /// Project root directory (needed by status, ambient). pub project_root: &'a Path, /// Agent pool (needed by status). pub agents: &'a AgentPool, /// Set of rooms with ambient mode enabled (needed by ambient). pub ambient_rooms: &'a Arc>>, /// The room this message came from (needed by ambient). pub room_id: &'a OwnedRoomId, /// Whether the message directly addressed the bot (mention/reply). /// Some commands (e.g. ambient) only operate when directly addressed. pub is_addressed: bool, } /// Context passed to individual command handlers. pub struct CommandContext<'a> { /// The bot's display name (e.g., "Timmy"). pub bot_name: &'a str, /// Any text after the command keyword, trimmed. pub args: &'a str, /// Project root directory (needed by status, ambient). pub project_root: &'a Path, /// Agent pool (needed by status). pub agents: &'a AgentPool, /// Set of rooms with ambient mode enabled (needed by ambient). pub ambient_rooms: &'a Arc>>, /// The room this message came from (needed by ambient). pub room_id: &'a OwnedRoomId, /// Whether the message directly addressed the bot (mention/reply). /// Some commands (e.g. ambient) only operate when directly addressed. pub is_addressed: bool, } /// Returns the full list of registered bot commands. /// /// Add new commands here — they will automatically appear in `help` output. pub fn commands() -> &'static [BotCommand] { &[ BotCommand { name: "help", description: "Show this list of available commands", handler: handle_help, }, BotCommand { name: "status", description: "Show pipeline status and agent availability", handler: handle_status, }, BotCommand { name: "ambient", description: "Toggle ambient mode for this room: `ambient on` or `ambient off`", handler: handle_ambient, }, BotCommand { name: "git", description: "Show git status: branch, uncommitted changes, and ahead/behind remote", handler: handle_git, }, BotCommand { name: "htop", description: "Show live system and agent process dashboard (`htop`, `htop 10m`, `htop stop`)", handler: handle_htop_fallback, }, BotCommand { name: "cost", description: "Show token spend: 24h total, top stories, breakdown by agent type, and all-time total", handler: handle_cost, }, ] } /// Try to match a user message against a registered bot command. /// /// The message is expected to be the raw body text from Matrix (e.g., /// `"@timmy help"`). The bot mention prefix is stripped before matching. /// /// Returns `Some(response)` if a command matched and was handled, `None` /// otherwise (the caller should fall through to the LLM). pub fn try_handle_command(dispatch: &CommandDispatch<'_>, message: &str) -> Option { let command_text = strip_bot_mention(message, dispatch.bot_name, dispatch.bot_user_id); let trimmed = command_text.trim(); if trimmed.is_empty() { return None; } let (cmd_name, args) = match trimmed.split_once(char::is_whitespace) { Some((c, a)) => (c, a.trim()), None => (trimmed, ""), }; let cmd_lower = cmd_name.to_ascii_lowercase(); let ctx = CommandContext { bot_name: dispatch.bot_name, args, project_root: dispatch.project_root, agents: dispatch.agents, ambient_rooms: dispatch.ambient_rooms, room_id: dispatch.room_id, is_addressed: dispatch.is_addressed, }; commands() .iter() .find(|c| c.name == cmd_lower) .and_then(|c| (c.handler)(&ctx)) } /// Strip the bot mention prefix from a raw message body. /// /// Handles these forms (case-insensitive where applicable): /// - `@bot_localpart:server.com rest` → `rest` /// - `@bot_localpart rest` → `rest` /// - `DisplayName rest` → `rest` fn strip_bot_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str { let trimmed = message.trim(); // Try full Matrix user ID (e.g. "@timmy:homeserver.local") if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) { return rest; } // Try @localpart (e.g. "@timmy") if let Some(localpart) = bot_user_id.split(':').next() && let Some(rest) = strip_prefix_ci(trimmed, localpart) { return rest; } // Try display name (e.g. "Timmy") if let Some(rest) = strip_prefix_ci(trimmed, bot_name) { return rest; } trimmed } /// Case-insensitive prefix strip that also requires the match to end at a /// word boundary (whitespace, punctuation, or end-of-string). 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()..]; // Must be at end or followed by non-alphanumeric match rest.chars().next() { None => Some(rest), // exact match, empty remainder Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None, // not a word boundary _ => Some(rest), } } // --------------------------------------------------------------------------- // Pipeline status helpers (moved from bot.rs) // --------------------------------------------------------------------------- /// Format a short display label for a work item. /// /// Extracts the leading numeric ID from the file stem (e.g. `"293"` from /// `"293_story_register_all_bot_commands"`) and combines it with the human- /// readable name from the front matter when available. /// /// Examples: /// - `("293_story_foo", Some("Register all bot commands"))` → `"293 — Register all bot commands"` /// - `("293_story_foo", None)` → `"293"` /// - `("no_number_here", None)` → `"no_number_here"` fn story_short_label(stem: &str, name: Option<&str>) -> String { let number = stem .split('_') .next() .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) .unwrap_or(stem); match name { Some(n) => format!("{number} — {n}"), None => number.to_string(), } } /// Read all story IDs and names from a pipeline stage directory. fn read_stage_items( project_root: &std::path::Path, stage_dir: &str, ) -> Vec<(String, Option)> { let dir = project_root .join(".story_kit") .join("work") .join(stage_dir); if !dir.exists() { return Vec::new(); } let mut items = Vec::new(); 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()) { let 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) }); items.push((stem.to_string(), name)); } } } items.sort_by(|a, b| a.0.cmp(&b.0)); items } /// Build the full pipeline status text formatted for Matrix (markdown). pub fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) -> String { // Build a map from story_id → active AgentInfo for quick lookup. let active_agents = agents.list_agents().unwrap_or_default(); let active_map: std::collections::HashMap = active_agents .iter() .filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending)) .map(|a| (a.story_id.clone(), a)) .collect(); let config = ProjectConfig::load(project_root).ok(); let mut out = String::from("**Pipeline Status**\n\n"); let stages = [ ("1_backlog", "Backlog"), ("2_current", "In Progress"), ("3_qa", "QA"), ("4_merge", "Merge"), ("5_done", "Done"), ]; for (dir, label) in &stages { let items = read_stage_items(project_root, dir); let count = items.len(); out.push_str(&format!("**{label}** ({count})\n")); if items.is_empty() { out.push_str(" *(none)*\n"); } else { for (story_id, name) in &items { let display = story_short_label(story_id, name.as_deref()); if let Some(agent) = active_map.get(story_id) { let model_str = config .as_ref() .and_then(|cfg| cfg.find_agent(&agent.agent_name)) .and_then(|ac| ac.model.as_deref()) .unwrap_or("?"); out.push_str(&format!( " • {display} — {} ({model_str})\n", agent.agent_name )); } else { out.push_str(&format!(" • {display}\n")); } } } out.push('\n'); } // Free agents: configured agents not currently running or pending. out.push_str("**Free Agents**\n"); if let Some(cfg) = &config { let busy_names: std::collections::HashSet = active_agents .iter() .filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending)) .map(|a| a.agent_name.clone()) .collect(); let free: Vec = cfg .agent .iter() .filter(|a| !busy_names.contains(&a.name)) .map(|a| match &a.model { Some(m) => format!("{} ({})", a.name, m), None => a.name.clone(), }) .collect(); if free.is_empty() { out.push_str(" *(none — all agents busy)*\n"); } else { for name in &free { out.push_str(&format!(" • {name}\n")); } } } else { out.push_str(" *(no agent config found)*\n"); } out } // --------------------------------------------------------------------------- // Built-in command handlers // --------------------------------------------------------------------------- fn handle_help(ctx: &CommandContext) -> Option { let mut output = format!("**{} Commands**\n\n", ctx.bot_name); for cmd in commands() { output.push_str(&format!("- **{}** — {}\n", cmd.name, cmd.description)); } Some(output) } fn handle_status(ctx: &CommandContext) -> Option { Some(build_pipeline_status(ctx.project_root, ctx.agents)) } /// Toggle ambient mode for this room. /// /// Only acts when the message directly addressed the bot (`is_addressed=true`) /// to prevent accidental toggling via ambient-mode traffic. fn handle_ambient(ctx: &CommandContext) -> Option { if !ctx.is_addressed { return None; } let enable = match ctx.args { "on" => true, "off" => false, _ => return Some("Usage: `ambient on` or `ambient off`".to_string()), }; let room_ids: Vec = { let mut ambient = ctx.ambient_rooms.lock().unwrap(); if enable { ambient.insert(ctx.room_id.clone()); } else { ambient.remove(ctx.room_id); } ambient.iter().map(|r| r.to_string()).collect() }; save_ambient_rooms(ctx.project_root, &room_ids); let msg = if enable { "Ambient mode on. I'll respond to all messages in this room." } else { "Ambient mode off. I'll only respond when mentioned." }; Some(msg.to_string()) } /// Show compact git status: branch, uncommitted files, ahead/behind remote. fn handle_git(ctx: &CommandContext) -> Option { use std::process::Command; // Current branch let branch = Command::new("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) .current_dir(ctx.project_root) .output() .ok() .filter(|o| o.status.success()) .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .unwrap_or_else(|| "unknown".to_string()); // Porcelain status for staged + unstaged changes let status_output = Command::new("git") .args(["status", "--porcelain"]) .current_dir(ctx.project_root) .output() .ok() .filter(|o| o.status.success()) .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) .unwrap_or_default(); let changed_files: Vec<&str> = status_output.lines().filter(|l| !l.is_empty()).collect(); let change_count = changed_files.len(); // Ahead/behind: --left-right gives "N\tM" (ahead\tbehind) let ahead_behind = Command::new("git") .args(["rev-list", "--count", "--left-right", "HEAD...@{u}"]) .current_dir(ctx.project_root) .output() .ok() .filter(|o| o.status.success()) .and_then(|o| { let s = String::from_utf8_lossy(&o.stdout).trim().to_string(); let mut parts = s.split_whitespace(); let ahead: u32 = parts.next()?.parse().ok()?; let behind: u32 = parts.next()?.parse().ok()?; Some((ahead, behind)) }); let mut out = format!("**Branch:** `{branch}`\n"); if change_count == 0 { out.push_str("**Changes:** clean\n"); } else { out.push_str(&format!("**Changes:** {change_count} file(s)\n")); for line in &changed_files { // Porcelain format: "XY filename" (2-char status + space + path) if line.len() > 3 { let codes = &line[..2]; let name = line[3..].trim(); out.push_str(&format!(" • `{codes}` {name}\n")); } else { out.push_str(&format!(" • {line}\n")); } } } match ahead_behind { Some((0, 0)) => out.push_str("**Remote:** up to date\n"), Some((ahead, 0)) => out.push_str(&format!("**Remote:** ↑{ahead} ahead\n")), Some((0, behind)) => out.push_str(&format!("**Remote:** ↓{behind} behind\n")), Some((ahead, behind)) => { out.push_str(&format!("**Remote:** ↑{ahead} ahead, ↓{behind} behind\n")); } None => out.push_str("**Remote:** no tracking branch\n"), } Some(out) } /// Fallback handler for the `htop` command when it is not intercepted by the /// async handler in `on_room_message`. In practice this is never called — /// htop 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 "htop" as a prompt. fn handle_htop_fallback(_ctx: &CommandContext) -> Option { None } /// Show token spend: 24h total, top 5 stories, agent-type breakdown, and /// all-time total. fn handle_cost(ctx: &CommandContext) -> Option { let records = match crate::agents::token_usage::read_all(ctx.project_root) { Ok(r) => r, Err(e) => return Some(format!("Failed to read token usage: {e}")), }; if records.is_empty() { return Some("**Token Spend**\n\nNo usage records found.".to_string()); } let now = chrono::Utc::now(); let cutoff = now - chrono::Duration::hours(24); // Partition into 24h window and all-time let mut recent = Vec::new(); let mut all_time_cost = 0.0; for r in &records { all_time_cost += r.usage.total_cost_usd; if let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&r.timestamp) && ts >= cutoff { recent.push(r); } } // 24h total let recent_cost: f64 = recent.iter().map(|r| r.usage.total_cost_usd).sum(); let mut out = String::from("**Token Spend**\n\n"); out.push_str(&format!("**Last 24h:** ${:.2}\n", recent_cost)); out.push_str(&format!("**All-time:** ${:.2}\n\n", all_time_cost)); // Top 5 most expensive stories (last 24h) let mut story_costs: HashMap<&str, f64> = HashMap::new(); for r in &recent { *story_costs.entry(r.story_id.as_str()).or_default() += r.usage.total_cost_usd; } let mut story_list: Vec<(&str, f64)> = story_costs.into_iter().collect(); story_list.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); story_list.truncate(5); out.push_str("**Top Stories (24h)**\n"); if story_list.is_empty() { out.push_str(" *(none)*\n"); } else { for (story_id, cost) in &story_list { let label = story_short_label(story_id, None); out.push_str(&format!(" • {label} — ${cost:.2}\n")); } } out.push('\n'); // Breakdown by agent type (last 24h) // Agent names follow pattern "coder-1", "qa-1", "mergemaster" — extract // the type as everything before the last '-' digit, or the full name. let mut type_costs: HashMap = HashMap::new(); for r in &recent { let agent_type = extract_agent_type(&r.agent_name); *type_costs.entry(agent_type).or_default() += r.usage.total_cost_usd; } let mut type_list: Vec<(String, f64)> = type_costs.into_iter().collect(); type_list.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); out.push_str("**By Agent Type (24h)**\n"); if type_list.is_empty() { out.push_str(" *(none)*\n"); } else { for (agent_type, cost) in &type_list { out.push_str(&format!(" • {agent_type} — ${cost:.2}\n")); } } Some(out) } /// Extract the agent type from an agent name. /// /// Agent names like "coder-1", "qa-2", "mergemaster" map to types "coder", /// "qa", "mergemaster". If the name ends with `-`, strip the suffix. fn extract_agent_type(agent_name: &str) -> String { if let Some(pos) = agent_name.rfind('-') { let suffix = &agent_name[pos + 1..]; if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) { return agent_name[..pos].to_string(); } } agent_name.to_string() } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::agents::AgentPool; // -- test helpers ------------------------------------------------------- fn make_room_id(s: &str) -> OwnedRoomId { s.parse().unwrap() } fn test_ambient_rooms() -> Arc>> { Arc::new(Mutex::new(HashSet::new())) } fn test_agents() -> Arc { Arc::new(AgentPool::new_test(3000)) } fn try_cmd( bot_name: &str, bot_user_id: &str, message: &str, ambient_rooms: &Arc>>, is_addressed: bool, ) -> Option { let agents = test_agents(); let room_id = make_room_id("!test:example.com"); let dispatch = CommandDispatch { bot_name, bot_user_id, project_root: std::path::Path::new("/tmp"), agents: &agents, ambient_rooms, room_id: &room_id, is_addressed, }; try_handle_command(&dispatch, message) } fn try_cmd_addressed(bot_name: &str, bot_user_id: &str, message: &str) -> Option { try_cmd(bot_name, bot_user_id, message, &test_ambient_rooms(), true) } // -- strip_bot_mention -------------------------------------------------- #[test] fn strip_mention_full_user_id() { let rest = strip_bot_mention( "@timmy:homeserver.local help", "Timmy", "@timmy:homeserver.local", ); assert_eq!(rest.trim(), "help"); } #[test] fn strip_mention_localpart() { let rest = strip_bot_mention("@timmy help me", "Timmy", "@timmy:homeserver.local"); assert_eq!(rest.trim(), "help me"); } #[test] fn strip_mention_display_name() { let rest = strip_bot_mention("Timmy help", "Timmy", "@timmy:homeserver.local"); assert_eq!(rest.trim(), "help"); } #[test] fn strip_mention_display_name_case_insensitive() { let rest = strip_bot_mention("timmy help", "Timmy", "@timmy:homeserver.local"); assert_eq!(rest.trim(), "help"); } #[test] fn strip_mention_no_match_returns_original() { let rest = strip_bot_mention("hello world", "Timmy", "@timmy:homeserver.local"); assert_eq!(rest, "hello world"); } #[test] fn strip_mention_does_not_match_longer_name() { // "@timmybot" should NOT match "@timmy" let rest = strip_bot_mention("@timmybot help", "Timmy", "@timmy:homeserver.local"); assert_eq!(rest, "@timmybot help"); } #[test] fn strip_mention_comma_after_name() { let rest = strip_bot_mention("@timmy, help", "Timmy", "@timmy:homeserver.local"); assert_eq!(rest.trim().trim_start_matches(',').trim(), "help"); } // -- try_handle_command ------------------------------------------------- #[test] fn help_command_matches() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help"); assert!(result.is_some(), "help command should match"); } #[test] fn help_command_case_insensitive() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy HELP"); assert!(result.is_some(), "HELP should match case-insensitively"); } #[test] fn unknown_command_returns_none() { let result = try_cmd_addressed( "Timmy", "@timmy:homeserver.local", "@timmy what is the weather?", ); assert!(result.is_none(), "non-command should return None"); } #[test] fn help_output_contains_all_commands() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help"); let output = result.unwrap(); for cmd in commands() { assert!( output.contains(cmd.name), "help output must include command '{}'", cmd.name ); assert!( output.contains(cmd.description), "help output must include description for '{}'", cmd.name ); } } #[test] fn help_output_uses_bot_name() { let result = try_cmd_addressed("HAL", "@hal:example.com", "@hal help"); let output = result.unwrap(); assert!( output.contains("HAL Commands"), "help output should use bot name: {output}" ); } #[test] fn help_output_formatted_as_markdown() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help"); let output = result.unwrap(); assert!( output.contains("**help**"), "command name should be bold: {output}" ); assert!( output.contains("- **"), "commands should be in a list: {output}" ); } #[test] fn empty_message_after_mention_returns_none() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy"); assert!( result.is_none(), "bare mention with no command should fall through to LLM" ); } // -- status command ----------------------------------------------------- #[test] fn status_command_matches() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status"); assert!(result.is_some(), "status command should match"); } #[test] fn status_command_returns_pipeline_text() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status"); let output = result.unwrap(); assert!( output.contains("Pipeline Status"), "status output should contain pipeline info: {output}" ); } #[test] fn status_command_case_insensitive() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy STATUS"); assert!(result.is_some(), "STATUS should match case-insensitively"); } // -- ambient command ---------------------------------------------------- #[test] fn ambient_on_requires_addressed() { let ambient_rooms = test_ambient_rooms(); let room_id = make_room_id("!myroom:example.com"); let result = try_cmd( "Timmy", "@timmy:homeserver.local", "@timmy ambient on", &ambient_rooms, false, // not addressed ); // Should fall through to LLM when not addressed assert!(result.is_none(), "ambient should not fire in non-addressed mode"); assert!( !ambient_rooms.lock().unwrap().contains(&room_id), "ambient_rooms should not be modified when not addressed" ); } #[test] fn ambient_on_enables_ambient_mode() { let ambient_rooms = test_ambient_rooms(); let agents = test_agents(); let room_id = make_room_id("!myroom:example.com"); let dispatch = CommandDispatch { bot_name: "Timmy", bot_user_id: "@timmy:homeserver.local", project_root: std::path::Path::new("/tmp"), agents: &agents, ambient_rooms: &ambient_rooms, room_id: &room_id, is_addressed: true, }; let result = try_handle_command(&dispatch, "@timmy ambient on"); assert!(result.is_some(), "ambient on should produce a response"); let output = result.unwrap(); assert!( output.contains("Ambient mode on"), "response should confirm ambient on: {output}" ); assert!( ambient_rooms.lock().unwrap().contains(&room_id), "room should be in ambient_rooms after ambient on" ); } #[test] fn ambient_off_disables_ambient_mode() { let ambient_rooms = test_ambient_rooms(); let agents = test_agents(); let room_id = make_room_id("!myroom:example.com"); // Pre-insert the room ambient_rooms.lock().unwrap().insert(room_id.clone()); let dispatch = CommandDispatch { bot_name: "Timmy", bot_user_id: "@timmy:homeserver.local", project_root: std::path::Path::new("/tmp"), agents: &agents, ambient_rooms: &ambient_rooms, room_id: &room_id, is_addressed: true, }; let result = try_handle_command(&dispatch, "@timmy ambient off"); assert!(result.is_some(), "ambient off should produce a response"); let output = result.unwrap(); assert!( output.contains("Ambient mode off"), "response should confirm ambient off: {output}" ); assert!( !ambient_rooms.lock().unwrap().contains(&room_id), "room should be removed from ambient_rooms after ambient off" ); } #[test] fn ambient_invalid_args_returns_usage() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy ambient"); let output = result.unwrap(); assert!( output.contains("Usage"), "invalid ambient args should show usage: {output}" ); } // -- help lists status, ambient, and htop -------------------------------- #[test] fn help_output_includes_status() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help"); let output = result.unwrap(); assert!(output.contains("status"), "help should list status command: {output}"); } #[test] fn help_output_includes_ambient() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help"); let output = result.unwrap(); assert!(output.contains("ambient"), "help should list ambient command: {output}"); } #[test] fn help_output_includes_htop() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help"); let output = result.unwrap(); assert!(output.contains("htop"), "help should list htop command: {output}"); } #[test] fn htop_command_falls_through_to_none() { // The htop handler returns None so the message is handled asynchronously // in on_room_message, not here. try_handle_command must return None. let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy htop"); assert!( result.is_none(), "htop should not produce a sync response (handled async): {result:?}" ); } // -- strip_prefix_ci ---------------------------------------------------- #[test] fn strip_prefix_ci_basic() { assert_eq!(strip_prefix_ci("Hello world", "hello"), Some(" world")); } #[test] fn strip_prefix_ci_no_match() { assert_eq!(strip_prefix_ci("goodbye", "hello"), None); } #[test] fn strip_prefix_ci_word_boundary_required() { assert_eq!(strip_prefix_ci("helloworld", "hello"), None); } #[test] fn strip_prefix_ci_exact_match() { assert_eq!(strip_prefix_ci("hello", "hello"), Some("")); } // -- story_short_label -------------------------------------------------- #[test] fn short_label_extracts_number_and_name() { let label = story_short_label("293_story_register_all_bot_commands", Some("Register all bot commands")); assert_eq!(label, "293 — Register all bot commands"); } #[test] fn short_label_number_only_when_no_name() { let label = story_short_label("297_story_improve_bot_status_command_formatting", None); assert_eq!(label, "297"); } #[test] fn short_label_falls_back_to_stem_when_no_numeric_prefix() { let label = story_short_label("no_number_here", None); assert_eq!(label, "no_number_here"); } #[test] fn short_label_does_not_include_underscore_slug() { let label = story_short_label("293_story_register_all_bot_commands_in_the_command_registry", Some("Register all bot commands")); assert!( !label.contains("story_register"), "label should not contain the slug portion: {label}" ); } // -- build_pipeline_status formatting ----------------------------------- #[test] fn status_does_not_show_full_filename_stem() { use std::io::Write; use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let stage_dir = tmp.path().join(".story_kit/work/2_current"); std::fs::create_dir_all(&stage_dir).unwrap(); // Write a story file with a front-matter name let story_path = stage_dir.join("293_story_register_all_bot_commands.md"); let mut f = std::fs::File::create(&story_path).unwrap(); writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap(); let agents = AgentPool::new_test(3000); let output = build_pipeline_status(tmp.path(), &agents); assert!( !output.contains("293_story_register_all_bot_commands"), "output must not show full filename stem: {output}" ); assert!( output.contains("293 — Register all bot commands"), "output must show number and title: {output}" ); } // -- commands registry -------------------------------------------------- #[test] fn commands_registry_is_not_empty() { assert!( !commands().is_empty(), "command registry must contain at least one command" ); } #[test] fn all_command_names_are_lowercase() { for cmd in commands() { assert_eq!( cmd.name, cmd.name.to_ascii_lowercase(), "command name '{}' must be lowercase", cmd.name ); } } #[test] fn all_commands_have_descriptions() { for cmd in commands() { assert!( !cmd.description.is_empty(), "command '{}' must have a description", cmd.name ); } } // -- git command -------------------------------------------------------- #[test] fn git_command_is_registered() { let found = commands().iter().any(|c| c.name == "git"); assert!(found, "git command must be in the registry"); } #[test] fn git_command_appears_in_help() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help"); let output = result.unwrap(); assert!(output.contains("git"), "help should list git command: {output}"); } #[test] fn git_command_returns_some() { // Run from the actual repo root so git commands have a real repo to query. let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap_or(std::path::Path::new(".")); let agents = test_agents(); let ambient_rooms = test_ambient_rooms(); let room_id = make_room_id("!test:example.com"); let dispatch = CommandDispatch { bot_name: "Timmy", bot_user_id: "@timmy:homeserver.local", project_root: repo_root, agents: &agents, ambient_rooms: &ambient_rooms, room_id: &room_id, is_addressed: true, }; let result = try_handle_command(&dispatch, "@timmy git"); assert!(result.is_some(), "git command should always return Some"); } #[test] fn git_command_output_contains_branch() { let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap_or(std::path::Path::new(".")); let agents = test_agents(); let ambient_rooms = test_ambient_rooms(); let room_id = make_room_id("!test:example.com"); let dispatch = CommandDispatch { bot_name: "Timmy", bot_user_id: "@timmy:homeserver.local", project_root: repo_root, agents: &agents, ambient_rooms: &ambient_rooms, room_id: &room_id, is_addressed: true, }; let output = try_handle_command(&dispatch, "@timmy git").unwrap(); assert!( output.contains("**Branch:**"), "git output should contain branch info: {output}" ); } #[test] fn git_command_output_contains_changes() { let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap_or(std::path::Path::new(".")); let agents = test_agents(); let ambient_rooms = test_ambient_rooms(); let room_id = make_room_id("!test:example.com"); let dispatch = CommandDispatch { bot_name: "Timmy", bot_user_id: "@timmy:homeserver.local", project_root: repo_root, agents: &agents, ambient_rooms: &ambient_rooms, room_id: &room_id, is_addressed: true, }; let output = try_handle_command(&dispatch, "@timmy git").unwrap(); assert!( output.contains("**Changes:**"), "git output should contain changes section: {output}" ); } #[test] fn git_command_output_contains_remote() { let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap_or(std::path::Path::new(".")); let agents = test_agents(); let ambient_rooms = test_ambient_rooms(); let room_id = make_room_id("!test:example.com"); let dispatch = CommandDispatch { bot_name: "Timmy", bot_user_id: "@timmy:homeserver.local", project_root: repo_root, agents: &agents, ambient_rooms: &ambient_rooms, room_id: &room_id, is_addressed: true, }; let output = try_handle_command(&dispatch, "@timmy git").unwrap(); assert!( output.contains("**Remote:**"), "git output should contain remote section: {output}" ); } #[test] fn git_command_case_insensitive() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy GIT"); assert!(result.is_some(), "GIT should match case-insensitively"); } // -- cost command ------------------------------------------------------- fn write_token_records(root: &std::path::Path, records: &[crate::agents::token_usage::TokenUsageRecord]) { for r in records { crate::agents::token_usage::append_record(root, r).unwrap(); } } fn make_usage(cost: f64) -> crate::agents::TokenUsage { crate::agents::TokenUsage { input_tokens: 100, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, total_cost_usd: cost, } } fn make_record(story_id: &str, agent_name: &str, cost: f64, hours_ago: i64) -> crate::agents::token_usage::TokenUsageRecord { let ts = (chrono::Utc::now() - chrono::Duration::hours(hours_ago)).to_rfc3339(); crate::agents::token_usage::TokenUsageRecord { story_id: story_id.to_string(), agent_name: agent_name.to_string(), timestamp: ts, usage: make_usage(cost), } } fn cost_cmd_with_root(root: &std::path::Path) -> Option { let agents = test_agents(); let ambient_rooms = test_ambient_rooms(); let room_id = make_room_id("!test:example.com"); let dispatch = CommandDispatch { bot_name: "Timmy", bot_user_id: "@timmy:homeserver.local", project_root: root, agents: &agents, ambient_rooms: &ambient_rooms, room_id: &room_id, is_addressed: true, }; try_handle_command(&dispatch, "@timmy cost") } #[test] fn cost_command_is_registered() { let found = commands().iter().any(|c| c.name == "cost"); assert!(found, "cost command must be in the registry"); } #[test] fn cost_command_appears_in_help() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help"); let output = result.unwrap(); assert!(output.contains("cost"), "help should list cost command: {output}"); } #[test] fn cost_command_no_records() { let tmp = tempfile::TempDir::new().unwrap(); let output = cost_cmd_with_root(tmp.path()).unwrap(); assert!(output.contains("No usage records found"), "should show empty message: {output}"); } #[test] fn cost_command_shows_24h_total() { let tmp = tempfile::TempDir::new().unwrap(); write_token_records(tmp.path(), &[ make_record("42_story_foo", "coder-1", 1.50, 2), make_record("42_story_foo", "coder-1", 0.50, 5), ]); let output = cost_cmd_with_root(tmp.path()).unwrap(); assert!(output.contains("**Last 24h:** $2.00"), "should show 24h total: {output}"); } #[test] fn cost_command_excludes_old_from_24h() { let tmp = tempfile::TempDir::new().unwrap(); write_token_records(tmp.path(), &[ make_record("42_story_foo", "coder-1", 1.00, 2), // within 24h make_record("43_story_bar", "coder-1", 5.00, 48), // older ]); let output = cost_cmd_with_root(tmp.path()).unwrap(); assert!(output.contains("**Last 24h:** $1.00"), "should only count recent: {output}"); assert!(output.contains("**All-time:** $6.00"), "all-time should include everything: {output}"); } #[test] fn cost_command_shows_top_stories() { let tmp = tempfile::TempDir::new().unwrap(); write_token_records(tmp.path(), &[ make_record("42_story_foo", "coder-1", 3.00, 1), make_record("43_story_bar", "coder-1", 1.00, 1), make_record("42_story_foo", "qa-1", 2.00, 1), ]); let output = cost_cmd_with_root(tmp.path()).unwrap(); assert!(output.contains("Top Stories"), "should have top stories section: {output}"); // Story 42 ($5.00) should appear before story 43 ($1.00) let pos_42 = output.find("42").unwrap(); let pos_43 = output.find("43").unwrap(); assert!(pos_42 < pos_43, "story 42 should appear before 43 (sorted by cost): {output}"); } #[test] fn cost_command_limits_to_5_stories() { let tmp = tempfile::TempDir::new().unwrap(); let mut records = Vec::new(); for i in 1..=7 { records.push(make_record(&format!("{i}_story_s{i}"), "coder-1", i as f64, 1)); } write_token_records(tmp.path(), &records); let output = cost_cmd_with_root(tmp.path()).unwrap(); // The top 5 most expensive are stories 7,6,5,4,3. Stories 1 and 2 should be excluded. let top_section = output.split("**By Agent Type").next().unwrap(); assert!(!top_section.contains("• 1 —"), "story 1 should not be in top 5: {output}"); assert!(!top_section.contains("• 2 —"), "story 2 should not be in top 5: {output}"); } #[test] fn cost_command_shows_agent_type_breakdown() { let tmp = tempfile::TempDir::new().unwrap(); write_token_records(tmp.path(), &[ make_record("42_story_foo", "coder-1", 2.00, 1), make_record("42_story_foo", "qa-1", 1.50, 1), make_record("42_story_foo", "mergemaster", 0.50, 1), ]); let output = cost_cmd_with_root(tmp.path()).unwrap(); assert!(output.contains("By Agent Type"), "should have agent type section: {output}"); assert!(output.contains("coder"), "should show coder type: {output}"); assert!(output.contains("qa"), "should show qa type: {output}"); assert!(output.contains("mergemaster"), "should show mergemaster type: {output}"); } #[test] fn cost_command_shows_all_time_total() { let tmp = tempfile::TempDir::new().unwrap(); write_token_records(tmp.path(), &[ make_record("42_story_foo", "coder-1", 1.00, 2), make_record("43_story_bar", "coder-1", 9.00, 100), ]); let output = cost_cmd_with_root(tmp.path()).unwrap(); assert!(output.contains("**All-time:** $10.00"), "should show all-time total: {output}"); } #[test] fn cost_command_case_insensitive() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy COST"); assert!(result.is_some(), "COST should match case-insensitively"); } // -- extract_agent_type ------------------------------------------------- #[test] fn extract_agent_type_strips_numeric_suffix() { assert_eq!(extract_agent_type("coder-1"), "coder"); assert_eq!(extract_agent_type("qa-2"), "qa"); } #[test] fn extract_agent_type_keeps_non_numeric_suffix() { assert_eq!(extract_agent_type("mergemaster"), "mergemaster"); assert_eq!(extract_agent_type("coder-alpha"), "coder-alpha"); } }