From eea797975b7ce6c23d305da9f579afa7d9f7f19d Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 20 Mar 2026 07:26:44 +0000 Subject: [PATCH] story-kit: merge 328_refactor_split_commands_rs_into_individual_command_handler_modules --- server/src/matrix/commands.rs | 1947 ------------------------ server/src/matrix/commands/ambient.rs | 142 ++ server/src/matrix/commands/cost.rs | 272 ++++ server/src/matrix/commands/git.rs | 207 +++ server/src/matrix/commands/help.rs | 91 ++ server/src/matrix/commands/mod.rs | 416 +++++ server/src/matrix/commands/overview.rs | 361 +++++ server/src/matrix/commands/show.rs | 202 +++ server/src/matrix/commands/status.rs | 354 +++++ 9 files changed, 2045 insertions(+), 1947 deletions(-) delete mode 100644 server/src/matrix/commands.rs create mode 100644 server/src/matrix/commands/ambient.rs create mode 100644 server/src/matrix/commands/cost.rs create mode 100644 server/src/matrix/commands/git.rs create mode 100644 server/src/matrix/commands/help.rs create mode 100644 server/src/matrix/commands/mod.rs create mode 100644 server/src/matrix/commands/overview.rs create mode 100644 server/src/matrix/commands/show.rs create mode 100644 server/src/matrix/commands/status.rs diff --git a/server/src/matrix/commands.rs b/server/src/matrix/commands.rs deleted file mode 100644 index 7124872..0000000 --- a/server/src/matrix/commands.rs +++ /dev/null @@ -1,1947 +0,0 @@ -//! 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 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. -/// -/// All identifiers are platform-agnostic strings so this struct works with -/// any [`ChatTransport`](crate::transport::ChatTransport) implementation. -pub struct CommandDispatch<'a> { - /// The bot's display name (e.g., "Timmy"). - pub bot_name: &'a str, - /// The bot's full user ID (e.g., `"@timmy:homeserver.local"` on Matrix). - 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 room IDs 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 str, - /// 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 room IDs 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 str, - /// 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, - }, - BotCommand { - name: "show", - description: "Display the full text of a work item: `show `", - handler: handle_show, - }, - BotCommand { - name: "overview", - description: "Show implementation summary for a merged story: `overview `", - handler: handle_overview, - }, - BotCommand { - name: "delete", - description: "Remove a work item from the pipeline: `delete `", - handler: handle_delete_fallback, - }, - ] -} - -/// 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(); - - // Read token usage once for all stories to avoid repeated file I/O. - let cost_by_story: std::collections::HashMap = - crate::agents::token_usage::read_all(project_root) - .unwrap_or_default() - .into_iter() - .fold(std::collections::HashMap::new(), |mut map, r| { - *map.entry(r.story_id).or_insert(0.0) += r.usage.total_cost_usd; - map - }); - - 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()); - let cost_suffix = cost_by_story - .get(story_id) - .filter(|&&c| c > 0.0) - .map(|c| format!(" — ${c:.2}")) - .unwrap_or_default(); - 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}{cost_suffix} — {} ({model_str})\n", - agent.agent_name - )); - } else { - out.push_str(&format!(" • {display}{cost_suffix}\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.to_string()); - } else { - ambient.remove(ctx.room_id); - } - ambient.iter().cloned().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() -} - -/// Display the full markdown text of a work item identified by its numeric ID. -/// -/// Searches all pipeline stages in order and returns the raw file contents of -/// the first matching story, bug, or spike. Returns a friendly message when -/// no match is found. -fn handle_show(ctx: &CommandContext) -> Option { - let num_str = ctx.args.trim(); - if num_str.is_empty() { - return Some(format!( - "Usage: `{} show `\n\nDisplays the full text of a story, bug, or spike.", - ctx.bot_name - )); - } - if !num_str.chars().all(|c| c.is_ascii_digit()) { - return Some(format!( - "Invalid story number: `{num_str}`. Usage: `{} show `", - ctx.bot_name - )); - } - - let stages = [ - "1_backlog", - "2_current", - "3_qa", - "4_merge", - "5_done", - "6_archived", - ]; - - for stage in &stages { - let dir = ctx - .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()) { - let file_num = stem - .split('_') - .next() - .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) - .unwrap_or(""); - if file_num == num_str { - return match std::fs::read_to_string(&path) { - Ok(contents) => Some(contents), - Err(e) => Some(format!("Failed to read story {num_str}: {e}")), - }; - } - } - } - } - } - - Some(format!( - "No story, bug, or spike with number **{num_str}** found." - )) -} - -/// 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. -/// The entry exists in the registry only so `help` lists it. -/// -/// Returns `None` to prevent the LLM from receiving "delete" as a prompt. -fn handle_delete_fallback(_ctx: &CommandContext) -> Option { - None -} - -/// Show implementation summary for a story identified by its number. -/// -/// Finds the `story-kit: merge {story_id}` commit on master, displays the -/// git diff --stat (files changed with line counts), and extracts key -/// function/struct/type names added or modified in the implementation. -/// Returns a friendly message when no merge commit is found. -fn handle_overview(ctx: &CommandContext) -> Option { - let num_str = ctx.args.trim(); - if num_str.is_empty() { - return Some(format!( - "Usage: `{} overview `\n\nShows the implementation summary for a story.", - ctx.bot_name - )); - } - if !num_str.chars().all(|c| c.is_ascii_digit()) { - return Some(format!( - "Invalid story number: `{num_str}`. Usage: `{} overview `", - ctx.bot_name - )); - } - - let commit_hash = match find_story_merge_commit(ctx.project_root, num_str) { - Some(h) => h, - None => { - return Some(format!( - "No implementation found for story **{num_str}**. \ - It may still be in the backlog or was never merged." - )); - } - }; - - let stat_output = get_commit_stat(ctx.project_root, &commit_hash); - let symbols = extract_diff_symbols(ctx.project_root, &commit_hash); - let story_name = find_story_name(ctx.project_root, num_str); - - let short_hash = &commit_hash[..commit_hash.len().min(8)]; - let mut out = match story_name { - Some(name) => format!("**Overview: Story {num_str} — {name}**\n\n"), - None => format!("**Overview: Story {num_str}**\n\n"), - }; - out.push_str(&format!("Commit: `{short_hash}`\n\n")); - - // Parse stat output: collect per-file lines and the summary line. - let mut file_lines: Vec = Vec::new(); - let mut summary_line = String::new(); - for line in stat_output.lines() { - if line.contains("changed") && (line.contains("insertion") || line.contains("deletion")) { - summary_line = line.trim().to_string(); - } else if !line.trim().is_empty() && line.contains('|') { - file_lines.push(line.trim().to_string()); - } - } - - if !summary_line.is_empty() { - out.push_str(&format!("**Changes:** {summary_line}\n")); - } - - if !file_lines.is_empty() { - out.push_str("**Files:**\n"); - for f in file_lines.iter().take(8) { - out.push_str(&format!(" • `{f}`\n")); - } - if file_lines.len() > 8 { - out.push_str(&format!(" … and {} more\n", file_lines.len() - 8)); - } - } - - if !symbols.is_empty() { - out.push_str("\n**Key symbols:**\n"); - for sym in &symbols { - out.push_str(&format!(" • {sym}\n")); - } - } - - Some(out) -} - -/// Find the merge commit hash for a story by its numeric ID. -/// -/// Searches git log for a commit whose subject matches -/// `story-kit: merge {num}_*`. -fn find_story_merge_commit(root: &std::path::Path, num_str: &str) -> Option { - use std::process::Command; - let grep_pattern = format!("story-kit: merge {num_str}_"); - let output = Command::new("git") - .args(["log", "--format=%H", "--all", "--grep", &grep_pattern]) - .current_dir(root) - .output() - .ok() - .filter(|o| o.status.success())?; - let text = String::from_utf8_lossy(&output.stdout); - let hash = text.lines().next()?.trim().to_string(); - if hash.is_empty() { None } else { Some(hash) } -} - -/// Find the human-readable name of a story by searching all pipeline stages. -fn find_story_name(root: &std::path::Path, num_str: &str) -> Option { - let stages = [ - "1_backlog", - "2_current", - "3_qa", - "4_merge", - "5_done", - "6_archived", - ]; - for stage in &stages { - let dir = 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()) { - let file_num = stem - .split('_') - .next() - .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) - .unwrap_or(""); - if file_num == num_str { - return std::fs::read_to_string(&path) - .ok() - .and_then(|c| { - crate::io::story_metadata::parse_front_matter(&c) - .ok() - .and_then(|m| m.name) - }); - } - } - } - } - } - None -} - -/// Return the `git show --stat` output for a commit. -fn get_commit_stat(root: &std::path::Path, hash: &str) -> String { - use std::process::Command; - Command::new("git") - .args(["show", "--stat", hash]) - .current_dir(root) - .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) - .unwrap_or_default() -} - -/// Extract up to 12 unique top-level symbol definitions from a commit diff. -/// -/// Scans added lines (`+`) for Rust `fn`, `struct`, `enum`, `type`, `trait`, -/// and `impl` declarations and returns them formatted as `` `Name` (kind) ``. -fn extract_diff_symbols(root: &std::path::Path, hash: &str) -> Vec { - use std::process::Command; - let output = Command::new("git") - .args(["show", hash]) - .current_dir(root) - .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) - .unwrap_or_default(); - - let mut symbols: Vec = Vec::new(); - for line in output.lines() { - if !line.starts_with('+') || line.starts_with("+++") { - continue; - } - if let Some(sym) = parse_symbol_definition(&line[1..]) { - if !symbols.contains(&sym) { - symbols.push(sym); - } - if symbols.len() >= 12 { - break; - } - } - } - symbols -} - -/// Parse a single line of code and return a formatted symbol if it opens a -/// top-level Rust definition (`fn`, `struct`, `enum`, `type`, `trait`, `impl`). -fn parse_symbol_definition(code: &str) -> Option { - let t = code.trim(); - let patterns: &[(&str, &str)] = &[ - ("pub async fn ", "fn"), - ("async fn ", "fn"), - ("pub fn ", "fn"), - ("fn ", "fn"), - ("pub struct ", "struct"), - ("struct ", "struct"), - ("pub enum ", "enum"), - ("enum ", "enum"), - ("pub type ", "type"), - ("type ", "type"), - ("pub trait ", "trait"), - ("trait ", "trait"), - ("impl ", "impl"), - ]; - for (prefix, kind) in patterns { - if let Some(rest) = t.strip_prefix(prefix) { - let name: String = rest - .chars() - .take_while(|c| c.is_alphanumeric() || *c == '_') - .collect(); - if !name.is_empty() { - return Some(format!("`{name}` ({kind})")); - } - } - } - None -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use crate::agents::AgentPool; - - // -- test helpers ------------------------------------------------------- - - 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 = "!test:example.com".to_string(); - 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 = "!myroom:example.com".to_string(); - 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 = "!myroom:example.com".to_string(); - 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 = "!myroom:example.com".to_string(); - // 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}" - ); - } - - // -- token cost in status output ---------------------------------------- - - #[test] - fn status_shows_cost_when_token_usage_exists() { - 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(); - - 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(); - - // Write token usage for this story. - let usage = crate::agents::TokenUsage { - input_tokens: 100, - output_tokens: 200, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - total_cost_usd: 0.29, - }; - let record = crate::agents::token_usage::build_record( - "293_story_register_all_bot_commands", - "coder-1", - None, - usage, - ); - crate::agents::token_usage::append_record(tmp.path(), &record).unwrap(); - - let agents = AgentPool::new_test(3000); - let output = build_pipeline_status(tmp.path(), &agents); - - assert!( - output.contains("293 — Register all bot commands — $0.29"), - "output must show cost next to story: {output}" - ); - } - - #[test] - fn status_no_cost_when_no_usage() { - 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(); - - 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(); - - // No token usage written. - let agents = AgentPool::new_test(3000); - let output = build_pipeline_status(tmp.path(), &agents); - - assert!( - !output.contains("$"), - "output must not show cost when no usage exists: {output}" - ); - } - - #[test] - fn status_aggregates_multiple_records_per_story() { - 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(); - - 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(); - - // Write two records for the same story — costs should be summed. - for cost in [0.10, 0.19] { - let usage = crate::agents::TokenUsage { - input_tokens: 50, - output_tokens: 100, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - total_cost_usd: cost, - }; - let record = crate::agents::token_usage::build_record( - "293_story_register_all_bot_commands", - "coder-1", - None, - usage, - ); - crate::agents::token_usage::append_record(tmp.path(), &record).unwrap(); - } - - let agents = AgentPool::new_test(3000); - let output = build_pipeline_status(tmp.path(), &agents); - - assert!( - output.contains("293 — Register all bot commands — $0.29"), - "output must show aggregated cost: {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 = "!test:example.com".to_string(); - 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 = "!test:example.com".to_string(); - 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 = "!test:example.com".to_string(); - 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 = "!test:example.com".to_string(); - 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, - model: None, - 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 = "!test:example.com".to_string(); - 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"); - } - - // -- show command ------------------------------------------------------- - - fn show_cmd_with_root(root: &std::path::Path, args: &str) -> Option { - let agents = test_agents(); - let ambient_rooms = test_ambient_rooms(); - let room_id = "!test:example.com".to_string(); - 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, &format!("@timmy show {args}")) - } - - fn write_story_file(root: &std::path::Path, stage: &str, filename: &str, content: &str) { - let dir = root.join(".story_kit/work").join(stage); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join(filename), content).unwrap(); - } - - #[test] - fn show_command_is_registered() { - let found = commands().iter().any(|c| c.name == "show"); - assert!(found, "show command must be in the registry"); - } - - #[test] - fn show_command_appears_in_help() { - let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help"); - let output = result.unwrap(); - assert!(output.contains("show"), "help should list show command: {output}"); - } - - #[test] - fn show_command_no_args_returns_usage() { - let tmp = tempfile::TempDir::new().unwrap(); - let output = show_cmd_with_root(tmp.path(), "").unwrap(); - assert!( - output.contains("Usage"), - "no args should show usage hint: {output}" - ); - } - - #[test] - fn show_command_non_numeric_args_returns_error() { - let tmp = tempfile::TempDir::new().unwrap(); - let output = show_cmd_with_root(tmp.path(), "abc").unwrap(); - assert!( - output.contains("Invalid"), - "non-numeric arg should return error message: {output}" - ); - } - - #[test] - fn show_command_not_found_returns_friendly_message() { - let tmp = tempfile::TempDir::new().unwrap(); - let output = show_cmd_with_root(tmp.path(), "999").unwrap(); - assert!( - output.contains("999"), - "not-found message should include the queried number: {output}" - ); - assert!( - output.contains("found"), - "not-found message should say not found: {output}" - ); - } - - #[test] - fn show_command_finds_story_in_backlog() { - let tmp = tempfile::TempDir::new().unwrap(); - write_story_file( - tmp.path(), - "1_backlog", - "305_story_show_command.md", - "---\nname: Show command\n---\n\n# Story 305\n\nFull story text here.", - ); - let output = show_cmd_with_root(tmp.path(), "305").unwrap(); - assert!( - output.contains("Full story text here."), - "show should return full story content: {output}" - ); - } - - #[test] - fn show_command_finds_story_in_current() { - let tmp = tempfile::TempDir::new().unwrap(); - write_story_file( - tmp.path(), - "2_current", - "42_story_do_something.md", - "---\nname: Do something\n---\n\n# Story 42\n\nIn progress.", - ); - let output = show_cmd_with_root(tmp.path(), "42").unwrap(); - assert!( - output.contains("In progress."), - "show should return story from current stage: {output}" - ); - } - - #[test] - fn show_command_finds_bug() { - let tmp = tempfile::TempDir::new().unwrap(); - write_story_file( - tmp.path(), - "1_backlog", - "7_bug_crash_on_login.md", - "---\nname: Crash on login\n---\n\n## Symptom\n\nCrashes.", - ); - let output = show_cmd_with_root(tmp.path(), "7").unwrap(); - assert!( - output.contains("Symptom"), - "show should return bug content: {output}" - ); - } - - #[test] - fn show_command_case_insensitive() { - let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy SHOW 1"); - assert!(result.is_some(), "SHOW should match case-insensitively"); - } - - // -- overview command --------------------------------------------------- - - fn overview_cmd_with_root(root: &std::path::Path, args: &str) -> Option { - let agents = test_agents(); - let ambient_rooms = test_ambient_rooms(); - let room_id = "!test:example.com".to_string(); - 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, &format!("@timmy overview {args}")) - } - - #[test] - fn overview_command_is_registered() { - let found = commands().iter().any(|c| c.name == "overview"); - assert!(found, "overview command must be in the registry"); - } - - #[test] - fn overview_command_appears_in_help() { - let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help"); - let output = result.unwrap(); - assert!(output.contains("overview"), "help should list overview command: {output}"); - } - - #[test] - fn overview_command_no_args_returns_usage() { - let tmp = tempfile::TempDir::new().unwrap(); - let output = overview_cmd_with_root(tmp.path(), "").unwrap(); - assert!( - output.contains("Usage"), - "no args should show usage hint: {output}" - ); - } - - #[test] - fn overview_command_non_numeric_arg_returns_error() { - let tmp = tempfile::TempDir::new().unwrap(); - let output = overview_cmd_with_root(tmp.path(), "abc").unwrap(); - assert!( - output.contains("Invalid"), - "non-numeric arg should return error: {output}" - ); - } - - #[test] - fn overview_command_not_found_returns_friendly_message() { - // Use the real repo root but a story number that was never merged. - let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap_or(std::path::Path::new(".")); - let output = overview_cmd_with_root(repo_root, "99999").unwrap(); - assert!( - output.contains("99999"), - "not-found message should include the story number: {output}" - ); - assert!( - output.contains("backlog") || output.contains("No implementation"), - "not-found message should explain why: {output}" - ); - } - - #[test] - fn overview_command_found_shows_commit_and_stat() { - // Story 324 has a real merge commit in master. - let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap_or(std::path::Path::new(".")); - let output = overview_cmd_with_root(repo_root, "324").unwrap(); - assert!( - output.contains("**Overview: Story 324"), - "output should show story header: {output}" - ); - assert!( - output.contains("Commit:"), - "output should show commit hash: {output}" - ); - assert!( - output.contains("**Changes:**") || output.contains("**Files:**"), - "output should show file changes: {output}" - ); - } - - #[test] - fn overview_command_case_insensitive() { - let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy OVERVIEW 1"); - assert!(result.is_some(), "OVERVIEW should match case-insensitively"); - } - - // -- parse_symbol_definition -------------------------------------------- - - #[test] - fn parse_symbol_pub_fn() { - let result = parse_symbol_definition("pub fn handle_foo(ctx: &Context) -> Option {"); - assert_eq!(result, Some("`handle_foo` (fn)".to_string())); - } - - #[test] - fn parse_symbol_pub_struct() { - let result = parse_symbol_definition("pub struct SlackTransport {"); - assert_eq!(result, Some("`SlackTransport` (struct)".to_string())); - } - - #[test] - fn parse_symbol_impl() { - let result = parse_symbol_definition("impl ChatTransport for SlackTransport {"); - assert_eq!(result, Some("`ChatTransport` (impl)".to_string())); - } - - #[test] - fn parse_symbol_no_match() { - let result = parse_symbol_definition(" let x = 42;"); - assert_eq!(result, None); - } - - #[test] - fn parse_symbol_pub_enum() { - let result = parse_symbol_definition("pub enum QaMode {"); - assert_eq!(result, Some("`QaMode` (enum)".to_string())); - } - - #[test] - fn parse_symbol_pub_type() { - let result = parse_symbol_definition("pub type SlackHistory = Arc>>>;"); - assert_eq!(result, Some("`SlackHistory` (type)".to_string())); - } -} diff --git a/server/src/matrix/commands/ambient.rs b/server/src/matrix/commands/ambient.rs new file mode 100644 index 0000000..e1c854a --- /dev/null +++ b/server/src/matrix/commands/ambient.rs @@ -0,0 +1,142 @@ +//! Handler for the `ambient` command. + +use super::CommandContext; +use crate::matrix::config::save_ambient_rooms; + +/// 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. +pub(super) 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.to_string()); + } else { + ambient.remove(ctx.room_id); + } + ambient.iter().cloned().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()) +} + +#[cfg(test)] +mod tests { + use crate::agents::AgentPool; + use std::collections::HashSet; + use std::sync::{Arc, Mutex}; + + use super::super::{CommandDispatch, try_handle_command}; + + fn test_ambient_rooms() -> Arc>> { + Arc::new(Mutex::new(HashSet::new())) + } + + fn test_agents() -> Arc { + Arc::new(AgentPool::new_test(3000)) + } + + #[test] + fn ambient_on_requires_addressed() { + let ambient_rooms = test_ambient_rooms(); + let room_id = "!myroom:example.com".to_string(); + let agents = test_agents(); + 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: false, // not addressed + }; + let result = try_handle_command(&dispatch, "@timmy ambient on"); + // 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 = "!myroom:example.com".to_string(); + 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 = "!myroom:example.com".to_string(); + // 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 = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy ambient"); + let output = result.unwrap(); + assert!( + output.contains("Usage"), + "invalid ambient args should show usage: {output}" + ); + } +} diff --git a/server/src/matrix/commands/cost.rs b/server/src/matrix/commands/cost.rs new file mode 100644 index 0000000..511c9cc --- /dev/null +++ b/server/src/matrix/commands/cost.rs @@ -0,0 +1,272 @@ +//! Handler for the `cost` command. + +use std::collections::HashMap; + +use super::status::story_short_label; +use super::CommandContext; + +/// Show token spend: 24h total, top 5 stories, agent-type breakdown, and +/// all-time total. +pub(super) 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. +pub(super) 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() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agents::AgentPool; + use std::sync::Arc; + + 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, + model: None, + usage: make_usage(cost), + } + } + + fn cost_cmd_with_root(root: &std::path::Path) -> Option { + use super::super::{CommandDispatch, try_handle_command}; + use std::collections::HashSet; + use std::sync::Mutex; + + let agents = Arc::new(AgentPool::new_test(3000)); + let ambient_rooms = Arc::new(Mutex::new(HashSet::new())); + let room_id = "!test:example.com".to_string(); + 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() { + use super::super::commands; + 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 = super::super::tests::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 = super::super::tests::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"); + } +} diff --git a/server/src/matrix/commands/git.rs b/server/src/matrix/commands/git.rs new file mode 100644 index 0000000..d757e5d --- /dev/null +++ b/server/src/matrix/commands/git.rs @@ -0,0 +1,207 @@ +//! Handler for the `git` command. + +use super::CommandContext; + +/// Show compact git status: branch, uncommitted files, ahead/behind remote. +pub(super) 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) +} + +#[cfg(test)] +mod tests { + use crate::agents::AgentPool; + use std::collections::HashSet; + use std::sync::{Arc, Mutex}; + + use super::super::{CommandDispatch, try_handle_command}; + + fn test_ambient_rooms() -> Arc>> { + Arc::new(Mutex::new(HashSet::new())) + } + + fn test_agents() -> Arc { + Arc::new(AgentPool::new_test(3000)) + } + + #[test] + fn git_command_is_registered() { + use super::super::commands; + 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 = super::super::tests::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 = "!test:example.com".to_string(); + 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 = "!test:example.com".to_string(); + 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 = "!test:example.com".to_string(); + 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 = "!test:example.com".to_string(); + 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 = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy GIT"); + assert!(result.is_some(), "GIT should match case-insensitively"); + } +} diff --git a/server/src/matrix/commands/help.rs b/server/src/matrix/commands/help.rs new file mode 100644 index 0000000..f8b47da --- /dev/null +++ b/server/src/matrix/commands/help.rs @@ -0,0 +1,91 @@ +//! Handler for the `help` command. + +use super::{commands, CommandContext}; + +pub(super) 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) +} + +#[cfg(test)] +mod tests { + use super::super::tests::{try_cmd_addressed, commands}; + + #[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 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 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}"); + } +} diff --git a/server/src/matrix/commands/mod.rs b/server/src/matrix/commands/mod.rs new file mode 100644 index 0000000..51ab0fc --- /dev/null +++ b/server/src/matrix/commands/mod.rs @@ -0,0 +1,416 @@ +//! 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. + +mod ambient; +mod cost; +mod git; +mod help; +mod overview; +mod show; +mod status; + +use crate::agents::AgentPool; +use std::collections::HashSet; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +/// 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. +/// +/// All identifiers are platform-agnostic strings so this struct works with +/// any [`ChatTransport`](crate::transport::ChatTransport) implementation. +pub struct CommandDispatch<'a> { + /// The bot's display name (e.g., "Timmy"). + pub bot_name: &'a str, + /// The bot's full user ID (e.g., `"@timmy:homeserver.local"` on Matrix). + 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 room IDs 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 str, + /// 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 room IDs 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 str, + /// 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: help::handle_help, + }, + BotCommand { + name: "status", + description: "Show pipeline status and agent availability", + handler: status::handle_status, + }, + BotCommand { + name: "ambient", + description: "Toggle ambient mode for this room: `ambient on` or `ambient off`", + handler: ambient::handle_ambient, + }, + BotCommand { + name: "git", + description: "Show git status: branch, uncommitted changes, and ahead/behind remote", + handler: git::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: cost::handle_cost, + }, + BotCommand { + name: "show", + description: "Display the full text of a work item: `show `", + handler: show::handle_show, + }, + BotCommand { + name: "overview", + description: "Show implementation summary for a merged story: `overview `", + handler: overview::handle_overview, + }, + BotCommand { + name: "delete", + description: "Remove a work item from the pipeline: `delete `", + handler: handle_delete_fallback, + }, + ] +} + +/// 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), + } +} + +/// 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 +} + +/// 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. +/// The entry exists in the registry only so `help` lists it. +/// +/// Returns `None` to prevent the LLM from receiving "delete" as a prompt. +fn handle_delete_fallback(_ctx: &CommandContext) -> Option { + None +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::agents::AgentPool; + + // -- test helpers (shared with submodule tests) ------------------------- + + pub fn test_ambient_rooms() -> Arc>> { + Arc::new(Mutex::new(HashSet::new())) + } + + pub fn test_agents() -> Arc { + Arc::new(AgentPool::new_test(3000)) + } + + pub 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 = "!test:example.com".to_string(); + 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) + } + + pub 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) + } + + // Re-export commands() for submodule tests + pub use super::commands; + + // -- 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 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 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" + ); + } + + #[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("")); + } + + // -- 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 + ); + } + } +} diff --git a/server/src/matrix/commands/overview.rs b/server/src/matrix/commands/overview.rs new file mode 100644 index 0000000..fbdd29c --- /dev/null +++ b/server/src/matrix/commands/overview.rs @@ -0,0 +1,361 @@ +//! Handler for the `overview` command. + +use super::CommandContext; + +/// Show implementation summary for a story identified by its number. +/// +/// Finds the `story-kit: merge {story_id}` commit on master, displays the +/// git diff --stat (files changed with line counts), and extracts key +/// function/struct/type names added or modified in the implementation. +/// Returns a friendly message when no merge commit is found. +pub(super) fn handle_overview(ctx: &CommandContext) -> Option { + let num_str = ctx.args.trim(); + if num_str.is_empty() { + return Some(format!( + "Usage: `{} overview `\n\nShows the implementation summary for a story.", + ctx.bot_name + )); + } + if !num_str.chars().all(|c| c.is_ascii_digit()) { + return Some(format!( + "Invalid story number: `{num_str}`. Usage: `{} overview `", + ctx.bot_name + )); + } + + let commit_hash = match find_story_merge_commit(ctx.project_root, num_str) { + Some(h) => h, + None => { + return Some(format!( + "No implementation found for story **{num_str}**. \ + It may still be in the backlog or was never merged." + )); + } + }; + + let stat_output = get_commit_stat(ctx.project_root, &commit_hash); + let symbols = extract_diff_symbols(ctx.project_root, &commit_hash); + let story_name = find_story_name(ctx.project_root, num_str); + + let short_hash = &commit_hash[..commit_hash.len().min(8)]; + let mut out = match story_name { + Some(name) => format!("**Overview: Story {num_str} — {name}**\n\n"), + None => format!("**Overview: Story {num_str}**\n\n"), + }; + out.push_str(&format!("Commit: `{short_hash}`\n\n")); + + // Parse stat output: collect per-file lines and the summary line. + let mut file_lines: Vec = Vec::new(); + let mut summary_line = String::new(); + for line in stat_output.lines() { + if line.contains("changed") && (line.contains("insertion") || line.contains("deletion")) { + summary_line = line.trim().to_string(); + } else if !line.trim().is_empty() && line.contains('|') { + file_lines.push(line.trim().to_string()); + } + } + + if !summary_line.is_empty() { + out.push_str(&format!("**Changes:** {summary_line}\n")); + } + + if !file_lines.is_empty() { + out.push_str("**Files:**\n"); + for f in file_lines.iter().take(8) { + out.push_str(&format!(" • `{f}`\n")); + } + if file_lines.len() > 8 { + out.push_str(&format!(" … and {} more\n", file_lines.len() - 8)); + } + } + + if !symbols.is_empty() { + out.push_str("\n**Key symbols:**\n"); + for sym in &symbols { + out.push_str(&format!(" • {sym}\n")); + } + } + + Some(out) +} + +/// Find the merge commit hash for a story by its numeric ID. +/// +/// Searches git log for a commit whose subject matches +/// `story-kit: merge {num}_*`. +fn find_story_merge_commit(root: &std::path::Path, num_str: &str) -> Option { + use std::process::Command; + let grep_pattern = format!("story-kit: merge {num_str}_"); + let output = Command::new("git") + .args(["log", "--format=%H", "--all", "--grep", &grep_pattern]) + .current_dir(root) + .output() + .ok() + .filter(|o| o.status.success())?; + let text = String::from_utf8_lossy(&output.stdout); + let hash = text.lines().next()?.trim().to_string(); + if hash.is_empty() { None } else { Some(hash) } +} + +/// Find the human-readable name of a story by searching all pipeline stages. +fn find_story_name(root: &std::path::Path, num_str: &str) -> Option { + let stages = [ + "1_backlog", + "2_current", + "3_qa", + "4_merge", + "5_done", + "6_archived", + ]; + for stage in &stages { + let dir = 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()) { + let file_num = stem + .split('_') + .next() + .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) + .unwrap_or(""); + if file_num == num_str { + return std::fs::read_to_string(&path) + .ok() + .and_then(|c| { + crate::io::story_metadata::parse_front_matter(&c) + .ok() + .and_then(|m| m.name) + }); + } + } + } + } + } + None +} + +/// Return the `git show --stat` output for a commit. +fn get_commit_stat(root: &std::path::Path, hash: &str) -> String { + use std::process::Command; + Command::new("git") + .args(["show", "--stat", hash]) + .current_dir(root) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) + .unwrap_or_default() +} + +/// Extract up to 12 unique top-level symbol definitions from a commit diff. +/// +/// Scans added lines (`+`) for Rust `fn`, `struct`, `enum`, `type`, `trait`, +/// and `impl` declarations and returns them formatted as `` `Name` (kind) ``. +fn extract_diff_symbols(root: &std::path::Path, hash: &str) -> Vec { + use std::process::Command; + let output = Command::new("git") + .args(["show", hash]) + .current_dir(root) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) + .unwrap_or_default(); + + let mut symbols: Vec = Vec::new(); + for line in output.lines() { + if !line.starts_with('+') || line.starts_with("+++") { + continue; + } + if let Some(sym) = parse_symbol_definition(&line[1..]) { + if !symbols.contains(&sym) { + symbols.push(sym); + } + if symbols.len() >= 12 { + break; + } + } + } + symbols +} + +/// Parse a single line of code and return a formatted symbol if it opens a +/// top-level Rust definition (`fn`, `struct`, `enum`, `type`, `trait`, `impl`). +fn parse_symbol_definition(code: &str) -> Option { + let t = code.trim(); + let patterns: &[(&str, &str)] = &[ + ("pub async fn ", "fn"), + ("async fn ", "fn"), + ("pub fn ", "fn"), + ("fn ", "fn"), + ("pub struct ", "struct"), + ("struct ", "struct"), + ("pub enum ", "enum"), + ("enum ", "enum"), + ("pub type ", "type"), + ("type ", "type"), + ("pub trait ", "trait"), + ("trait ", "trait"), + ("impl ", "impl"), + ]; + for (prefix, kind) in patterns { + if let Some(rest) = t.strip_prefix(prefix) { + let name: String = rest + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if !name.is_empty() { + return Some(format!("`{name}` ({kind})")); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agents::AgentPool; + use std::collections::HashSet; + use std::sync::{Arc, Mutex}; + + use super::super::{CommandDispatch, try_handle_command}; + + fn overview_cmd_with_root(root: &std::path::Path, args: &str) -> Option { + let agents = Arc::new(AgentPool::new_test(3000)); + let ambient_rooms = Arc::new(Mutex::new(HashSet::new())); + let room_id = "!test:example.com".to_string(); + 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, &format!("@timmy overview {args}")) + } + + #[test] + fn overview_command_is_registered() { + use super::super::commands; + let found = commands().iter().any(|c| c.name == "overview"); + assert!(found, "overview command must be in the registry"); + } + + #[test] + fn overview_command_appears_in_help() { + let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help"); + let output = result.unwrap(); + assert!(output.contains("overview"), "help should list overview command: {output}"); + } + + #[test] + fn overview_command_no_args_returns_usage() { + let tmp = tempfile::TempDir::new().unwrap(); + let output = overview_cmd_with_root(tmp.path(), "").unwrap(); + assert!( + output.contains("Usage"), + "no args should show usage hint: {output}" + ); + } + + #[test] + fn overview_command_non_numeric_arg_returns_error() { + let tmp = tempfile::TempDir::new().unwrap(); + let output = overview_cmd_with_root(tmp.path(), "abc").unwrap(); + assert!( + output.contains("Invalid"), + "non-numeric arg should return error: {output}" + ); + } + + #[test] + fn overview_command_not_found_returns_friendly_message() { + // Use the real repo root but a story number that was never merged. + let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap_or(std::path::Path::new(".")); + let output = overview_cmd_with_root(repo_root, "99999").unwrap(); + assert!( + output.contains("99999"), + "not-found message should include the story number: {output}" + ); + assert!( + output.contains("backlog") || output.contains("No implementation"), + "not-found message should explain why: {output}" + ); + } + + #[test] + fn overview_command_found_shows_commit_and_stat() { + // Story 324 has a real merge commit in master. + let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap_or(std::path::Path::new(".")); + let output = overview_cmd_with_root(repo_root, "324").unwrap(); + assert!( + output.contains("**Overview: Story 324"), + "output should show story header: {output}" + ); + assert!( + output.contains("Commit:"), + "output should show commit hash: {output}" + ); + assert!( + output.contains("**Changes:**") || output.contains("**Files:**"), + "output should show file changes: {output}" + ); + } + + #[test] + fn overview_command_case_insensitive() { + let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy OVERVIEW 1"); + assert!(result.is_some(), "OVERVIEW should match case-insensitively"); + } + + // -- parse_symbol_definition -------------------------------------------- + + #[test] + fn parse_symbol_pub_fn() { + let result = parse_symbol_definition("pub fn handle_foo(ctx: &Context) -> Option {"); + assert_eq!(result, Some("`handle_foo` (fn)".to_string())); + } + + #[test] + fn parse_symbol_pub_struct() { + let result = parse_symbol_definition("pub struct SlackTransport {"); + assert_eq!(result, Some("`SlackTransport` (struct)".to_string())); + } + + #[test] + fn parse_symbol_impl() { + let result = parse_symbol_definition("impl ChatTransport for SlackTransport {"); + assert_eq!(result, Some("`ChatTransport` (impl)".to_string())); + } + + #[test] + fn parse_symbol_no_match() { + let result = parse_symbol_definition(" let x = 42;"); + assert_eq!(result, None); + } + + #[test] + fn parse_symbol_pub_enum() { + let result = parse_symbol_definition("pub enum QaMode {"); + assert_eq!(result, Some("`QaMode` (enum)".to_string())); + } + + #[test] + fn parse_symbol_pub_type() { + let result = parse_symbol_definition("pub type SlackHistory = Arc>>>;"); + assert_eq!(result, Some("`SlackHistory` (type)".to_string())); + } +} diff --git a/server/src/matrix/commands/show.rs b/server/src/matrix/commands/show.rs new file mode 100644 index 0000000..43a9e84 --- /dev/null +++ b/server/src/matrix/commands/show.rs @@ -0,0 +1,202 @@ +//! Handler for the `show` command. + +use super::CommandContext; + +/// Display the full markdown text of a work item identified by its numeric ID. +/// +/// Searches all pipeline stages in order and returns the raw file contents of +/// the first matching story, bug, or spike. Returns a friendly message when +/// no match is found. +pub(super) fn handle_show(ctx: &CommandContext) -> Option { + let num_str = ctx.args.trim(); + if num_str.is_empty() { + return Some(format!( + "Usage: `{} show `\n\nDisplays the full text of a story, bug, or spike.", + ctx.bot_name + )); + } + if !num_str.chars().all(|c| c.is_ascii_digit()) { + return Some(format!( + "Invalid story number: `{num_str}`. Usage: `{} show `", + ctx.bot_name + )); + } + + let stages = [ + "1_backlog", + "2_current", + "3_qa", + "4_merge", + "5_done", + "6_archived", + ]; + + for stage in &stages { + let dir = ctx + .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()) { + let file_num = stem + .split('_') + .next() + .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) + .unwrap_or(""); + if file_num == num_str { + return match std::fs::read_to_string(&path) { + Ok(contents) => Some(contents), + Err(e) => Some(format!("Failed to read story {num_str}: {e}")), + }; + } + } + } + } + } + + Some(format!( + "No story, bug, or spike with number **{num_str}** found." + )) +} + +#[cfg(test)] +mod tests { + use crate::agents::AgentPool; + use std::collections::HashSet; + use std::sync::{Arc, Mutex}; + + use super::super::{CommandDispatch, try_handle_command}; + + fn show_cmd_with_root(root: &std::path::Path, args: &str) -> Option { + let agents = Arc::new(AgentPool::new_test(3000)); + let ambient_rooms = Arc::new(Mutex::new(HashSet::new())); + let room_id = "!test:example.com".to_string(); + 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, &format!("@timmy show {args}")) + } + + fn write_story_file(root: &std::path::Path, stage: &str, filename: &str, content: &str) { + let dir = root.join(".story_kit/work").join(stage); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(filename), content).unwrap(); + } + + #[test] + fn show_command_is_registered() { + use super::super::commands; + let found = commands().iter().any(|c| c.name == "show"); + assert!(found, "show command must be in the registry"); + } + + #[test] + fn show_command_appears_in_help() { + let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help"); + let output = result.unwrap(); + assert!(output.contains("show"), "help should list show command: {output}"); + } + + #[test] + fn show_command_no_args_returns_usage() { + let tmp = tempfile::TempDir::new().unwrap(); + let output = show_cmd_with_root(tmp.path(), "").unwrap(); + assert!( + output.contains("Usage"), + "no args should show usage hint: {output}" + ); + } + + #[test] + fn show_command_non_numeric_args_returns_error() { + let tmp = tempfile::TempDir::new().unwrap(); + let output = show_cmd_with_root(tmp.path(), "abc").unwrap(); + assert!( + output.contains("Invalid"), + "non-numeric arg should return error message: {output}" + ); + } + + #[test] + fn show_command_not_found_returns_friendly_message() { + let tmp = tempfile::TempDir::new().unwrap(); + let output = show_cmd_with_root(tmp.path(), "999").unwrap(); + assert!( + output.contains("999"), + "not-found message should include the queried number: {output}" + ); + assert!( + output.contains("found"), + "not-found message should say not found: {output}" + ); + } + + #[test] + fn show_command_finds_story_in_backlog() { + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "1_backlog", + "305_story_show_command.md", + "---\nname: Show command\n---\n\n# Story 305\n\nFull story text here.", + ); + let output = show_cmd_with_root(tmp.path(), "305").unwrap(); + assert!( + output.contains("Full story text here."), + "show should return full story content: {output}" + ); + } + + #[test] + fn show_command_finds_story_in_current() { + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "2_current", + "42_story_do_something.md", + "---\nname: Do something\n---\n\n# Story 42\n\nIn progress.", + ); + let output = show_cmd_with_root(tmp.path(), "42").unwrap(); + assert!( + output.contains("In progress."), + "show should return story from current stage: {output}" + ); + } + + #[test] + fn show_command_finds_bug() { + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "1_backlog", + "7_bug_crash_on_login.md", + "---\nname: Crash on login\n---\n\n## Symptom\n\nCrashes.", + ); + let output = show_cmd_with_root(tmp.path(), "7").unwrap(); + assert!( + output.contains("Symptom"), + "show should return bug content: {output}" + ); + } + + #[test] + fn show_command_case_insensitive() { + let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy SHOW 1"); + assert!(result.is_some(), "SHOW should match case-insensitively"); + } +} diff --git a/server/src/matrix/commands/status.rs b/server/src/matrix/commands/status.rs new file mode 100644 index 0000000..b7d6d8e --- /dev/null +++ b/server/src/matrix/commands/status.rs @@ -0,0 +1,354 @@ +//! Handler for the `status` command and pipeline status helpers. + +use crate::agents::{AgentPool, AgentStatus}; +use crate::config::ProjectConfig; +use std::collections::{HashMap, HashSet}; + +use super::CommandContext; + +pub(super) fn handle_status(ctx: &CommandContext) -> Option { + Some(build_pipeline_status(ctx.project_root, ctx.agents)) +} + +/// 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"` +pub(super) 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(super) 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: HashMap = active_agents + .iter() + .filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending)) + .map(|a| (a.story_id.clone(), a)) + .collect(); + + // Read token usage once for all stories to avoid repeated file I/O. + let cost_by_story: HashMap = + crate::agents::token_usage::read_all(project_root) + .unwrap_or_default() + .into_iter() + .fold(HashMap::new(), |mut map, r| { + *map.entry(r.story_id).or_insert(0.0) += r.usage.total_cost_usd; + map + }); + + 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()); + let cost_suffix = cost_by_story + .get(story_id) + .filter(|&&c| c > 0.0) + .map(|c| format!(" — ${c:.2}")) + .unwrap_or_default(); + 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}{cost_suffix} — {} ({model_str})\n", + agent.agent_name + )); + } else { + out.push_str(&format!(" • {display}{cost_suffix}\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: 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 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agents::AgentPool; + + #[test] + fn status_command_matches() { + let result = super::super::tests::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 = super::super::tests::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 = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy STATUS"); + assert!(result.is_some(), "STATUS should match case-insensitively"); + } + + // -- 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}" + ); + } + + // -- token cost in status output ---------------------------------------- + + #[test] + fn status_shows_cost_when_token_usage_exists() { + 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(); + + 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(); + + // Write token usage for this story. + let usage = crate::agents::TokenUsage { + input_tokens: 100, + output_tokens: 200, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + total_cost_usd: 0.29, + }; + let record = crate::agents::token_usage::build_record( + "293_story_register_all_bot_commands", + "coder-1", + None, + usage, + ); + crate::agents::token_usage::append_record(tmp.path(), &record).unwrap(); + + let agents = AgentPool::new_test(3000); + let output = build_pipeline_status(tmp.path(), &agents); + + assert!( + output.contains("293 — Register all bot commands — $0.29"), + "output must show cost next to story: {output}" + ); + } + + #[test] + fn status_no_cost_when_no_usage() { + 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(); + + 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(); + + // No token usage written. + let agents = AgentPool::new_test(3000); + let output = build_pipeline_status(tmp.path(), &agents); + + assert!( + !output.contains("$"), + "output must not show cost when no usage exists: {output}" + ); + } + + #[test] + fn status_aggregates_multiple_records_per_story() { + 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(); + + 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(); + + // Write two records for the same story — costs should be summed. + for cost in [0.10, 0.19] { + let usage = crate::agents::TokenUsage { + input_tokens: 50, + output_tokens: 100, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + total_cost_usd: cost, + }; + let record = crate::agents::token_usage::build_record( + "293_story_register_all_bot_commands", + "coder-1", + None, + usage, + ); + crate::agents::token_usage::append_record(tmp.path(), &record).unwrap(); + } + + let agents = AgentPool::new_test(3000); + let output = build_pipeline_status(tmp.path(), &agents); + + assert!( + output.contains("293 — Register all bot commands — $0.29"), + "output must show aggregated cost: {output}" + ); + } +}