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]