From 73c86b6946cf04c460f197b693837b304183a3e4 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Mar 2026 09:14:04 +0000 Subject: [PATCH] story-kit: merge 293_story_register_all_bot_commands_in_the_command_registry Moves status, ambient, and help commands into a unified command registry in commands.rs. Help output now automatically lists all registered commands. Resolved merge conflict with 1_backlog rename. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/LozengeFlyContext.test.tsx | 56 ++ frontend/src/components/StagePanel.test.tsx | 32 + server/src/matrix/bot.rs | 568 ++---------------- server/src/matrix/commands.rs | 440 +++++++++++++- 4 files changed, 539 insertions(+), 557 deletions(-) diff --git a/frontend/src/components/LozengeFlyContext.test.tsx b/frontend/src/components/LozengeFlyContext.test.tsx index 32543dc..57f12c6 100644 --- a/frontend/src/components/LozengeFlyContext.test.tsx +++ b/frontend/src/components/LozengeFlyContext.test.tsx @@ -59,6 +59,8 @@ describe("AgentLozenge fixed intrinsic width", () => { error: null, merge_failure: null, agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, + review_hold: null, + manual_qa: null, }, ]; const pipeline = makePipeline({ current: items }); @@ -111,6 +113,8 @@ describe("LozengeFlyProvider fly-in visibility", () => { error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, + review_hold: null, + manual_qa: null, }, ], }); @@ -151,6 +155,8 @@ describe("LozengeFlyProvider fly-in visibility", () => { model: null, status: "running", }, + review_hold: null, + manual_qa: null, }, ], }); @@ -213,6 +219,8 @@ describe("LozengeFlyProvider fly-in clone", () => { error: null, merge_failure: null, agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, + review_hold: null, + manual_qa: null, }, ], }); @@ -254,6 +262,8 @@ describe("LozengeFlyProvider fly-in clone", () => { error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, + review_hold: null, + manual_qa: null, }, ], }); @@ -301,6 +311,8 @@ describe("LozengeFlyProvider fly-in clone", () => { error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, + review_hold: null, + manual_qa: null, }, ], }); @@ -370,6 +382,8 @@ describe("LozengeFlyProvider fly-out", () => { error: null, merge_failure: null, agent: { agent_name: "coder-1", model: "haiku", status: "completed" }, + review_hold: null, + manual_qa: null, }, ], }); @@ -395,6 +409,8 @@ describe("LozengeFlyProvider fly-out", () => { error: null, merge_failure: null, agent: null, + review_hold: null, + manual_qa: null, }, ], }); @@ -427,6 +443,8 @@ describe("AgentLozenge idle vs active appearance", () => { error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, + review_hold: null, + manual_qa: null, }, ]; const { container } = render( @@ -451,6 +469,8 @@ describe("AgentLozenge idle vs active appearance", () => { error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "pending" }, + review_hold: null, + manual_qa: null, }, ]; const { container } = render( @@ -475,6 +495,8 @@ describe("AgentLozenge idle vs active appearance", () => { error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, + review_hold: null, + manual_qa: null, }, ]; const { container } = render( @@ -526,6 +548,8 @@ describe("hiddenRosterAgents: assigned agents are absent from roster", () => { error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, + review_hold: null, + manual_qa: null, }, ], }); @@ -547,6 +571,8 @@ describe("hiddenRosterAgents: assigned agents are absent from roster", () => { error: null, merge_failure: null, agent: null, + review_hold: null, + manual_qa: null, }, ], }); @@ -569,6 +595,8 @@ describe("hiddenRosterAgents: assigned agents are absent from roster", () => { error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, + review_hold: null, + manual_qa: null, }, ], }); @@ -629,6 +657,8 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", () error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "completed" }, + review_hold: null, + manual_qa: null, }, ], }); @@ -640,6 +670,8 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", () error: null, merge_failure: null, agent: null, + review_hold: null, + manual_qa: null, }, ], }); @@ -682,6 +714,8 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", () error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "completed" }, + review_hold: null, + manual_qa: null, }, ], }); @@ -693,6 +727,8 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", () error: null, merge_failure: null, agent: null, + review_hold: null, + manual_qa: null, }, ], }); @@ -766,6 +802,8 @@ describe("LozengeFlyProvider agent swap (name change)", () => { error: null, merge_failure: null, agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, + review_hold: null, + manual_qa: null, }, ], }); @@ -777,6 +815,8 @@ describe("LozengeFlyProvider agent swap (name change)", () => { error: null, merge_failure: null, agent: { agent_name: "coder-2", model: "haiku", status: "running" }, + review_hold: null, + manual_qa: null, }, ], }); @@ -861,6 +901,8 @@ describe("LozengeFlyProvider fly-out without roster element", () => { model: null, status: "completed", }, + review_hold: null, + manual_qa: null, }, ], }); @@ -872,6 +914,8 @@ describe("LozengeFlyProvider fly-out without roster element", () => { error: null, merge_failure: null, agent: null, + review_hold: null, + manual_qa: null, }, ], }); @@ -943,6 +987,8 @@ describe("FlyingLozengeClone initial non-flying render", () => { error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, + review_hold: null, + manual_qa: null, }, ], }); @@ -1018,6 +1064,8 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", () error: null, merge_failure: null, agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, + review_hold: null, + manual_qa: null, }, ], }); @@ -1029,6 +1077,8 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", () error: null, merge_failure: null, agent: { agent_name: "coder-2", model: "haiku", status: "running" }, + review_hold: null, + manual_qa: null, }, ], }); @@ -1095,6 +1145,8 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", () error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, + review_hold: null, + manual_qa: null, }, ], }); @@ -1106,6 +1158,8 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", () error: null, merge_failure: null, agent: { agent_name: "coder-2", model: null, status: "running" }, + review_hold: null, + manual_qa: null, }, ], }); @@ -1191,6 +1245,8 @@ describe("Bug 137: animations remain functional through sustained agent activity error: null, merge_failure: null, agent: { agent_name: agentName, model: null, status: "running" }, + review_hold: null, + manual_qa: null, }, ], }); diff --git a/frontend/src/components/StagePanel.test.tsx b/frontend/src/components/StagePanel.test.tsx index 3e637ae..dcbedb3 100644 --- a/frontend/src/components/StagePanel.test.tsx +++ b/frontend/src/components/StagePanel.test.tsx @@ -17,6 +17,8 @@ describe("StagePanel", () => { error: null, merge_failure: null, agent: null, + review_hold: null, + manual_qa: null, }, ]; render(); @@ -37,6 +39,8 @@ describe("StagePanel", () => { model: "sonnet", status: "running", }, + review_hold: null, + manual_qa: null, }, ]; render(); @@ -56,6 +60,8 @@ describe("StagePanel", () => { model: null, status: "running", }, + review_hold: null, + manual_qa: null, }, ]; render(); @@ -74,6 +80,8 @@ describe("StagePanel", () => { model: "haiku", status: "pending", }, + review_hold: null, + manual_qa: null, }, ]; render(); @@ -88,6 +96,8 @@ describe("StagePanel", () => { error: null, merge_failure: null, agent: null, + review_hold: null, + manual_qa: null, }, ]; render(); @@ -102,6 +112,8 @@ describe("StagePanel", () => { error: "Missing front matter", merge_failure: null, agent: null, + review_hold: null, + manual_qa: null, }, ]; render(); @@ -116,6 +128,8 @@ describe("StagePanel", () => { error: null, merge_failure: null, agent: null, + review_hold: null, + manual_qa: null, }, ]; render(); @@ -132,6 +146,8 @@ describe("StagePanel", () => { error: null, merge_failure: null, agent: null, + review_hold: null, + manual_qa: null, }, ]; render(); @@ -148,6 +164,8 @@ describe("StagePanel", () => { error: null, merge_failure: null, agent: null, + review_hold: null, + manual_qa: null, }, ]; render(); @@ -164,6 +182,8 @@ describe("StagePanel", () => { error: null, merge_failure: null, agent: null, + review_hold: null, + manual_qa: null, }, ]; render(); @@ -180,6 +200,8 @@ describe("StagePanel", () => { error: null, merge_failure: null, agent: null, + review_hold: null, + manual_qa: null, }, ]; render(); @@ -199,6 +221,8 @@ describe("StagePanel", () => { error: null, merge_failure: null, agent: null, + review_hold: null, + manual_qa: null, }, ]; render(); @@ -215,6 +239,8 @@ describe("StagePanel", () => { error: null, merge_failure: null, agent: null, + review_hold: null, + manual_qa: null, }, ]; render(); @@ -231,6 +257,8 @@ describe("StagePanel", () => { error: null, merge_failure: null, agent: null, + review_hold: null, + manual_qa: null, }, ]; render(); @@ -247,6 +275,8 @@ describe("StagePanel", () => { error: null, merge_failure: "Squash merge failed: conflicts in Cargo.lock", agent: null, + review_hold: null, + manual_qa: null, }, ]; render(); @@ -266,6 +296,8 @@ describe("StagePanel", () => { error: null, merge_failure: null, agent: null, + review_hold: null, + manual_qa: null, }, ]; render(); diff --git a/server/src/matrix/bot.rs b/server/src/matrix/bot.rs index 456933a..9b6c3b5 100644 --- a/server/src/matrix/bot.rs +++ b/server/src/matrix/bot.rs @@ -1,5 +1,4 @@ -use crate::agents::{AgentPool, AgentStatus}; -use crate::config::ProjectConfig; +use crate::agents::AgentPool; use crate::http::context::{PermissionDecision, PermissionForward}; use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult}; use crate::slog; @@ -32,7 +31,7 @@ use matrix_sdk::encryption::verification::{ }; use matrix_sdk::ruma::events::key::verification::request::ToDeviceKeyVerificationRequestEvent; -use super::config::{BotConfig, save_ambient_rooms}; +use super::config::BotConfig; // --------------------------------------------------------------------------- // Conversation history types @@ -103,7 +102,11 @@ pub fn load_history(project_root: &std::path::Path) -> HashMap().ok().map(|room_id| (room_id, v))) + .filter_map(|(k, v)| { + k.parse::() + .ok() + .map(|room_id| (room_id, v)) + }) .collect() } @@ -164,9 +167,9 @@ pub struct BotContext { /// in bot.toml; defaults to "Assistant" when unset. pub bot_name: String, /// Set of room IDs where ambient mode is active. In ambient mode the bot - /// responds to all messages rather than only addressed ones. This is - /// in-memory only — the state does not survive a bot restart. - pub ambient_rooms: Arc>>, + /// responds to all messages rather than only addressed ones. + /// Uses a sync mutex since locks are never held across await points. + pub ambient_rooms: Arc>>, /// Agent pool for checking agent availability. pub agents: Arc, } @@ -187,162 +190,6 @@ pub fn format_startup_announcement(bot_name: &str) -> String { // Command extraction // --------------------------------------------------------------------------- -/// Extract the command portion from a bot-addressed message. -/// -/// Strips the leading bot mention (full Matrix user ID, `@localpart`, or -/// display name) plus any trailing punctuation (`,`, `:`) and whitespace, -/// then returns the remainder in lowercase. Returns `None` when no -/// recognized mention prefix is found in the message. -pub fn extract_command(body: &str, bot_name: &str, bot_user_id: &OwnedUserId) -> Option { - let full_id = bot_user_id.as_str().to_lowercase(); - let at_localpart = format!("@{}", bot_user_id.localpart().to_lowercase()); - let bot_name_lower = bot_name.to_lowercase(); - let body_lower = body.trim().to_lowercase(); - - let stripped = if let Some(s) = body_lower.strip_prefix(&full_id) { - s - } else if let Some(s) = body_lower.strip_prefix(&at_localpart) { - // Guard against matching a longer @mention (e.g. "@timmybot" vs "@timmy"). - let next = s.chars().next(); - if next.is_some_and(|c| c.is_alphanumeric() || c == '-' || c == '_') { - return None; - } - s - } else if let Some(s) = body_lower.strip_prefix(&bot_name_lower) { - // Guard against matching a longer display-name prefix. - let next = s.chars().next(); - if next.is_some_and(|c| c.is_alphanumeric() || c == '-' || c == '_') { - return None; - } - s - } else { - return None; - }; - - // Strip leading separators (`,`, `:`) and whitespace after the mention. - let cmd = stripped.trim_start_matches(|c: char| c == ':' || c == ',' || c.is_whitespace()); - Some(cmd.trim().to_string()) -} - -// --------------------------------------------------------------------------- -// Pipeline status formatter -// --------------------------------------------------------------------------- - -/// Read all story IDs and names from a pipeline stage directory. -fn read_stage_items( - project_root: &std::path::Path, - stage_dir: &str, -) -> Vec<(String, Option)> { - let dir = project_root.join(".story_kit").join("work").join(stage_dir); - if !dir.exists() { - return Vec::new(); - } - let mut items = Vec::new(); - if let Ok(entries) = std::fs::read_dir(&dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("md") { - continue; - } - if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { - let name = std::fs::read_to_string(&path).ok().and_then(|contents| { - crate::io::story_metadata::parse_front_matter(&contents) - .ok() - .and_then(|m| m.name) - }); - items.push((stem.to_string(), name)); - } - } - } - items.sort_by(|a, b| a.0.cmp(&b.0)); - items -} - -/// Build the full pipeline status text formatted for Matrix (markdown). -pub fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) -> String { - // Build a map from story_id → active AgentInfo for quick lookup. - let active_agents = agents.list_agents().unwrap_or_default(); - let active_map: std::collections::HashMap = active_agents - .iter() - .filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending)) - .map(|a| (a.story_id.clone(), a)) - .collect(); - - let config = ProjectConfig::load(project_root).ok(); - - let mut out = String::from("**Pipeline Status**\n\n"); - - let stages = [ - ("1_backlog", "Backlog"), - ("2_current", "In Progress"), - ("3_qa", "QA"), - ("4_merge", "Merge"), - ("5_done", "Done"), - ]; - - for (dir, label) in &stages { - let items = read_stage_items(project_root, dir); - let count = items.len(); - out.push_str(&format!("**{label}** ({count})\n")); - if items.is_empty() { - out.push_str(" *(none)*\n"); - } else { - for (story_id, name) in &items { - let display = match name { - Some(n) => format!("{story_id} — {n}"), - None => story_id.clone(), - }; - 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} — {} ({}) [{}]\n", - agent.agent_name, model_str, agent.status - )); - } else { - out.push_str(&format!(" • {display}\n")); - } - } - } - out.push('\n'); - } - - // Free agents: configured agents not currently running or pending. - out.push_str("**Free Agents**\n"); - if let Some(cfg) = &config { - let busy_names: std::collections::HashSet = active_agents - .iter() - .filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending)) - .map(|a| a.agent_name.clone()) - .collect(); - - let free: Vec = cfg - .agent - .iter() - .filter(|a| !busy_names.contains(&a.name)) - .map(|a| match &a.model { - Some(m) => format!("{} ({})", a.name, m), - None => a.name.clone(), - }) - .collect(); - - if free.is_empty() { - out.push_str(" *(none — all agents busy)*\n"); - } else { - for name in &free { - out.push_str(&format!(" • {name}\n")); - } - } - } else { - out.push_str(" *(no agent config found)*\n"); - } - - out -} - // --------------------------------------------------------------------------- // Bot entry point // --------------------------------------------------------------------------- @@ -399,10 +246,7 @@ pub async fn run_bot( .ok_or_else(|| "No user ID after login".to_string())? .to_owned(); - slog!( - "[matrix-bot] Logged in as {bot_user_id} (device: {})", - login_response.device_id - ); + slog!("[matrix-bot] Logged in as {bot_user_id} (device: {})", login_response.device_id); // Bootstrap cross-signing keys for E2EE verification support. // Pass the bot's password for UIA (User-Interactive Authentication) — @@ -530,13 +374,11 @@ pub async fn run_bot( pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())), permission_timeout_secs: config.permission_timeout_secs, bot_name, - ambient_rooms: Arc::new(TokioMutex::new(persisted_ambient)), + ambient_rooms: Arc::new(std::sync::Mutex::new(persisted_ambient)), agents, }; - slog!( - "[matrix-bot] Cryptographic identity verification is always ON — commands from unencrypted rooms or unverified devices are rejected" - ); + slog!("[matrix-bot] Cryptographic identity verification is always ON — commands from unencrypted rooms or unverified devices are rejected"); // Register event handlers and inject shared context. client.add_event_handler_context(ctx); @@ -650,48 +492,6 @@ fn contains_word(haystack: &str, needle: &str) -> bool { false } -/// Parse an ambient-mode toggle command from a message body. -/// -/// Recognises the following (case-insensitive) forms, with or without a -/// leading bot mention: -/// -/// - `@botname ambient on` / `@botname:server ambient on` -/// - `botname ambient on` -/// - `ambient on` -/// -/// and the `off` variants. -/// -/// Returns `Some(true)` for "ambient on", `Some(false)` for "ambient off", -/// and `None` when the body is not an ambient mode command. -pub fn parse_ambient_command( - body: &str, - bot_user_id: &OwnedUserId, - bot_name: &str, -) -> Option { - let lower = body.trim().to_ascii_lowercase(); - let display_lower = bot_name.to_ascii_lowercase(); - let localpart_lower = bot_user_id.localpart().to_ascii_lowercase(); - - // Strip a leading @mention (handles "@localpart" and "@localpart:server"). - let rest = if let Some(after_at) = lower.strip_prefix('@') { - // Skip everything up to the first whitespace (the full mention token). - let word_end = after_at.find(char::is_whitespace).unwrap_or(after_at.len()); - after_at[word_end..].trim() - } else if let Some(after) = lower.strip_prefix(display_lower.as_str()) { - after.trim() - } else if let Some(after) = lower.strip_prefix(localpart_lower.as_str()) { - after.trim() - } else { - lower.as_str() - }; - - match rest { - "ambient on" => Some(true), - "ambient off" => Some(false), - _ => None, - } -} - /// Returns `true` if the message's `relates_to` field references an event that /// the bot previously sent (i.e. the message is a reply or thread-reply to a /// bot message). @@ -728,7 +528,10 @@ async fn is_reply_to_bot( /// is the correct trust model: a user is accepted when they have cross-signing /// configured, regardless of whether the bot has run an explicit verification /// ceremony with a specific device. -async fn check_sender_verified(client: &Client, sender: &OwnedUserId) -> Result { +async fn check_sender_verified( + client: &Client, + sender: &OwnedUserId, +) -> Result { let identity = client .encryption() .get_user_identity(sender) @@ -794,9 +597,8 @@ async fn on_to_device_verification_request( } break; } - VerificationRequestState::Done | VerificationRequestState::Cancelled(_) => { - break; - } + VerificationRequestState::Done + | VerificationRequestState::Cancelled(_) => break, _ => {} } } @@ -898,7 +700,7 @@ async fn on_room_message( // ambient mode is enabled for this room. let is_addressed = mentions_bot(&body, formatted_body.as_deref(), &ctx.bot_user_id) || is_reply_to_bot(ev.content.relates_to.as_ref(), &ctx.bot_sent_event_ids).await; - let is_ambient = ctx.ambient_rooms.lock().await.contains(&incoming_room_id); + let is_ambient = ctx.ambient_rooms.lock().unwrap().contains(&incoming_room_id); if !is_addressed && !is_ambient { slog!( @@ -969,54 +771,23 @@ async fn on_room_message( } } - // Check for ambient mode toggle commands. Commands are only recognised - // from addressed messages so they can't be accidentally triggered by - // ambient-mode traffic from other users. - let ambient_cmd = is_addressed - .then(|| parse_ambient_command(&body, &ctx.bot_user_id, &ctx.bot_name)) - .flatten(); - if let Some(enable) = ambient_cmd { - let ambient_room_ids: Vec = { - let mut ambient = ctx.ambient_rooms.lock().await; - if enable { - ambient.insert(incoming_room_id.clone()); - } else { - ambient.remove(&incoming_room_id); - } - ambient.iter().map(|r| r.to_string()).collect() - }; // lock released before the async send below - - // Persist updated ambient rooms to bot.toml so the state survives restarts. - save_ambient_rooms(&ctx.project_root, &ambient_room_ids); - - let confirmation = if enable { - "Ambient mode on. I'll respond to all messages in this room." - } else { - "Ambient mode off. I'll only respond when mentioned." - }; - let html = markdown_to_html(confirmation); - if let Ok(resp) = room - .send(RoomMessageEventContent::text_html(confirmation, html)) - .await - { - ctx.bot_sent_event_ids.lock().await.insert(resp.event_id); - } - slog!( - "[matrix-bot] Ambient mode {} for room {}", - if enable { "enabled" } else { "disabled" }, - incoming_room_id - ); - return; - } - let sender = ev.sender.to_string(); let user_message = body; slog!("[matrix-bot] Message from {sender}: {user_message}"); - // Check for bot-level commands (e.g. "help") before invoking the LLM. - if let Some(response) = - super::commands::try_handle_command(&ctx.bot_name, ctx.bot_user_id.as_str(), &user_message) - { + // Check for bot-level commands (help, status, ambient, …) before invoking + // the LLM. All commands are registered in commands.rs — no special-casing + // needed here. + let dispatch = super::commands::CommandDispatch { + bot_name: &ctx.bot_name, + bot_user_id: ctx.bot_user_id.as_str(), + project_root: &ctx.project_root, + agents: &ctx.agents, + ambient_rooms: &ctx.ambient_rooms, + room_id: &incoming_room_id, + is_addressed, + }; + if let Some(response) = super::commands::try_handle_command(&dispatch, &user_message) { slog!("[matrix-bot] Handled bot command from {sender}"); let html = markdown_to_html(&response); if let Ok(resp) = room @@ -1052,28 +823,14 @@ async fn handle_message( sender: String, user_message: String, ) { - // Handle built-in commands before invoking Claude. - if let Some(cmd) = extract_command(&user_message, &ctx.bot_name, &ctx.bot_user_id) - && cmd == "status" - { - let project_root = ctx.project_root.clone(); - let status_text = build_pipeline_status(&project_root, &ctx.agents); - let html = markdown_to_html(&status_text); - if let Ok(resp) = room - .send(RoomMessageEventContent::text_html(status_text, html)) - .await - { - ctx.bot_sent_event_ids.lock().await.insert(resp.event_id); - } - return; - } - // Look up the room's existing Claude Code session ID (if any) so we can // resume the conversation with structured API messages instead of // flattening history into a text prefix. let resume_session_id: Option = { let guard = ctx.history.lock().await; - guard.get(&room_id).and_then(|conv| conv.session_id.clone()) + guard + .get(&room_id) + .and_then(|conv| conv.session_id.clone()) }; // The prompt is just the current message with sender attribution. @@ -1248,11 +1005,7 @@ async fn handle_message( let conv = guard.entry(room_id).or_default(); // Store the session ID so the next turn uses --resume. - slog!( - "[matrix-bot] storing session_id: {:?} (was: {:?})", - new_session_id, - conv.session_id - ); + slog!("[matrix-bot] storing session_id: {:?} (was: {:?})", new_session_id, conv.session_id); if new_session_id.is_some() { conv.session_id = new_session_id; } @@ -1554,7 +1307,7 @@ mod tests { pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())), permission_timeout_secs: 120, bot_name: "Assistant".to_string(), - ambient_rooms: Arc::new(TokioMutex::new(HashSet::new())), + ambient_rooms: Arc::new(std::sync::Mutex::new(HashSet::new())), agents: Arc::new(AgentPool::new_test(3000)), }; // Clone must work (required by Matrix SDK event handler injection). @@ -2009,125 +1762,7 @@ mod tests { #[test] fn startup_announcement_uses_configured_display_name_not_hardcoded() { assert_eq!(format_startup_announcement("HAL"), "HAL is online."); - assert_eq!( - format_startup_announcement("Assistant"), - "Assistant is online." - ); - } - - // -- extract_command (status trigger) ------------------------------------ - - #[test] - fn extract_command_returns_status_for_bot_name_prefix() { - let uid = make_user_id("@assistant:example.com"); - let result = extract_command("Assistant status", "Assistant", &uid); - assert_eq!(result.as_deref(), Some("status")); - } - - #[test] - fn extract_command_returns_status_for_at_localpart_prefix() { - let uid = make_user_id("@assistant:example.com"); - let result = extract_command("@assistant status", "Assistant", &uid); - assert_eq!(result.as_deref(), Some("status")); - } - - #[test] - fn extract_command_returns_status_for_full_id_prefix() { - let uid = make_user_id("@assistant:example.com"); - let result = extract_command("@assistant:example.com status", "Assistant", &uid); - assert_eq!(result.as_deref(), Some("status")); - } - - #[test] - fn extract_command_returns_none_when_no_bot_mention() { - let uid = make_user_id("@assistant:example.com"); - let result = extract_command("status", "Assistant", &uid); - assert!(result.is_none()); - } - - #[test] - fn extract_command_handles_punctuation_after_mention() { - let uid = make_user_id("@assistant:example.com"); - let result = extract_command("@assistant: status", "Assistant", &uid); - assert_eq!(result.as_deref(), Some("status")); - } - - // -- build_pipeline_status ----------------------------------------------- - - fn write_story_file(dir: &std::path::Path, stage: &str, filename: &str, name: &str) { - let stage_dir = dir.join(".story_kit").join("work").join(stage); - std::fs::create_dir_all(&stage_dir).unwrap(); - let content = format!("---\nname: \"{name}\"\n---\n\n# {name}\n"); - std::fs::write(stage_dir.join(filename), content).unwrap(); - } - - #[test] - fn build_pipeline_status_includes_all_stages() { - let dir = tempfile::tempdir().unwrap(); - let pool = AgentPool::new_test(3001); - let out = build_pipeline_status(dir.path(), &pool); - - assert!(out.contains("Backlog"), "missing Backlog: {out}"); - assert!(out.contains("In Progress"), "missing In Progress: {out}"); - assert!(out.contains("QA"), "missing QA: {out}"); - assert!(out.contains("Merge"), "missing Merge: {out}"); - assert!(out.contains("Done"), "missing Done: {out}"); - } - - #[test] - fn build_pipeline_status_shows_story_id_and_name() { - let dir = tempfile::tempdir().unwrap(); - write_story_file( - dir.path(), - "1_backlog", - "42_story_do_something.md", - "Do Something", - ); - let pool = AgentPool::new_test(3001); - let out = build_pipeline_status(dir.path(), &pool); - - assert!( - out.contains("42_story_do_something"), - "missing story id: {out}" - ); - assert!(out.contains("Do Something"), "missing story name: {out}"); - } - - #[test] - fn build_pipeline_status_includes_free_agents_section() { - let dir = tempfile::tempdir().unwrap(); - let pool = AgentPool::new_test(3001); - let out = build_pipeline_status(dir.path(), &pool); - - assert!( - out.contains("Free Agents"), - "missing Free Agents section: {out}" - ); - } - - #[test] - fn build_pipeline_status_uses_markdown_bold_headings() { - let dir = tempfile::tempdir().unwrap(); - let pool = AgentPool::new_test(3001); - let out = build_pipeline_status(dir.path(), &pool); - - // Stages and headers should use markdown bold (**text**). - assert!( - out.contains("**Pipeline Status**"), - "missing bold title: {out}" - ); - assert!(out.contains("**Pipeline Status**"), "missing bold title: {out}"); - assert!(out.contains("**Backlog**"), "stage should use bold: {out}"); - } - - #[test] - fn build_pipeline_status_shows_none_for_empty_stages() { - let dir = tempfile::tempdir().unwrap(); - let pool = AgentPool::new_test(3001); - let out = build_pipeline_status(dir.path(), &pool); - - // Empty stages show *(none)* - assert!(out.contains("*(none)*"), "expected none marker: {out}"); + assert_eq!(format_startup_announcement("Assistant"), "Assistant is online."); } // -- bot_name / system prompt ------------------------------------------- @@ -2154,129 +1789,4 @@ mod tests { assert_eq!(resolve_bot_name(Some("Timmy".to_string())), "Timmy"); } - // -- parse_ambient_command ------------------------------------------------ - - #[test] - fn ambient_command_on_with_at_mention() { - let uid = make_user_id("@timmy:homeserver.local"); - assert_eq!( - parse_ambient_command("@timmy ambient on", &uid, "Timmy"), - Some(true) - ); - } - - #[test] - fn ambient_command_off_with_at_mention() { - let uid = make_user_id("@timmy:homeserver.local"); - assert_eq!( - parse_ambient_command("@timmy ambient off", &uid, "Timmy"), - Some(false) - ); - } - - #[test] - fn ambient_command_on_with_full_user_id() { - let uid = make_user_id("@timmy:homeserver.local"); - assert_eq!( - parse_ambient_command("@timmy:homeserver.local ambient on", &uid, "Timmy"), - Some(true) - ); - } - - #[test] - fn ambient_command_on_with_display_name() { - let uid = make_user_id("@timmy:homeserver.local"); - assert_eq!( - parse_ambient_command("timmy ambient on", &uid, "Timmy"), - Some(true) - ); - } - - #[test] - fn ambient_command_off_with_display_name() { - let uid = make_user_id("@timmy:homeserver.local"); - assert_eq!( - parse_ambient_command("timmy ambient off", &uid, "Timmy"), - Some(false) - ); - } - - #[test] - fn ambient_command_on_bare() { - // "ambient on" without any bot mention is also recognised. - let uid = make_user_id("@timmy:homeserver.local"); - assert_eq!( - parse_ambient_command("ambient on", &uid, "Timmy"), - Some(true) - ); - } - - #[test] - fn ambient_command_off_bare() { - let uid = make_user_id("@timmy:homeserver.local"); - assert_eq!( - parse_ambient_command("ambient off", &uid, "Timmy"), - Some(false) - ); - } - - #[test] - fn ambient_command_case_insensitive() { - let uid = make_user_id("@timmy:homeserver.local"); - assert_eq!( - parse_ambient_command("@Timmy AMBIENT ON", &uid, "Timmy"), - Some(true) - ); - assert_eq!( - parse_ambient_command("TIMMY AMBIENT OFF", &uid, "Timmy"), - Some(false) - ); - } - - #[test] - fn ambient_command_unrelated_message_returns_none() { - let uid = make_user_id("@timmy:homeserver.local"); - assert_eq!( - parse_ambient_command("@timmy what is the status?", &uid, "Timmy"), - None - ); - assert_eq!(parse_ambient_command("hello there", &uid, "Timmy"), None); - assert_eq!(parse_ambient_command("ambient", &uid, "Timmy"), None); - } - - // -- ambient mode state --------------------------------------------------- - - #[tokio::test] - async fn ambient_rooms_defaults_to_empty() { - let ambient_rooms: Arc>> = - Arc::new(TokioMutex::new(HashSet::new())); - let room_id: OwnedRoomId = "!room:example.com".parse().unwrap(); - assert!(!ambient_rooms.lock().await.contains(&room_id)); - } - - #[tokio::test] - async fn ambient_mode_can_be_toggled_per_room() { - let ambient_rooms: Arc>> = - Arc::new(TokioMutex::new(HashSet::new())); - let room_a: OwnedRoomId = "!room_a:example.com".parse().unwrap(); - let room_b: OwnedRoomId = "!room_b:example.com".parse().unwrap(); - - // Enable ambient mode for room_a only. - ambient_rooms.lock().await.insert(room_a.clone()); - - let guard = ambient_rooms.lock().await; - assert!(guard.contains(&room_a), "room_a should be in ambient mode"); - assert!( - !guard.contains(&room_b), - "room_b should NOT be in ambient mode" - ); - drop(guard); - - // Disable ambient mode for room_a. - ambient_rooms.lock().await.remove(&room_a); - assert!( - !ambient_rooms.lock().await.contains(&room_a), - "room_a ambient mode should be off" - ); - } } diff --git a/server/src/matrix/commands.rs b/server/src/matrix/commands.rs index 5f64508..044ab73 100644 --- a/server/src/matrix/commands.rs +++ b/server/src/matrix/commands.rs @@ -5,34 +5,90 @@ //! iterates it automatically so new commands appear in the help output as soon //! as they are added. +use crate::agents::{AgentPool, AgentStatus}; +use crate::config::ProjectConfig; +use matrix_sdk::ruma::OwnedRoomId; +use std::collections::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). - pub handler: fn(&CommandContext) -> String, + /// 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, } -/// Context passed to command handlers. +/// Dispatch parameters passed to `try_handle_command`. +/// +/// Groups all the caller-supplied context needed to dispatch and execute bot +/// commands. Construct one per incoming message and pass it alongside the raw +/// message body. +pub struct CommandDispatch<'a> { + /// The bot's display name (e.g., "Timmy"). + pub bot_name: &'a str, + /// The bot's full Matrix user ID (e.g., `"@timmy:homeserver.local"`). + pub bot_user_id: &'a str, + /// Project root directory (needed by status, ambient). + pub project_root: &'a Path, + /// Agent pool (needed by status). + pub agents: &'a AgentPool, + /// Set of rooms with ambient mode enabled (needed by ambient). + pub ambient_rooms: &'a Arc>>, + /// The room this message came from (needed by ambient). + pub room_id: &'a OwnedRoomId, + /// Whether the message directly addressed the bot (mention/reply). + /// Some commands (e.g. ambient) only operate when directly addressed. + pub is_addressed: bool, +} + +/// Context passed to individual command handlers. pub struct CommandContext<'a> { /// The bot's display name (e.g., "Timmy"). pub bot_name: &'a str, /// Any text after the command keyword, trimmed. - #[allow(dead_code)] pub args: &'a str, + /// Project root directory (needed by status, ambient). + pub project_root: &'a Path, + /// Agent pool (needed by status). + pub agents: &'a AgentPool, + /// Set of rooms with ambient mode enabled (needed by ambient). + pub ambient_rooms: &'a Arc>>, + /// The room this message came from (needed by ambient). + pub room_id: &'a OwnedRoomId, + /// Whether the message directly addressed the bot (mention/reply). + /// Some commands (e.g. ambient) only operate when directly addressed. + pub is_addressed: bool, } /// Returns the full list of registered bot commands. /// /// Add new commands here — they will automatically appear in `help` output. pub fn commands() -> &'static [BotCommand] { - &[BotCommand { - name: "help", - description: "Show this list of available commands", - handler: handle_help, - }] + &[ + BotCommand { + name: "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, + }, + ] } /// Try to match a user message against a registered bot command. @@ -40,14 +96,10 @@ pub fn commands() -> &'static [BotCommand] { /// 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, `None` otherwise (the -/// caller should fall through to the LLM). -pub fn try_handle_command( - bot_name: &str, - bot_user_id: &str, - message: &str, -) -> Option { - let command_text = strip_bot_mention(message, bot_name, bot_user_id); +/// 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; @@ -60,14 +112,19 @@ pub fn try_handle_command( let cmd_lower = cmd_name.to_ascii_lowercase(); let ctx = CommandContext { - bot_name, + 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) - .map(|c| (c.handler)(&ctx)) + .and_then(|c| (c.handler)(&ctx)) } /// Strip the bot mention prefix from a raw message body. @@ -117,16 +174,175 @@ fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> { } } +// --------------------------------------------------------------------------- +// Pipeline status helpers (moved from bot.rs) +// --------------------------------------------------------------------------- + +/// Read all story IDs and names from a pipeline stage directory. +fn read_stage_items( + project_root: &std::path::Path, + stage_dir: &str, +) -> Vec<(String, Option)> { + let dir = project_root + .join(".story_kit") + .join("work") + .join(stage_dir); + if !dir.exists() { + return Vec::new(); + } + let mut items = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + let name = std::fs::read_to_string(&path) + .ok() + .and_then(|contents| { + crate::io::story_metadata::parse_front_matter(&contents) + .ok() + .and_then(|m| m.name) + }); + items.push((stem.to_string(), name)); + } + } + } + items.sort_by(|a, b| a.0.cmp(&b.0)); + items +} + +/// Build the full pipeline status text formatted for Matrix (markdown). +pub fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) -> String { + // Build a map from story_id → active AgentInfo for quick lookup. + let active_agents = agents.list_agents().unwrap_or_default(); + let active_map: std::collections::HashMap = active_agents + .iter() + .filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending)) + .map(|a| (a.story_id.clone(), a)) + .collect(); + + let config = ProjectConfig::load(project_root).ok(); + + let mut out = String::from("**Pipeline Status**\n\n"); + + let stages = [ + ("1_backlog", "Backlog"), + ("2_current", "In Progress"), + ("3_qa", "QA"), + ("4_merge", "Merge"), + ("5_done", "Done"), + ]; + + for (dir, label) in &stages { + let items = read_stage_items(project_root, dir); + let count = items.len(); + out.push_str(&format!("**{label}** ({count})\n")); + if items.is_empty() { + out.push_str(" *(none)*\n"); + } else { + for (story_id, name) in &items { + let display = match name { + Some(n) => format!("{story_id} — {n}"), + None => story_id.clone(), + }; + 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} — {} ({}) [{}]\n", + agent.agent_name, model_str, agent.status + )); + } else { + out.push_str(&format!(" • {display}\n")); + } + } + } + out.push('\n'); + } + + // Free agents: configured agents not currently running or pending. + out.push_str("**Free Agents**\n"); + if let Some(cfg) = &config { + let busy_names: std::collections::HashSet = active_agents + .iter() + .filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending)) + .map(|a| a.agent_name.clone()) + .collect(); + + let free: Vec = cfg + .agent + .iter() + .filter(|a| !busy_names.contains(&a.name)) + .map(|a| match &a.model { + Some(m) => format!("{} ({})", a.name, m), + None => a.name.clone(), + }) + .collect(); + + if free.is_empty() { + out.push_str(" *(none — all agents busy)*\n"); + } else { + for name in &free { + out.push_str(&format!(" • {name}\n")); + } + } + } else { + out.push_str(" *(no agent config found)*\n"); + } + + out +} + // --------------------------------------------------------------------------- // Built-in command handlers // --------------------------------------------------------------------------- -fn handle_help(ctx: &CommandContext) -> String { +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)); } - output + Some(output) +} + +fn handle_status(ctx: &CommandContext) -> Option { + Some(build_pipeline_status(ctx.project_root, ctx.agents)) +} + +/// Toggle ambient mode for this room. +/// +/// Only acts when the message directly addressed the bot (`is_addressed=true`) +/// to prevent accidental toggling via ambient-mode traffic. +fn handle_ambient(ctx: &CommandContext) -> Option { + if !ctx.is_addressed { + return None; + } + let enable = match ctx.args { + "on" => true, + "off" => false, + _ => return Some("Usage: `ambient on` or `ambient off`".to_string()), + }; + let room_ids: Vec = { + let mut ambient = ctx.ambient_rooms.lock().unwrap(); + if enable { + ambient.insert(ctx.room_id.clone()); + } else { + ambient.remove(ctx.room_id); + } + ambient.iter().map(|r| r.to_string()).collect() + }; + save_ambient_rooms(ctx.project_root, &room_ids); + let msg = if enable { + "Ambient mode on. I'll respond to all messages in this room." + } else { + "Ambient mode off. I'll only respond when mentioned." + }; + Some(msg.to_string()) } // --------------------------------------------------------------------------- @@ -136,6 +352,46 @@ fn handle_help(ctx: &CommandContext) -> String { #[cfg(test)] mod tests { use super::*; + use crate::agents::AgentPool; + + // -- test helpers ------------------------------------------------------- + + fn make_room_id(s: &str) -> OwnedRoomId { + s.parse().unwrap() + } + + fn test_ambient_rooms() -> Arc>> { + Arc::new(Mutex::new(HashSet::new())) + } + + fn test_agents() -> Arc { + Arc::new(AgentPool::new_test(3000)) + } + + fn try_cmd( + bot_name: &str, + bot_user_id: &str, + message: &str, + ambient_rooms: &Arc>>, + is_addressed: bool, + ) -> Option { + let agents = test_agents(); + let room_id = make_room_id("!test:example.com"); + let dispatch = CommandDispatch { + bot_name, + bot_user_id, + project_root: std::path::Path::new("/tmp"), + agents: &agents, + ambient_rooms, + room_id: &room_id, + is_addressed, + }; + try_handle_command(&dispatch, message) + } + + fn try_cmd_addressed(bot_name: &str, bot_user_id: &str, message: &str) -> Option { + try_cmd(bot_name, bot_user_id, message, &test_ambient_rooms(), true) + } // -- strip_bot_mention -------------------------------------------------- @@ -190,19 +446,19 @@ mod tests { #[test] fn help_command_matches() { - let result = try_handle_command("Timmy", "@timmy:homeserver.local", "@timmy help"); + 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_handle_command("Timmy", "@timmy:homeserver.local", "@timmy HELP"); + 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_handle_command( + let result = try_cmd_addressed( "Timmy", "@timmy:homeserver.local", "@timmy what is the weather?", @@ -212,7 +468,7 @@ mod tests { #[test] fn help_output_contains_all_commands() { - let result = try_handle_command("Timmy", "@timmy:homeserver.local", "@timmy help"); + let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help"); let output = result.unwrap(); for cmd in commands() { assert!( @@ -230,7 +486,7 @@ mod tests { #[test] fn help_output_uses_bot_name() { - let result = try_handle_command("HAL", "@hal:example.com", "@hal help"); + let result = try_cmd_addressed("HAL", "@hal:example.com", "@hal help"); let output = result.unwrap(); assert!( output.contains("HAL Commands"), @@ -240,7 +496,7 @@ mod tests { #[test] fn help_output_formatted_as_markdown() { - let result = try_handle_command("Timmy", "@timmy:homeserver.local", "@timmy help"); + let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help"); let output = result.unwrap(); assert!( output.contains("**help**"), @@ -254,13 +510,141 @@ mod tests { #[test] fn empty_message_after_mention_returns_none() { - let result = try_handle_command("Timmy", "@timmy:homeserver.local", "@timmy"); + let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy"); assert!( result.is_none(), "bare mention with no command should fall through to LLM" ); } + // -- status command ----------------------------------------------------- + + #[test] + fn status_command_matches() { + let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status"); + assert!(result.is_some(), "status command should match"); + } + + #[test] + fn status_command_returns_pipeline_text() { + let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status"); + let output = result.unwrap(); + assert!( + output.contains("Pipeline Status"), + "status output should contain pipeline info: {output}" + ); + } + + #[test] + fn status_command_case_insensitive() { + let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy STATUS"); + assert!(result.is_some(), "STATUS should match case-insensitively"); + } + + // -- ambient command ---------------------------------------------------- + + #[test] + fn ambient_on_requires_addressed() { + let ambient_rooms = test_ambient_rooms(); + let room_id = make_room_id("!myroom:example.com"); + let result = try_cmd( + "Timmy", + "@timmy:homeserver.local", + "@timmy ambient on", + &ambient_rooms, + false, // not addressed + ); + // Should fall through to LLM when not addressed + assert!(result.is_none(), "ambient should not fire in non-addressed mode"); + assert!( + !ambient_rooms.lock().unwrap().contains(&room_id), + "ambient_rooms should not be modified when not addressed" + ); + } + + #[test] + fn ambient_on_enables_ambient_mode() { + let ambient_rooms = test_ambient_rooms(); + let agents = test_agents(); + let room_id = make_room_id("!myroom:example.com"); + let dispatch = CommandDispatch { + bot_name: "Timmy", + bot_user_id: "@timmy:homeserver.local", + project_root: std::path::Path::new("/tmp"), + agents: &agents, + ambient_rooms: &ambient_rooms, + room_id: &room_id, + is_addressed: true, + }; + let result = try_handle_command(&dispatch, "@timmy ambient on"); + assert!(result.is_some(), "ambient on should produce a response"); + let output = result.unwrap(); + assert!( + output.contains("Ambient mode on"), + "response should confirm ambient on: {output}" + ); + assert!( + ambient_rooms.lock().unwrap().contains(&room_id), + "room should be in ambient_rooms after ambient on" + ); + } + + #[test] + fn ambient_off_disables_ambient_mode() { + let ambient_rooms = test_ambient_rooms(); + let agents = test_agents(); + let room_id = make_room_id("!myroom:example.com"); + // Pre-insert the room + ambient_rooms.lock().unwrap().insert(room_id.clone()); + + let dispatch = CommandDispatch { + bot_name: "Timmy", + bot_user_id: "@timmy:homeserver.local", + project_root: std::path::Path::new("/tmp"), + agents: &agents, + ambient_rooms: &ambient_rooms, + room_id: &room_id, + is_addressed: true, + }; + let result = try_handle_command(&dispatch, "@timmy ambient off"); + assert!(result.is_some(), "ambient off should produce a response"); + let output = result.unwrap(); + assert!( + output.contains("Ambient mode off"), + "response should confirm ambient off: {output}" + ); + assert!( + !ambient_rooms.lock().unwrap().contains(&room_id), + "room should be removed from ambient_rooms after ambient off" + ); + } + + #[test] + fn ambient_invalid_args_returns_usage() { + let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy ambient"); + let output = result.unwrap(); + assert!( + output.contains("Usage"), + "invalid ambient args should show usage: {output}" + ); + } + + // -- help lists status and ambient -------------------------------------- + + #[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}"); + } + // -- strip_prefix_ci ---------------------------------------------------- #[test]