story-kit: merge 328_refactor_split_commands_rs_into_individual_command_handler_modules
This commit is contained in:
File diff suppressed because it is too large
Load Diff
142
server/src/matrix/commands/ambient.rs
Normal file
142
server/src/matrix/commands/ambient.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
//! Handler for the `ambient` command.
|
||||
|
||||
use super::CommandContext;
|
||||
use crate::matrix::config::save_ambient_rooms;
|
||||
|
||||
/// Toggle ambient mode for this room.
|
||||
///
|
||||
/// Only acts when the message directly addressed the bot (`is_addressed=true`)
|
||||
/// to prevent accidental toggling via ambient-mode traffic.
|
||||
pub(super) fn handle_ambient(ctx: &CommandContext) -> Option<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.to_string());
|
||||
} else {
|
||||
ambient.remove(ctx.room_id);
|
||||
}
|
||||
ambient.iter().cloned().collect()
|
||||
};
|
||||
save_ambient_rooms(ctx.project_root, &room_ids);
|
||||
let msg = if enable {
|
||||
"Ambient mode on. I'll respond to all messages in this room."
|
||||
} else {
|
||||
"Ambient mode off. I'll only respond when mentioned."
|
||||
};
|
||||
Some(msg.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::agents::AgentPool;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::super::{CommandDispatch, try_handle_command};
|
||||
|
||||
fn test_ambient_rooms() -> Arc<Mutex<HashSet<String>>> {
|
||||
Arc::new(Mutex::new(HashSet::new()))
|
||||
}
|
||||
|
||||
fn test_agents() -> Arc<AgentPool> {
|
||||
Arc::new(AgentPool::new_test(3000))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_on_requires_addressed() {
|
||||
let ambient_rooms = test_ambient_rooms();
|
||||
let room_id = "!myroom:example.com".to_string();
|
||||
let agents = test_agents();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: std::path::Path::new("/tmp"),
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
is_addressed: false, // not addressed
|
||||
};
|
||||
let result = try_handle_command(&dispatch, "@timmy ambient on");
|
||||
// Should fall through to LLM when not addressed
|
||||
assert!(result.is_none(), "ambient should not fire in non-addressed mode");
|
||||
assert!(
|
||||
!ambient_rooms.lock().unwrap().contains(&room_id),
|
||||
"ambient_rooms should not be modified when not addressed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_on_enables_ambient_mode() {
|
||||
let ambient_rooms = test_ambient_rooms();
|
||||
let agents = test_agents();
|
||||
let room_id = "!myroom:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: std::path::Path::new("/tmp"),
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
is_addressed: true,
|
||||
};
|
||||
let result = try_handle_command(&dispatch, "@timmy ambient on");
|
||||
assert!(result.is_some(), "ambient on should produce a response");
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("Ambient mode on"),
|
||||
"response should confirm ambient on: {output}"
|
||||
);
|
||||
assert!(
|
||||
ambient_rooms.lock().unwrap().contains(&room_id),
|
||||
"room should be in ambient_rooms after ambient on"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_off_disables_ambient_mode() {
|
||||
let ambient_rooms = test_ambient_rooms();
|
||||
let agents = test_agents();
|
||||
let room_id = "!myroom:example.com".to_string();
|
||||
// Pre-insert the room
|
||||
ambient_rooms.lock().unwrap().insert(room_id.clone());
|
||||
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: std::path::Path::new("/tmp"),
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
is_addressed: true,
|
||||
};
|
||||
let result = try_handle_command(&dispatch, "@timmy ambient off");
|
||||
assert!(result.is_some(), "ambient off should produce a response");
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("Ambient mode off"),
|
||||
"response should confirm ambient off: {output}"
|
||||
);
|
||||
assert!(
|
||||
!ambient_rooms.lock().unwrap().contains(&room_id),
|
||||
"room should be removed from ambient_rooms after ambient off"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_invalid_args_returns_usage() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy ambient");
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("Usage"),
|
||||
"invalid ambient args should show usage: {output}"
|
||||
);
|
||||
}
|
||||
}
|
||||
272
server/src/matrix/commands/cost.rs
Normal file
272
server/src/matrix/commands/cost.rs
Normal file
@@ -0,0 +1,272 @@
|
||||
//! Handler for the `cost` command.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::status::story_short_label;
|
||||
use super::CommandContext;
|
||||
|
||||
/// Show token spend: 24h total, top 5 stories, agent-type breakdown, and
|
||||
/// all-time total.
|
||||
pub(super) fn handle_cost(ctx: &CommandContext) -> Option<String> {
|
||||
let records = match crate::agents::token_usage::read_all(ctx.project_root) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Some(format!("Failed to read token usage: {e}")),
|
||||
};
|
||||
|
||||
if records.is_empty() {
|
||||
return Some("**Token Spend**\n\nNo usage records found.".to_string());
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let cutoff = now - chrono::Duration::hours(24);
|
||||
|
||||
// Partition into 24h window and all-time
|
||||
let mut recent = Vec::new();
|
||||
let mut all_time_cost = 0.0;
|
||||
for r in &records {
|
||||
all_time_cost += r.usage.total_cost_usd;
|
||||
if let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&r.timestamp)
|
||||
&& ts >= cutoff
|
||||
{
|
||||
recent.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
// 24h total
|
||||
let recent_cost: f64 = recent.iter().map(|r| r.usage.total_cost_usd).sum();
|
||||
|
||||
let mut out = String::from("**Token Spend**\n\n");
|
||||
out.push_str(&format!("**Last 24h:** ${:.2}\n", recent_cost));
|
||||
out.push_str(&format!("**All-time:** ${:.2}\n\n", all_time_cost));
|
||||
|
||||
// Top 5 most expensive stories (last 24h)
|
||||
let mut story_costs: HashMap<&str, f64> = HashMap::new();
|
||||
for r in &recent {
|
||||
*story_costs.entry(r.story_id.as_str()).or_default() += r.usage.total_cost_usd;
|
||||
}
|
||||
let mut story_list: Vec<(&str, f64)> = story_costs.into_iter().collect();
|
||||
story_list.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
story_list.truncate(5);
|
||||
|
||||
out.push_str("**Top Stories (24h)**\n");
|
||||
if story_list.is_empty() {
|
||||
out.push_str(" *(none)*\n");
|
||||
} else {
|
||||
for (story_id, cost) in &story_list {
|
||||
let label = story_short_label(story_id, None);
|
||||
out.push_str(&format!(" • {label} — ${cost:.2}\n"));
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
|
||||
// Breakdown by agent type (last 24h)
|
||||
// Agent names follow pattern "coder-1", "qa-1", "mergemaster" — extract
|
||||
// the type as everything before the last '-' digit, or the full name.
|
||||
let mut type_costs: HashMap<String, f64> = HashMap::new();
|
||||
for r in &recent {
|
||||
let agent_type = extract_agent_type(&r.agent_name);
|
||||
*type_costs.entry(agent_type).or_default() += r.usage.total_cost_usd;
|
||||
}
|
||||
let mut type_list: Vec<(String, f64)> = type_costs.into_iter().collect();
|
||||
type_list.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
out.push_str("**By Agent Type (24h)**\n");
|
||||
if type_list.is_empty() {
|
||||
out.push_str(" *(none)*\n");
|
||||
} else {
|
||||
for (agent_type, cost) in &type_list {
|
||||
out.push_str(&format!(" • {agent_type} — ${cost:.2}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Extract the agent type from an agent name.
|
||||
///
|
||||
/// Agent names like "coder-1", "qa-2", "mergemaster" map to types "coder",
|
||||
/// "qa", "mergemaster". If the name ends with `-<digits>`, strip the suffix.
|
||||
pub(super) fn extract_agent_type(agent_name: &str) -> String {
|
||||
if let Some(pos) = agent_name.rfind('-') {
|
||||
let suffix = &agent_name[pos + 1..];
|
||||
if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) {
|
||||
return agent_name[..pos].to_string();
|
||||
}
|
||||
}
|
||||
agent_name.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agents::AgentPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn write_token_records(root: &std::path::Path, records: &[crate::agents::token_usage::TokenUsageRecord]) {
|
||||
for r in records {
|
||||
crate::agents::token_usage::append_record(root, r).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn make_usage(cost: f64) -> crate::agents::TokenUsage {
|
||||
crate::agents::TokenUsage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 200,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
total_cost_usd: cost,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_record(story_id: &str, agent_name: &str, cost: f64, hours_ago: i64) -> crate::agents::token_usage::TokenUsageRecord {
|
||||
let ts = (chrono::Utc::now() - chrono::Duration::hours(hours_ago)).to_rfc3339();
|
||||
crate::agents::token_usage::TokenUsageRecord {
|
||||
story_id: story_id.to_string(),
|
||||
agent_name: agent_name.to_string(),
|
||||
timestamp: ts,
|
||||
model: None,
|
||||
usage: make_usage(cost),
|
||||
}
|
||||
}
|
||||
|
||||
fn cost_cmd_with_root(root: &std::path::Path) -> Option<String> {
|
||||
use super::super::{CommandDispatch, try_handle_command};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Mutex;
|
||||
|
||||
let agents = Arc::new(AgentPool::new_test(3000));
|
||||
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: root,
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
is_addressed: true,
|
||||
};
|
||||
try_handle_command(&dispatch, "@timmy cost")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_is_registered() {
|
||||
use super::super::commands;
|
||||
let found = commands().iter().any(|c| c.name == "cost");
|
||||
assert!(found, "cost command must be in the registry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_appears_in_help() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("cost"), "help should list cost command: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_no_records() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("No usage records found"), "should show empty message: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_shows_24h_total() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_token_records(tmp.path(), &[
|
||||
make_record("42_story_foo", "coder-1", 1.50, 2),
|
||||
make_record("42_story_foo", "coder-1", 0.50, 5),
|
||||
]);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("**Last 24h:** $2.00"), "should show 24h total: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_excludes_old_from_24h() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_token_records(tmp.path(), &[
|
||||
make_record("42_story_foo", "coder-1", 1.00, 2), // within 24h
|
||||
make_record("43_story_bar", "coder-1", 5.00, 48), // older
|
||||
]);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("**Last 24h:** $1.00"), "should only count recent: {output}");
|
||||
assert!(output.contains("**All-time:** $6.00"), "all-time should include everything: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_shows_top_stories() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_token_records(tmp.path(), &[
|
||||
make_record("42_story_foo", "coder-1", 3.00, 1),
|
||||
make_record("43_story_bar", "coder-1", 1.00, 1),
|
||||
make_record("42_story_foo", "qa-1", 2.00, 1),
|
||||
]);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("Top Stories"), "should have top stories section: {output}");
|
||||
// Story 42 ($5.00) should appear before story 43 ($1.00)
|
||||
let pos_42 = output.find("42").unwrap();
|
||||
let pos_43 = output.find("43").unwrap();
|
||||
assert!(pos_42 < pos_43, "story 42 should appear before 43 (sorted by cost): {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_limits_to_5_stories() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mut records = Vec::new();
|
||||
for i in 1..=7 {
|
||||
records.push(make_record(&format!("{i}_story_s{i}"), "coder-1", i as f64, 1));
|
||||
}
|
||||
write_token_records(tmp.path(), &records);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
// The top 5 most expensive are stories 7,6,5,4,3. Stories 1 and 2 should be excluded.
|
||||
let top_section = output.split("**By Agent Type").next().unwrap();
|
||||
assert!(!top_section.contains("• 1 —"), "story 1 should not be in top 5: {output}");
|
||||
assert!(!top_section.contains("• 2 —"), "story 2 should not be in top 5: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_shows_agent_type_breakdown() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_token_records(tmp.path(), &[
|
||||
make_record("42_story_foo", "coder-1", 2.00, 1),
|
||||
make_record("42_story_foo", "qa-1", 1.50, 1),
|
||||
make_record("42_story_foo", "mergemaster", 0.50, 1),
|
||||
]);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("By Agent Type"), "should have agent type section: {output}");
|
||||
assert!(output.contains("coder"), "should show coder type: {output}");
|
||||
assert!(output.contains("qa"), "should show qa type: {output}");
|
||||
assert!(output.contains("mergemaster"), "should show mergemaster type: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_shows_all_time_total() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_token_records(tmp.path(), &[
|
||||
make_record("42_story_foo", "coder-1", 1.00, 2),
|
||||
make_record("43_story_bar", "coder-1", 9.00, 100),
|
||||
]);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("**All-time:** $10.00"), "should show all-time total: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_case_insensitive() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy COST");
|
||||
assert!(result.is_some(), "COST should match case-insensitively");
|
||||
}
|
||||
|
||||
// -- extract_agent_type -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn extract_agent_type_strips_numeric_suffix() {
|
||||
assert_eq!(extract_agent_type("coder-1"), "coder");
|
||||
assert_eq!(extract_agent_type("qa-2"), "qa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_agent_type_keeps_non_numeric_suffix() {
|
||||
assert_eq!(extract_agent_type("mergemaster"), "mergemaster");
|
||||
assert_eq!(extract_agent_type("coder-alpha"), "coder-alpha");
|
||||
}
|
||||
}
|
||||
207
server/src/matrix/commands/git.rs
Normal file
207
server/src/matrix/commands/git.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
//! Handler for the `git` command.
|
||||
|
||||
use super::CommandContext;
|
||||
|
||||
/// Show compact git status: branch, uncommitted files, ahead/behind remote.
|
||||
pub(super) fn handle_git(ctx: &CommandContext) -> Option<String> {
|
||||
use std::process::Command;
|
||||
|
||||
// Current branch
|
||||
let branch = Command::new("git")
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.current_dir(ctx.project_root)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
// Porcelain status for staged + unstaged changes
|
||||
let status_output = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(ctx.project_root)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let changed_files: Vec<&str> = status_output.lines().filter(|l| !l.is_empty()).collect();
|
||||
let change_count = changed_files.len();
|
||||
|
||||
// Ahead/behind: --left-right gives "N\tM" (ahead\tbehind)
|
||||
let ahead_behind = Command::new("git")
|
||||
.args(["rev-list", "--count", "--left-right", "HEAD...@{u}"])
|
||||
.current_dir(ctx.project_root)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| {
|
||||
let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||
let mut parts = s.split_whitespace();
|
||||
let ahead: u32 = parts.next()?.parse().ok()?;
|
||||
let behind: u32 = parts.next()?.parse().ok()?;
|
||||
Some((ahead, behind))
|
||||
});
|
||||
|
||||
let mut out = format!("**Branch:** `{branch}`\n");
|
||||
|
||||
if change_count == 0 {
|
||||
out.push_str("**Changes:** clean\n");
|
||||
} else {
|
||||
out.push_str(&format!("**Changes:** {change_count} file(s)\n"));
|
||||
for line in &changed_files {
|
||||
// Porcelain format: "XY filename" (2-char status + space + path)
|
||||
if line.len() > 3 {
|
||||
let codes = &line[..2];
|
||||
let name = line[3..].trim();
|
||||
out.push_str(&format!(" • `{codes}` {name}\n"));
|
||||
} else {
|
||||
out.push_str(&format!(" • {line}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match ahead_behind {
|
||||
Some((0, 0)) => out.push_str("**Remote:** up to date\n"),
|
||||
Some((ahead, 0)) => out.push_str(&format!("**Remote:** ↑{ahead} ahead\n")),
|
||||
Some((0, behind)) => out.push_str(&format!("**Remote:** ↓{behind} behind\n")),
|
||||
Some((ahead, behind)) => {
|
||||
out.push_str(&format!("**Remote:** ↑{ahead} ahead, ↓{behind} behind\n"));
|
||||
}
|
||||
None => out.push_str("**Remote:** no tracking branch\n"),
|
||||
}
|
||||
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::agents::AgentPool;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::super::{CommandDispatch, try_handle_command};
|
||||
|
||||
fn test_ambient_rooms() -> Arc<Mutex<HashSet<String>>> {
|
||||
Arc::new(Mutex::new(HashSet::new()))
|
||||
}
|
||||
|
||||
fn test_agents() -> Arc<AgentPool> {
|
||||
Arc::new(AgentPool::new_test(3000))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_command_is_registered() {
|
||||
use super::super::commands;
|
||||
let found = commands().iter().any(|c| c.name == "git");
|
||||
assert!(found, "git command must be in the registry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_command_appears_in_help() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("git"), "help should list git command: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_command_returns_some() {
|
||||
// Run from the actual repo root so git commands have a real repo to query.
|
||||
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("."));
|
||||
let agents = test_agents();
|
||||
let ambient_rooms = test_ambient_rooms();
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: repo_root,
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
is_addressed: true,
|
||||
};
|
||||
let result = try_handle_command(&dispatch, "@timmy git");
|
||||
assert!(result.is_some(), "git command should always return Some");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_command_output_contains_branch() {
|
||||
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("."));
|
||||
let agents = test_agents();
|
||||
let ambient_rooms = test_ambient_rooms();
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: repo_root,
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
is_addressed: true,
|
||||
};
|
||||
let output = try_handle_command(&dispatch, "@timmy git").unwrap();
|
||||
assert!(
|
||||
output.contains("**Branch:**"),
|
||||
"git output should contain branch info: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_command_output_contains_changes() {
|
||||
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("."));
|
||||
let agents = test_agents();
|
||||
let ambient_rooms = test_ambient_rooms();
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: repo_root,
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
is_addressed: true,
|
||||
};
|
||||
let output = try_handle_command(&dispatch, "@timmy git").unwrap();
|
||||
assert!(
|
||||
output.contains("**Changes:**"),
|
||||
"git output should contain changes section: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_command_output_contains_remote() {
|
||||
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("."));
|
||||
let agents = test_agents();
|
||||
let ambient_rooms = test_ambient_rooms();
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: repo_root,
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
is_addressed: true,
|
||||
};
|
||||
let output = try_handle_command(&dispatch, "@timmy git").unwrap();
|
||||
assert!(
|
||||
output.contains("**Remote:**"),
|
||||
"git output should contain remote section: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_command_case_insensitive() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy GIT");
|
||||
assert!(result.is_some(), "GIT should match case-insensitively");
|
||||
}
|
||||
}
|
||||
91
server/src/matrix/commands/help.rs
Normal file
91
server/src/matrix/commands/help.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
//! Handler for the `help` command.
|
||||
|
||||
use super::{commands, CommandContext};
|
||||
|
||||
pub(super) 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));
|
||||
}
|
||||
Some(output)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::tests::{try_cmd_addressed, commands};
|
||||
|
||||
#[test]
|
||||
fn help_command_matches() {
|
||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
assert!(result.is_some(), "help command should match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_command_case_insensitive() {
|
||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy HELP");
|
||||
assert!(result.is_some(), "HELP should match case-insensitively");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_output_contains_all_commands() {
|
||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let output = result.unwrap();
|
||||
for cmd in commands() {
|
||||
assert!(
|
||||
output.contains(cmd.name),
|
||||
"help output must include command '{}'",
|
||||
cmd.name
|
||||
);
|
||||
assert!(
|
||||
output.contains(cmd.description),
|
||||
"help output must include description for '{}'",
|
||||
cmd.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_output_uses_bot_name() {
|
||||
let result = try_cmd_addressed("HAL", "@hal:example.com", "@hal help");
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("HAL Commands"),
|
||||
"help output should use bot name: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_output_formatted_as_markdown() {
|
||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("**help**"),
|
||||
"command name should be bold: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("- **"),
|
||||
"commands should be in a list: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_output_includes_status() {
|
||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("status"), "help should list status command: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_output_includes_ambient() {
|
||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("ambient"), "help should list ambient command: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_output_includes_htop() {
|
||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("htop"), "help should list htop command: {output}");
|
||||
}
|
||||
}
|
||||
416
server/src/matrix/commands/mod.rs
Normal file
416
server/src/matrix/commands/mod.rs
Normal file
@@ -0,0 +1,416 @@
|
||||
//! Bot-level command registry for the Matrix bot.
|
||||
//!
|
||||
//! Commands registered here are handled directly by the bot without invoking
|
||||
//! the LLM. The registry is the single source of truth — the `help` command
|
||||
//! iterates it automatically so new commands appear in the help output as soon
|
||||
//! as they are added.
|
||||
|
||||
mod ambient;
|
||||
mod cost;
|
||||
mod git;
|
||||
mod help;
|
||||
mod overview;
|
||||
mod show;
|
||||
mod status;
|
||||
|
||||
use crate::agents::AgentPool;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// A bot-level command that is handled without LLM invocation.
|
||||
pub struct BotCommand {
|
||||
/// The command keyword (e.g., `"help"`). Always lowercase.
|
||||
pub name: &'static str,
|
||||
/// Short description shown in help output.
|
||||
pub description: &'static str,
|
||||
/// Handler that produces the response text (Markdown), or `None` to fall
|
||||
/// through to the LLM (e.g. when a command requires direct addressing but
|
||||
/// the message arrived via ambient mode).
|
||||
pub handler: fn(&CommandContext) -> Option<String>,
|
||||
}
|
||||
|
||||
/// Dispatch parameters passed to `try_handle_command`.
|
||||
///
|
||||
/// Groups all the caller-supplied context needed to dispatch and execute bot
|
||||
/// commands. Construct one per incoming message and pass it alongside the raw
|
||||
/// message body.
|
||||
///
|
||||
/// All identifiers are platform-agnostic strings so this struct works with
|
||||
/// any [`ChatTransport`](crate::transport::ChatTransport) implementation.
|
||||
pub struct CommandDispatch<'a> {
|
||||
/// The bot's display name (e.g., "Timmy").
|
||||
pub bot_name: &'a str,
|
||||
/// The bot's full user ID (e.g., `"@timmy:homeserver.local"` on Matrix).
|
||||
pub bot_user_id: &'a str,
|
||||
/// Project root directory (needed by status, ambient).
|
||||
pub project_root: &'a Path,
|
||||
/// Agent pool (needed by status).
|
||||
pub agents: &'a AgentPool,
|
||||
/// Set of room IDs with ambient mode enabled (needed by ambient).
|
||||
pub ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
|
||||
/// The room this message came from (needed by ambient).
|
||||
pub room_id: &'a str,
|
||||
/// Whether the message directly addressed the bot (mention/reply).
|
||||
/// Some commands (e.g. ambient) only operate when directly addressed.
|
||||
pub is_addressed: bool,
|
||||
}
|
||||
|
||||
/// Context passed to individual command handlers.
|
||||
pub struct CommandContext<'a> {
|
||||
/// The bot's display name (e.g., "Timmy").
|
||||
pub bot_name: &'a str,
|
||||
/// Any text after the command keyword, trimmed.
|
||||
pub args: &'a str,
|
||||
/// Project root directory (needed by status, ambient).
|
||||
pub project_root: &'a Path,
|
||||
/// Agent pool (needed by status).
|
||||
pub agents: &'a AgentPool,
|
||||
/// Set of room IDs with ambient mode enabled (needed by ambient).
|
||||
pub ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
|
||||
/// The room this message came from (needed by ambient).
|
||||
pub room_id: &'a str,
|
||||
/// Whether the message directly addressed the bot (mention/reply).
|
||||
/// Some commands (e.g. ambient) only operate when directly addressed.
|
||||
pub is_addressed: bool,
|
||||
}
|
||||
|
||||
/// Returns the full list of registered bot commands.
|
||||
///
|
||||
/// Add new commands here — they will automatically appear in `help` output.
|
||||
pub fn commands() -> &'static [BotCommand] {
|
||||
&[
|
||||
BotCommand {
|
||||
name: "help",
|
||||
description: "Show this list of available commands",
|
||||
handler: help::handle_help,
|
||||
},
|
||||
BotCommand {
|
||||
name: "status",
|
||||
description: "Show pipeline status and agent availability",
|
||||
handler: status::handle_status,
|
||||
},
|
||||
BotCommand {
|
||||
name: "ambient",
|
||||
description: "Toggle ambient mode for this room: `ambient on` or `ambient off`",
|
||||
handler: ambient::handle_ambient,
|
||||
},
|
||||
BotCommand {
|
||||
name: "git",
|
||||
description: "Show git status: branch, uncommitted changes, and ahead/behind remote",
|
||||
handler: git::handle_git,
|
||||
},
|
||||
BotCommand {
|
||||
name: "htop",
|
||||
description: "Show live system and agent process dashboard (`htop`, `htop 10m`, `htop stop`)",
|
||||
handler: handle_htop_fallback,
|
||||
},
|
||||
BotCommand {
|
||||
name: "cost",
|
||||
description: "Show token spend: 24h total, top stories, breakdown by agent type, and all-time total",
|
||||
handler: cost::handle_cost,
|
||||
},
|
||||
BotCommand {
|
||||
name: "show",
|
||||
description: "Display the full text of a work item: `show <number>`",
|
||||
handler: show::handle_show,
|
||||
},
|
||||
BotCommand {
|
||||
name: "overview",
|
||||
description: "Show implementation summary for a merged story: `overview <number>`",
|
||||
handler: overview::handle_overview,
|
||||
},
|
||||
BotCommand {
|
||||
name: "delete",
|
||||
description: "Remove a work item from the pipeline: `delete <number>`",
|
||||
handler: handle_delete_fallback,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Try to match a user message against a registered bot command.
|
||||
///
|
||||
/// The message is expected to be the raw body text from Matrix (e.g.,
|
||||
/// `"@timmy help"`). The bot mention prefix is stripped before matching.
|
||||
///
|
||||
/// Returns `Some(response)` if a command matched and was handled, `None`
|
||||
/// otherwise (the caller should fall through to the LLM).
|
||||
pub fn try_handle_command(dispatch: &CommandDispatch<'_>, message: &str) -> Option<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;
|
||||
}
|
||||
|
||||
let (cmd_name, args) = match trimmed.split_once(char::is_whitespace) {
|
||||
Some((c, a)) => (c, a.trim()),
|
||||
None => (trimmed, ""),
|
||||
};
|
||||
let cmd_lower = cmd_name.to_ascii_lowercase();
|
||||
|
||||
let ctx = CommandContext {
|
||||
bot_name: dispatch.bot_name,
|
||||
args,
|
||||
project_root: dispatch.project_root,
|
||||
agents: dispatch.agents,
|
||||
ambient_rooms: dispatch.ambient_rooms,
|
||||
room_id: dispatch.room_id,
|
||||
is_addressed: dispatch.is_addressed,
|
||||
};
|
||||
|
||||
commands()
|
||||
.iter()
|
||||
.find(|c| c.name == cmd_lower)
|
||||
.and_then(|c| (c.handler)(&ctx))
|
||||
}
|
||||
|
||||
/// Strip the bot mention prefix from a raw message body.
|
||||
///
|
||||
/// Handles these forms (case-insensitive where applicable):
|
||||
/// - `@bot_localpart:server.com rest` → `rest`
|
||||
/// - `@bot_localpart rest` → `rest`
|
||||
/// - `DisplayName rest` → `rest`
|
||||
fn strip_bot_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
|
||||
let trimmed = message.trim();
|
||||
|
||||
// Try full Matrix user ID (e.g. "@timmy:homeserver.local")
|
||||
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
|
||||
return rest;
|
||||
}
|
||||
|
||||
// Try @localpart (e.g. "@timmy")
|
||||
if let Some(localpart) = bot_user_id.split(':').next()
|
||||
&& let Some(rest) = strip_prefix_ci(trimmed, localpart)
|
||||
{
|
||||
return rest;
|
||||
}
|
||||
|
||||
// Try display name (e.g. "Timmy")
|
||||
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
|
||||
return rest;
|
||||
}
|
||||
|
||||
trimmed
|
||||
}
|
||||
|
||||
/// Case-insensitive prefix strip that also requires the match to end at a
|
||||
/// word boundary (whitespace, punctuation, or end-of-string).
|
||||
fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
|
||||
if text.len() < prefix.len() {
|
||||
return None;
|
||||
}
|
||||
if !text[..prefix.len()].eq_ignore_ascii_case(prefix) {
|
||||
return None;
|
||||
}
|
||||
let rest = &text[prefix.len()..];
|
||||
// Must be at end or followed by non-alphanumeric
|
||||
match rest.chars().next() {
|
||||
None => Some(rest), // exact match, empty remainder
|
||||
Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None, // not a word boundary
|
||||
_ => Some(rest),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback handler for the `htop` command when it is not intercepted by the
|
||||
/// async handler in `on_room_message`. In practice this is never called —
|
||||
/// htop is detected and handled before `try_handle_command` is invoked.
|
||||
/// The entry exists in the registry only so `help` lists it.
|
||||
///
|
||||
/// Returns `None` to prevent the LLM from receiving "htop" as a prompt.
|
||||
fn handle_htop_fallback(_ctx: &CommandContext) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Fallback handler for the `delete` command when it is not intercepted by
|
||||
/// the async handler in `on_room_message`. In practice this is never called —
|
||||
/// delete is detected and handled before `try_handle_command` is invoked.
|
||||
/// The entry exists in the registry only so `help` lists it.
|
||||
///
|
||||
/// Returns `None` to prevent the LLM from receiving "delete" as a prompt.
|
||||
fn handle_delete_fallback(_ctx: &CommandContext) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use crate::agents::AgentPool;
|
||||
|
||||
// -- test helpers (shared with submodule tests) -------------------------
|
||||
|
||||
pub fn test_ambient_rooms() -> Arc<Mutex<HashSet<String>>> {
|
||||
Arc::new(Mutex::new(HashSet::new()))
|
||||
}
|
||||
|
||||
pub fn test_agents() -> Arc<AgentPool> {
|
||||
Arc::new(AgentPool::new_test(3000))
|
||||
}
|
||||
|
||||
pub fn try_cmd(
|
||||
bot_name: &str,
|
||||
bot_user_id: &str,
|
||||
message: &str,
|
||||
ambient_rooms: &Arc<Mutex<HashSet<String>>>,
|
||||
is_addressed: bool,
|
||||
) -> Option<String> {
|
||||
let agents = test_agents();
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name,
|
||||
bot_user_id,
|
||||
project_root: std::path::Path::new("/tmp"),
|
||||
agents: &agents,
|
||||
ambient_rooms,
|
||||
room_id: &room_id,
|
||||
is_addressed,
|
||||
};
|
||||
try_handle_command(&dispatch, message)
|
||||
}
|
||||
|
||||
pub fn try_cmd_addressed(bot_name: &str, bot_user_id: &str, message: &str) -> Option<String> {
|
||||
try_cmd(bot_name, bot_user_id, message, &test_ambient_rooms(), true)
|
||||
}
|
||||
|
||||
// Re-export commands() for submodule tests
|
||||
pub use super::commands;
|
||||
|
||||
// -- strip_bot_mention --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn strip_mention_full_user_id() {
|
||||
let rest = strip_bot_mention(
|
||||
"@timmy:homeserver.local help",
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
);
|
||||
assert_eq!(rest.trim(), "help");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_mention_localpart() {
|
||||
let rest = strip_bot_mention("@timmy help me", "Timmy", "@timmy:homeserver.local");
|
||||
assert_eq!(rest.trim(), "help me");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_mention_display_name() {
|
||||
let rest = strip_bot_mention("Timmy help", "Timmy", "@timmy:homeserver.local");
|
||||
assert_eq!(rest.trim(), "help");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_mention_display_name_case_insensitive() {
|
||||
let rest = strip_bot_mention("timmy help", "Timmy", "@timmy:homeserver.local");
|
||||
assert_eq!(rest.trim(), "help");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_mention_no_match_returns_original() {
|
||||
let rest = strip_bot_mention("hello world", "Timmy", "@timmy:homeserver.local");
|
||||
assert_eq!(rest, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_mention_does_not_match_longer_name() {
|
||||
// "@timmybot" should NOT match "@timmy"
|
||||
let rest = strip_bot_mention("@timmybot help", "Timmy", "@timmy:homeserver.local");
|
||||
assert_eq!(rest, "@timmybot help");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_mention_comma_after_name() {
|
||||
let rest = strip_bot_mention("@timmy, help", "Timmy", "@timmy:homeserver.local");
|
||||
assert_eq!(rest.trim().trim_start_matches(',').trim(), "help");
|
||||
}
|
||||
|
||||
// -- try_handle_command -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn unknown_command_returns_none() {
|
||||
let result = try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy what is the weather?",
|
||||
);
|
||||
assert!(result.is_none(), "non-command should return None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_message_after_mention_returns_none() {
|
||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy");
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"bare mention with no command should fall through to LLM"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn htop_command_falls_through_to_none() {
|
||||
// The htop handler returns None so the message is handled asynchronously
|
||||
// in on_room_message, not here. try_handle_command must return None.
|
||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy htop");
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"htop should not produce a sync response (handled async): {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- strip_prefix_ci ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn strip_prefix_ci_basic() {
|
||||
assert_eq!(strip_prefix_ci("Hello world", "hello"), Some(" world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_prefix_ci_no_match() {
|
||||
assert_eq!(strip_prefix_ci("goodbye", "hello"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_prefix_ci_word_boundary_required() {
|
||||
assert_eq!(strip_prefix_ci("helloworld", "hello"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_prefix_ci_exact_match() {
|
||||
assert_eq!(strip_prefix_ci("hello", "hello"), Some(""));
|
||||
}
|
||||
|
||||
// -- commands registry --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn commands_registry_is_not_empty() {
|
||||
assert!(
|
||||
!commands().is_empty(),
|
||||
"command registry must contain at least one command"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_command_names_are_lowercase() {
|
||||
for cmd in commands() {
|
||||
assert_eq!(
|
||||
cmd.name,
|
||||
cmd.name.to_ascii_lowercase(),
|
||||
"command name '{}' must be lowercase",
|
||||
cmd.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_commands_have_descriptions() {
|
||||
for cmd in commands() {
|
||||
assert!(
|
||||
!cmd.description.is_empty(),
|
||||
"command '{}' must have a description",
|
||||
cmd.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
361
server/src/matrix/commands/overview.rs
Normal file
361
server/src/matrix/commands/overview.rs
Normal file
@@ -0,0 +1,361 @@
|
||||
//! Handler for the `overview` command.
|
||||
|
||||
use super::CommandContext;
|
||||
|
||||
/// Show implementation summary for a story identified by its number.
|
||||
///
|
||||
/// Finds the `story-kit: merge {story_id}` commit on master, displays the
|
||||
/// git diff --stat (files changed with line counts), and extracts key
|
||||
/// function/struct/type names added or modified in the implementation.
|
||||
/// Returns a friendly message when no merge commit is found.
|
||||
pub(super) fn handle_overview(ctx: &CommandContext) -> Option<String> {
|
||||
let num_str = ctx.args.trim();
|
||||
if num_str.is_empty() {
|
||||
return Some(format!(
|
||||
"Usage: `{} overview <number>`\n\nShows the implementation summary for a story.",
|
||||
ctx.bot_name
|
||||
));
|
||||
}
|
||||
if !num_str.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Some(format!(
|
||||
"Invalid story number: `{num_str}`. Usage: `{} overview <number>`",
|
||||
ctx.bot_name
|
||||
));
|
||||
}
|
||||
|
||||
let commit_hash = match find_story_merge_commit(ctx.project_root, num_str) {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
return Some(format!(
|
||||
"No implementation found for story **{num_str}**. \
|
||||
It may still be in the backlog or was never merged."
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let stat_output = get_commit_stat(ctx.project_root, &commit_hash);
|
||||
let symbols = extract_diff_symbols(ctx.project_root, &commit_hash);
|
||||
let story_name = find_story_name(ctx.project_root, num_str);
|
||||
|
||||
let short_hash = &commit_hash[..commit_hash.len().min(8)];
|
||||
let mut out = match story_name {
|
||||
Some(name) => format!("**Overview: Story {num_str} — {name}**\n\n"),
|
||||
None => format!("**Overview: Story {num_str}**\n\n"),
|
||||
};
|
||||
out.push_str(&format!("Commit: `{short_hash}`\n\n"));
|
||||
|
||||
// Parse stat output: collect per-file lines and the summary line.
|
||||
let mut file_lines: Vec<String> = Vec::new();
|
||||
let mut summary_line = String::new();
|
||||
for line in stat_output.lines() {
|
||||
if line.contains("changed") && (line.contains("insertion") || line.contains("deletion")) {
|
||||
summary_line = line.trim().to_string();
|
||||
} else if !line.trim().is_empty() && line.contains('|') {
|
||||
file_lines.push(line.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if !summary_line.is_empty() {
|
||||
out.push_str(&format!("**Changes:** {summary_line}\n"));
|
||||
}
|
||||
|
||||
if !file_lines.is_empty() {
|
||||
out.push_str("**Files:**\n");
|
||||
for f in file_lines.iter().take(8) {
|
||||
out.push_str(&format!(" • `{f}`\n"));
|
||||
}
|
||||
if file_lines.len() > 8 {
|
||||
out.push_str(&format!(" … and {} more\n", file_lines.len() - 8));
|
||||
}
|
||||
}
|
||||
|
||||
if !symbols.is_empty() {
|
||||
out.push_str("\n**Key symbols:**\n");
|
||||
for sym in &symbols {
|
||||
out.push_str(&format!(" • {sym}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Find the merge commit hash for a story by its numeric ID.
|
||||
///
|
||||
/// Searches git log for a commit whose subject matches
|
||||
/// `story-kit: merge {num}_*`.
|
||||
fn find_story_merge_commit(root: &std::path::Path, num_str: &str) -> Option<String> {
|
||||
use std::process::Command;
|
||||
let grep_pattern = format!("story-kit: merge {num_str}_");
|
||||
let output = Command::new("git")
|
||||
.args(["log", "--format=%H", "--all", "--grep", &grep_pattern])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())?;
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
let hash = text.lines().next()?.trim().to_string();
|
||||
if hash.is_empty() { None } else { Some(hash) }
|
||||
}
|
||||
|
||||
/// Find the human-readable name of a story by searching all pipeline stages.
|
||||
fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
|
||||
let stages = [
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
];
|
||||
for stage in &stages {
|
||||
let dir = root.join(".story_kit").join("work").join(stage);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("");
|
||||
if file_num == num_str {
|
||||
return std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|c| {
|
||||
crate::io::story_metadata::parse_front_matter(&c)
|
||||
.ok()
|
||||
.and_then(|m| m.name)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Return the `git show --stat` output for a commit.
|
||||
fn get_commit_stat(root: &std::path::Path, hash: &str) -> String {
|
||||
use std::process::Command;
|
||||
Command::new("git")
|
||||
.args(["show", "--stat", hash])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Extract up to 12 unique top-level symbol definitions from a commit diff.
|
||||
///
|
||||
/// Scans added lines (`+`) for Rust `fn`, `struct`, `enum`, `type`, `trait`,
|
||||
/// and `impl` declarations and returns them formatted as `` `Name` (kind) ``.
|
||||
fn extract_diff_symbols(root: &std::path::Path, hash: &str) -> Vec<String> {
|
||||
use std::process::Command;
|
||||
let output = Command::new("git")
|
||||
.args(["show", hash])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut symbols: Vec<String> = Vec::new();
|
||||
for line in output.lines() {
|
||||
if !line.starts_with('+') || line.starts_with("+++") {
|
||||
continue;
|
||||
}
|
||||
if let Some(sym) = parse_symbol_definition(&line[1..]) {
|
||||
if !symbols.contains(&sym) {
|
||||
symbols.push(sym);
|
||||
}
|
||||
if symbols.len() >= 12 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
symbols
|
||||
}
|
||||
|
||||
/// Parse a single line of code and return a formatted symbol if it opens a
|
||||
/// top-level Rust definition (`fn`, `struct`, `enum`, `type`, `trait`, `impl`).
|
||||
fn parse_symbol_definition(code: &str) -> Option<String> {
|
||||
let t = code.trim();
|
||||
let patterns: &[(&str, &str)] = &[
|
||||
("pub async fn ", "fn"),
|
||||
("async fn ", "fn"),
|
||||
("pub fn ", "fn"),
|
||||
("fn ", "fn"),
|
||||
("pub struct ", "struct"),
|
||||
("struct ", "struct"),
|
||||
("pub enum ", "enum"),
|
||||
("enum ", "enum"),
|
||||
("pub type ", "type"),
|
||||
("type ", "type"),
|
||||
("pub trait ", "trait"),
|
||||
("trait ", "trait"),
|
||||
("impl ", "impl"),
|
||||
];
|
||||
for (prefix, kind) in patterns {
|
||||
if let Some(rest) = t.strip_prefix(prefix) {
|
||||
let name: String = rest
|
||||
.chars()
|
||||
.take_while(|c| c.is_alphanumeric() || *c == '_')
|
||||
.collect();
|
||||
if !name.is_empty() {
|
||||
return Some(format!("`{name}` ({kind})"));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agents::AgentPool;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::super::{CommandDispatch, try_handle_command};
|
||||
|
||||
fn overview_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
|
||||
let agents = Arc::new(AgentPool::new_test(3000));
|
||||
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: root,
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
is_addressed: true,
|
||||
};
|
||||
try_handle_command(&dispatch, &format!("@timmy overview {args}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_command_is_registered() {
|
||||
use super::super::commands;
|
||||
let found = commands().iter().any(|c| c.name == "overview");
|
||||
assert!(found, "overview command must be in the registry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_command_appears_in_help() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("overview"), "help should list overview command: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_command_no_args_returns_usage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = overview_cmd_with_root(tmp.path(), "").unwrap();
|
||||
assert!(
|
||||
output.contains("Usage"),
|
||||
"no args should show usage hint: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_command_non_numeric_arg_returns_error() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = overview_cmd_with_root(tmp.path(), "abc").unwrap();
|
||||
assert!(
|
||||
output.contains("Invalid"),
|
||||
"non-numeric arg should return error: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_command_not_found_returns_friendly_message() {
|
||||
// Use the real repo root but a story number that was never merged.
|
||||
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("."));
|
||||
let output = overview_cmd_with_root(repo_root, "99999").unwrap();
|
||||
assert!(
|
||||
output.contains("99999"),
|
||||
"not-found message should include the story number: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("backlog") || output.contains("No implementation"),
|
||||
"not-found message should explain why: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_command_found_shows_commit_and_stat() {
|
||||
// Story 324 has a real merge commit in master.
|
||||
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("."));
|
||||
let output = overview_cmd_with_root(repo_root, "324").unwrap();
|
||||
assert!(
|
||||
output.contains("**Overview: Story 324"),
|
||||
"output should show story header: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("Commit:"),
|
||||
"output should show commit hash: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("**Changes:**") || output.contains("**Files:**"),
|
||||
"output should show file changes: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_command_case_insensitive() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy OVERVIEW 1");
|
||||
assert!(result.is_some(), "OVERVIEW should match case-insensitively");
|
||||
}
|
||||
|
||||
// -- parse_symbol_definition --------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_symbol_pub_fn() {
|
||||
let result = parse_symbol_definition("pub fn handle_foo(ctx: &Context) -> Option<String> {");
|
||||
assert_eq!(result, Some("`handle_foo` (fn)".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_symbol_pub_struct() {
|
||||
let result = parse_symbol_definition("pub struct SlackTransport {");
|
||||
assert_eq!(result, Some("`SlackTransport` (struct)".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_symbol_impl() {
|
||||
let result = parse_symbol_definition("impl ChatTransport for SlackTransport {");
|
||||
assert_eq!(result, Some("`ChatTransport` (impl)".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_symbol_no_match() {
|
||||
let result = parse_symbol_definition(" let x = 42;");
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_symbol_pub_enum() {
|
||||
let result = parse_symbol_definition("pub enum QaMode {");
|
||||
assert_eq!(result, Some("`QaMode` (enum)".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_symbol_pub_type() {
|
||||
let result = parse_symbol_definition("pub type SlackHistory = Arc<Mutex<HashMap<String, Vec<u8>>>>;");
|
||||
assert_eq!(result, Some("`SlackHistory` (type)".to_string()));
|
||||
}
|
||||
}
|
||||
202
server/src/matrix/commands/show.rs
Normal file
202
server/src/matrix/commands/show.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
//! Handler for the `show` command.
|
||||
|
||||
use super::CommandContext;
|
||||
|
||||
/// Display the full markdown text of a work item identified by its numeric ID.
|
||||
///
|
||||
/// Searches all pipeline stages in order and returns the raw file contents of
|
||||
/// the first matching story, bug, or spike. Returns a friendly message when
|
||||
/// no match is found.
|
||||
pub(super) fn handle_show(ctx: &CommandContext) -> Option<String> {
|
||||
let num_str = ctx.args.trim();
|
||||
if num_str.is_empty() {
|
||||
return Some(format!(
|
||||
"Usage: `{} show <number>`\n\nDisplays the full text of a story, bug, or spike.",
|
||||
ctx.bot_name
|
||||
));
|
||||
}
|
||||
if !num_str.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Some(format!(
|
||||
"Invalid story number: `{num_str}`. Usage: `{} show <number>`",
|
||||
ctx.bot_name
|
||||
));
|
||||
}
|
||||
|
||||
let stages = [
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
];
|
||||
|
||||
for stage in &stages {
|
||||
let dir = ctx
|
||||
.project_root
|
||||
.join(".story_kit")
|
||||
.join("work")
|
||||
.join(stage);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("");
|
||||
if file_num == num_str {
|
||||
return match std::fs::read_to_string(&path) {
|
||||
Ok(contents) => Some(contents),
|
||||
Err(e) => Some(format!("Failed to read story {num_str}: {e}")),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"No story, bug, or spike with number **{num_str}** found."
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::agents::AgentPool;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::super::{CommandDispatch, try_handle_command};
|
||||
|
||||
fn show_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
|
||||
let agents = Arc::new(AgentPool::new_test(3000));
|
||||
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: root,
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
is_addressed: true,
|
||||
};
|
||||
try_handle_command(&dispatch, &format!("@timmy show {args}"))
|
||||
}
|
||||
|
||||
fn write_story_file(root: &std::path::Path, stage: &str, filename: &str, content: &str) {
|
||||
let dir = root.join(".story_kit/work").join(stage);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(filename), content).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_command_is_registered() {
|
||||
use super::super::commands;
|
||||
let found = commands().iter().any(|c| c.name == "show");
|
||||
assert!(found, "show command must be in the registry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_command_appears_in_help() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("show"), "help should list show command: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_command_no_args_returns_usage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = show_cmd_with_root(tmp.path(), "").unwrap();
|
||||
assert!(
|
||||
output.contains("Usage"),
|
||||
"no args should show usage hint: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_command_non_numeric_args_returns_error() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = show_cmd_with_root(tmp.path(), "abc").unwrap();
|
||||
assert!(
|
||||
output.contains("Invalid"),
|
||||
"non-numeric arg should return error message: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_command_not_found_returns_friendly_message() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = show_cmd_with_root(tmp.path(), "999").unwrap();
|
||||
assert!(
|
||||
output.contains("999"),
|
||||
"not-found message should include the queried number: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("found"),
|
||||
"not-found message should say not found: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_command_finds_story_in_backlog() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"1_backlog",
|
||||
"305_story_show_command.md",
|
||||
"---\nname: Show command\n---\n\n# Story 305\n\nFull story text here.",
|
||||
);
|
||||
let output = show_cmd_with_root(tmp.path(), "305").unwrap();
|
||||
assert!(
|
||||
output.contains("Full story text here."),
|
||||
"show should return full story content: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_command_finds_story_in_current() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"42_story_do_something.md",
|
||||
"---\nname: Do something\n---\n\n# Story 42\n\nIn progress.",
|
||||
);
|
||||
let output = show_cmd_with_root(tmp.path(), "42").unwrap();
|
||||
assert!(
|
||||
output.contains("In progress."),
|
||||
"show should return story from current stage: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_command_finds_bug() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"1_backlog",
|
||||
"7_bug_crash_on_login.md",
|
||||
"---\nname: Crash on login\n---\n\n## Symptom\n\nCrashes.",
|
||||
);
|
||||
let output = show_cmd_with_root(tmp.path(), "7").unwrap();
|
||||
assert!(
|
||||
output.contains("Symptom"),
|
||||
"show should return bug content: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_command_case_insensitive() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy SHOW 1");
|
||||
assert!(result.is_some(), "SHOW should match case-insensitively");
|
||||
}
|
||||
}
|
||||
354
server/src/matrix/commands/status.rs
Normal file
354
server/src/matrix/commands/status.rs
Normal file
@@ -0,0 +1,354 @@
|
||||
//! Handler for the `status` command and pipeline status helpers.
|
||||
|
||||
use crate::agents::{AgentPool, AgentStatus};
|
||||
use crate::config::ProjectConfig;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::CommandContext;
|
||||
|
||||
pub(super) fn handle_status(ctx: &CommandContext) -> Option<String> {
|
||||
Some(build_pipeline_status(ctx.project_root, ctx.agents))
|
||||
}
|
||||
|
||||
/// Format a short display label for a work item.
|
||||
///
|
||||
/// Extracts the leading numeric ID from the file stem (e.g. `"293"` from
|
||||
/// `"293_story_register_all_bot_commands"`) and combines it with the human-
|
||||
/// readable name from the front matter when available.
|
||||
///
|
||||
/// Examples:
|
||||
/// - `("293_story_foo", Some("Register all bot commands"))` → `"293 — Register all bot commands"`
|
||||
/// - `("293_story_foo", None)` → `"293"`
|
||||
/// - `("no_number_here", None)` → `"no_number_here"`
|
||||
pub(super) fn story_short_label(stem: &str, name: Option<&str>) -> String {
|
||||
let number = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or(stem);
|
||||
match name {
|
||||
Some(n) => format!("{number} — {n}"),
|
||||
None => number.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read all story IDs and names from a pipeline stage directory.
|
||||
fn read_stage_items(
|
||||
project_root: &std::path::Path,
|
||||
stage_dir: &str,
|
||||
) -> Vec<(String, Option<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(super) fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) -> String {
|
||||
// Build a map from story_id → active AgentInfo for quick lookup.
|
||||
let active_agents = agents.list_agents().unwrap_or_default();
|
||||
let active_map: HashMap<String, &crate::agents::AgentInfo> = active_agents
|
||||
.iter()
|
||||
.filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending))
|
||||
.map(|a| (a.story_id.clone(), a))
|
||||
.collect();
|
||||
|
||||
// Read token usage once for all stories to avoid repeated file I/O.
|
||||
let cost_by_story: HashMap<String, f64> =
|
||||
crate::agents::token_usage::read_all(project_root)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.fold(HashMap::new(), |mut map, r| {
|
||||
*map.entry(r.story_id).or_insert(0.0) += r.usage.total_cost_usd;
|
||||
map
|
||||
});
|
||||
|
||||
let config = ProjectConfig::load(project_root).ok();
|
||||
|
||||
let mut out = String::from("**Pipeline Status**\n\n");
|
||||
|
||||
let stages = [
|
||||
("1_backlog", "Backlog"),
|
||||
("2_current", "In Progress"),
|
||||
("3_qa", "QA"),
|
||||
("4_merge", "Merge"),
|
||||
("5_done", "Done"),
|
||||
];
|
||||
|
||||
for (dir, label) in &stages {
|
||||
let items = read_stage_items(project_root, dir);
|
||||
let count = items.len();
|
||||
out.push_str(&format!("**{label}** ({count})\n"));
|
||||
if items.is_empty() {
|
||||
out.push_str(" *(none)*\n");
|
||||
} else {
|
||||
for (story_id, name) in &items {
|
||||
let display = story_short_label(story_id, name.as_deref());
|
||||
let cost_suffix = cost_by_story
|
||||
.get(story_id)
|
||||
.filter(|&&c| c > 0.0)
|
||||
.map(|c| format!(" — ${c:.2}"))
|
||||
.unwrap_or_default();
|
||||
if let Some(agent) = active_map.get(story_id) {
|
||||
let model_str = config
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.find_agent(&agent.agent_name))
|
||||
.and_then(|ac| ac.model.as_deref())
|
||||
.unwrap_or("?");
|
||||
out.push_str(&format!(
|
||||
" • {display}{cost_suffix} — {} ({model_str})\n",
|
||||
agent.agent_name
|
||||
));
|
||||
} else {
|
||||
out.push_str(&format!(" • {display}{cost_suffix}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// Free agents: configured agents not currently running or pending.
|
||||
out.push_str("**Free Agents**\n");
|
||||
if let Some(cfg) = &config {
|
||||
let busy_names: HashSet<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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agents::AgentPool;
|
||||
|
||||
#[test]
|
||||
fn status_command_matches() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status");
|
||||
assert!(result.is_some(), "status command should match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_command_returns_pipeline_text() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status");
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("Pipeline Status"),
|
||||
"status output should contain pipeline info: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_command_case_insensitive() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy STATUS");
|
||||
assert!(result.is_some(), "STATUS should match case-insensitively");
|
||||
}
|
||||
|
||||
// -- story_short_label --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn short_label_extracts_number_and_name() {
|
||||
let label = story_short_label("293_story_register_all_bot_commands", Some("Register all bot commands"));
|
||||
assert_eq!(label, "293 — Register all bot commands");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_label_number_only_when_no_name() {
|
||||
let label = story_short_label("297_story_improve_bot_status_command_formatting", None);
|
||||
assert_eq!(label, "297");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_label_falls_back_to_stem_when_no_numeric_prefix() {
|
||||
let label = story_short_label("no_number_here", None);
|
||||
assert_eq!(label, "no_number_here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_label_does_not_include_underscore_slug() {
|
||||
let label = story_short_label("293_story_register_all_bot_commands_in_the_command_registry", Some("Register all bot commands"));
|
||||
assert!(
|
||||
!label.contains("story_register"),
|
||||
"label should not contain the slug portion: {label}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- build_pipeline_status formatting -----------------------------------
|
||||
|
||||
#[test]
|
||||
fn status_does_not_show_full_filename_stem() {
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let stage_dir = tmp.path().join(".story_kit/work/2_current");
|
||||
std::fs::create_dir_all(&stage_dir).unwrap();
|
||||
|
||||
// Write a story file with a front-matter name
|
||||
let story_path = stage_dir.join("293_story_register_all_bot_commands.md");
|
||||
let mut f = std::fs::File::create(&story_path).unwrap();
|
||||
writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap();
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_pipeline_status(tmp.path(), &agents);
|
||||
|
||||
assert!(
|
||||
!output.contains("293_story_register_all_bot_commands"),
|
||||
"output must not show full filename stem: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("293 — Register all bot commands"),
|
||||
"output must show number and title: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- token cost in status output ----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn status_shows_cost_when_token_usage_exists() {
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let stage_dir = tmp.path().join(".story_kit/work/2_current");
|
||||
std::fs::create_dir_all(&stage_dir).unwrap();
|
||||
|
||||
let story_path = stage_dir.join("293_story_register_all_bot_commands.md");
|
||||
let mut f = std::fs::File::create(&story_path).unwrap();
|
||||
writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap();
|
||||
|
||||
// Write token usage for this story.
|
||||
let usage = crate::agents::TokenUsage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 200,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
total_cost_usd: 0.29,
|
||||
};
|
||||
let record = crate::agents::token_usage::build_record(
|
||||
"293_story_register_all_bot_commands",
|
||||
"coder-1",
|
||||
None,
|
||||
usage,
|
||||
);
|
||||
crate::agents::token_usage::append_record(tmp.path(), &record).unwrap();
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_pipeline_status(tmp.path(), &agents);
|
||||
|
||||
assert!(
|
||||
output.contains("293 — Register all bot commands — $0.29"),
|
||||
"output must show cost next to story: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_no_cost_when_no_usage() {
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let stage_dir = tmp.path().join(".story_kit/work/2_current");
|
||||
std::fs::create_dir_all(&stage_dir).unwrap();
|
||||
|
||||
let story_path = stage_dir.join("293_story_register_all_bot_commands.md");
|
||||
let mut f = std::fs::File::create(&story_path).unwrap();
|
||||
writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap();
|
||||
|
||||
// No token usage written.
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_pipeline_status(tmp.path(), &agents);
|
||||
|
||||
assert!(
|
||||
!output.contains("$"),
|
||||
"output must not show cost when no usage exists: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_aggregates_multiple_records_per_story() {
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let stage_dir = tmp.path().join(".story_kit/work/2_current");
|
||||
std::fs::create_dir_all(&stage_dir).unwrap();
|
||||
|
||||
let story_path = stage_dir.join("293_story_register_all_bot_commands.md");
|
||||
let mut f = std::fs::File::create(&story_path).unwrap();
|
||||
writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap();
|
||||
|
||||
// Write two records for the same story — costs should be summed.
|
||||
for cost in [0.10, 0.19] {
|
||||
let usage = crate::agents::TokenUsage {
|
||||
input_tokens: 50,
|
||||
output_tokens: 100,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
total_cost_usd: cost,
|
||||
};
|
||||
let record = crate::agents::token_usage::build_record(
|
||||
"293_story_register_all_bot_commands",
|
||||
"coder-1",
|
||||
None,
|
||||
usage,
|
||||
);
|
||||
crate::agents::token_usage::append_record(tmp.path(), &record).unwrap();
|
||||
}
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_pipeline_status(tmp.path(), &agents);
|
||||
|
||||
assert!(
|
||||
output.contains("293 — Register all bot commands — $0.29"),
|
||||
"output must show aggregated cost: {output}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user