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) <noreply@anthropic.com>
This commit is contained in:
Dave
2026-03-19 09:14:04 +00:00
parent 11afd21f17
commit 73c86b6946
4 changed files with 539 additions and 557 deletions

View File

@@ -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<String>,
}
/// 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<Mutex<HashSet<OwnedRoomId>>>,
/// 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<Mutex<HashSet<OwnedRoomId>>>,
/// 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<String> {
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<String> {
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<String>)> {
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<String, &crate::agents::AgentInfo> = 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<String> = active_agents
.iter()
.filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending))
.map(|a| a.agent_name.clone())
.collect();
let free: Vec<String> = 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<String> {
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<String> {
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<String> {
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<String> = {
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<Mutex<HashSet<OwnedRoomId>>> {
Arc::new(Mutex::new(HashSet::new()))
}
fn test_agents() -> Arc<AgentPool> {
Arc::new(AgentPool::new_test(3000))
}
fn try_cmd(
bot_name: &str,
bot_user_id: &str,
message: &str,
ambient_rooms: &Arc<Mutex<HashSet<OwnedRoomId>>>,
is_addressed: bool,
) -> Option<String> {
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<String> {
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]