huskies: merge 629_refactor_migrate_commanddispatch_and_commandcontext_to_services_bundle

This commit is contained in:
dave
2026-04-25 20:37:10 +00:00
parent 2a3f88fdcf
commit 14b158d0b2
27 changed files with 407 additions and 544 deletions
+40 -44
View File
@@ -16,7 +16,7 @@ pub(super) fn handle_ambient(ctx: &CommandContext) -> Option<String> {
_ => return Some("Usage: `ambient on` or `ambient off`".to_string()),
};
let room_ids: Vec<String> = {
let mut ambient = ctx.ambient_rooms.lock().unwrap();
let mut ambient = ctx.services.ambient_rooms.lock().unwrap();
if enable {
ambient.insert(ctx.room_id.to_string());
} else {
@@ -24,7 +24,7 @@ pub(super) fn handle_ambient(ctx: &CommandContext) -> Option<String> {
}
ambient.iter().cloned().collect()
};
save_ambient_rooms(ctx.project_root, &room_ids);
save_ambient_rooms(ctx.effective_root(), &room_ids);
let msg = if enable {
"Ambient mode on. I'll respond to all messages in this room."
} else {
@@ -35,35 +35,23 @@ pub(super) fn handle_ambient(ctx: &CommandContext) -> Option<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))
}
// Bug 352: ambient commands were being forwarded to LLM after refactors
// 328/330 because handle_ambient required is_addressed=true, but
// mentions_bot() only matches @-prefixed mentions, not bare bot names.
// "timmy ambient off" sets is_addressed=false even though it names the bot.
#[test]
fn ambient_on_works_when_unaddressed() {
let ambient_rooms = test_ambient_rooms();
let services = crate::services::Services::new_test(
std::path::PathBuf::from("/tmp"),
"Timmy".to_string(),
);
let room_id = "!myroom:example.com".to_string();
let agents = test_agents();
let dispatch = CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
// "timmy ambient on" — bot name mentioned but not @-prefixed, so
@@ -74,23 +62,27 @@ mod tests {
"ambient on should fire even when is_addressed=false"
);
assert!(
ambient_rooms.lock().unwrap().contains(&room_id),
services.ambient_rooms.lock().unwrap().contains(&room_id),
"room should be in ambient_rooms after ambient on"
);
}
#[test]
fn ambient_off_works_bare_in_ambient_room() {
let ambient_rooms = test_ambient_rooms();
let services = crate::services::Services::new_test(
std::path::PathBuf::from("/tmp"),
"Timmy".to_string(),
);
let room_id = "!myroom:example.com".to_string();
ambient_rooms.lock().unwrap().insert(room_id.clone());
let agents = test_agents();
services
.ambient_rooms
.lock()
.unwrap()
.insert(room_id.clone());
let dispatch = CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
// Bare "ambient off" in an ambient room (is_addressed=false).
@@ -105,22 +97,22 @@ mod tests {
"response should confirm ambient off: {output}"
);
assert!(
!ambient_rooms.lock().unwrap().contains(&room_id),
!services.ambient_rooms.lock().unwrap().contains(&room_id),
"room should be removed from ambient_rooms after ambient off"
);
}
#[test]
fn ambient_on_enables_ambient_mode() {
let ambient_rooms = test_ambient_rooms();
let agents = test_agents();
let services = crate::services::Services::new_test(
std::path::PathBuf::from("/tmp"),
"Timmy".to_string(),
);
let room_id = "!myroom:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
let result = try_handle_command(&dispatch, "@timmy ambient on");
@@ -131,25 +123,29 @@ mod tests {
"response should confirm ambient on: {output}"
);
assert!(
ambient_rooms.lock().unwrap().contains(&room_id),
services.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 services = crate::services::Services::new_test(
std::path::PathBuf::from("/tmp"),
"Timmy".to_string(),
);
let room_id = "!myroom:example.com".to_string();
// Pre-insert the room
ambient_rooms.lock().unwrap().insert(room_id.clone());
services
.ambient_rooms
.lock()
.unwrap()
.insert(room_id.clone());
let dispatch = CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
let result = try_handle_command(&dispatch, "@timmy ambient off");
@@ -160,7 +156,7 @@ mod tests {
"response should confirm ambient off: {output}"
);
assert!(
!ambient_rooms.lock().unwrap().contains(&room_id),
!services.ambient_rooms.lock().unwrap().contains(&room_id),
"room should be removed from ambient_rooms after ambient off"
);
}
+4 -11
View File
@@ -8,7 +8,7 @@ use super::status::story_short_label;
/// 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) {
let records = match crate::agents::token_usage::read_all(ctx.effective_root()) {
Ok(r) => r,
Err(e) => return Some(format!("Failed to read token usage: {e}")),
};
@@ -99,8 +99,6 @@ pub(super) fn extract_agent_type(agent_name: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::AgentPool;
use std::sync::Arc;
fn write_token_records(
root: &std::path::Path,
@@ -139,18 +137,13 @@ mod tests {
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 services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, "@timmy cost")
+29 -50
View File
@@ -34,8 +34,8 @@ pub(super) fn handle_coverage(ctx: &CommandContext) -> Option<String> {
let args = ctx.args.trim();
match args {
"run" => Some(run_coverage(ctx.project_root)),
"" => Some(read_cached_coverage(ctx.project_root)),
"run" => Some(run_coverage(ctx.effective_root())),
"" => Some(read_cached_coverage(ctx.effective_root())),
other => Some(format!(
"Usage: `coverage` (cached) or `coverage run` (fresh)\n\nUnknown argument: `{other}`"
)),
@@ -262,32 +262,13 @@ fn extract_summary_field(output: &str, label: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
fn make_ctx<'a>(
agents: &'a Arc<AgentPool>,
ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
services: &'a crate::services::Services,
project_root: &'a std::path::Path,
args: &'a str,
) -> super::super::CommandContext<'a> {
super::super::CommandContext {
bot_name: "Timmy",
args,
project_root,
agents,
ambient_rooms,
room_id: "!test:example.com",
}
}
fn test_agents() -> Arc<AgentPool> {
Arc::new(AgentPool::new_test(3000))
}
fn test_ambient() -> Arc<Mutex<HashSet<String>>> {
Arc::new(Mutex::new(HashSet::new()))
super::super::CommandContext::new_test(services, args, "!test:example.com", project_root)
}
fn sample_coverage_report(overall: f64, threshold: f64, files: Vec<(&str, f64)>) -> String {
@@ -336,9 +317,9 @@ mod tests {
);
std::fs::write(dir.path().join(".coverage_report.json"), &report).unwrap();
let agents = test_agents();
let ambient = test_ambient();
let ctx = make_ctx(&agents, &ambient, dir.path(), "");
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, dir.path(), "");
let output = handle_coverage(&ctx).unwrap();
assert!(output.contains("72.5"), "should include overall: {output}");
@@ -371,9 +352,9 @@ mod tests {
let report = sample_coverage_report(40.0, 30.0, files);
std::fs::write(dir.path().join(".coverage_report.json"), &report).unwrap();
let agents = test_agents();
let ambient = test_ambient();
let ctx = make_ctx(&agents, &ambient, dir.path(), "");
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, dir.path(), "");
let output = handle_coverage(&ctx).unwrap();
assert!(output.contains("a.rs"), "should show lowest file: {output}");
@@ -396,9 +377,9 @@ mod tests {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join(".coverage_baseline"), "72.5\n").unwrap();
let agents = test_agents();
let ambient = test_ambient();
let ctx = make_ctx(&agents, &ambient, dir.path(), "");
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, dir.path(), "");
let output = handle_coverage(&ctx).unwrap();
assert!(
@@ -416,9 +397,9 @@ mod tests {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join(".coverage_baseline"), "60.00\n65.21\n").unwrap();
let agents = test_agents();
let ambient = test_ambient();
let ctx = make_ctx(&agents, &ambient, dir.path(), "");
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, dir.path(), "");
let output = handle_coverage(&ctx).unwrap();
assert!(
@@ -435,9 +416,9 @@ mod tests {
fn coverage_missing_both_files_reports_clearly() {
let dir = tempfile::tempdir().expect("tempdir");
let agents = test_agents();
let ambient = test_ambient();
let ctx = make_ctx(&agents, &ambient, dir.path(), "");
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, dir.path(), "");
let output = handle_coverage(&ctx).unwrap();
assert!(
@@ -450,9 +431,9 @@ mod tests {
fn coverage_run_missing_script_reports_error() {
let dir = tempfile::tempdir().expect("tempdir");
let agents = test_agents();
let ambient = test_ambient();
let ctx = make_ctx(&agents, &ambient, dir.path(), "run");
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, dir.path(), "run");
let output = handle_coverage(&ctx).unwrap();
assert!(
@@ -464,9 +445,9 @@ mod tests {
#[test]
fn coverage_unknown_arg_returns_usage() {
let dir = tempfile::tempdir().expect("tempdir");
let agents = test_agents();
let ambient = test_ambient();
let ctx = make_ctx(&agents, &ambient, dir.path(), "blah");
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, dir.path(), "blah");
let output = handle_coverage(&ctx).unwrap();
assert!(
output.contains("Usage"),
@@ -480,15 +461,13 @@ mod tests {
let report = sample_coverage_report(55.0, 50.0, vec![]);
std::fs::write(dir.path().join(".coverage_report.json"), &report).unwrap();
let agents = test_agents();
let ambient = test_ambient();
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = super::super::CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: dir.path(),
agents: &agents,
ambient_rooms: &ambient,
room_id: &room_id,
};
let result = super::super::try_handle_command(&dispatch, "@timmy coverage");
+6 -13
View File
@@ -25,7 +25,7 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option<String> {
"Usage: `{} depends <number> [dep1 dep2 ...]`\n\nExamples:\n\
• `{0} depends 484 477 478` — set depends_on: [477, 478]\n\
• `{0} depends 484` — clear all dependencies",
ctx.bot_name
ctx.services.bot_name
));
}
@@ -35,7 +35,7 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option<String> {
if !num_str.chars().all(|c| c.is_ascii_digit()) || num_str.is_empty() {
return Some(format!(
"Invalid story number: `{num_str}`. Usage: `{} depends <number> [dep1 dep2 ...]`",
ctx.bot_name
ctx.services.bot_name
));
}
@@ -54,7 +54,7 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option<String> {
// Find the story by numeric prefix: CRDT → content store → filesystem.
let (story_id, stage_dir, path, content) =
match crate::chat::lookup::find_story_by_number(ctx.project_root, num_str) {
match crate::chat::lookup::find_story_by_number(ctx.effective_root(), num_str) {
Some(found) => found,
None => {
return Some(format!(
@@ -115,22 +115,15 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option<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 depends_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 services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, &format!("@timmy depends {args}"))
+7 -13
View File
@@ -15,13 +15,13 @@ pub(super) fn handle_diff(ctx: &CommandContext) -> Option<String> {
if num_str.is_empty() {
return Some(format!(
"Usage: `{} diff <number>`\n\nShows the git diff from the main branch to the story's worktree HEAD.",
ctx.bot_name
ctx.services.bot_name
));
}
if !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Invalid story number: `{num_str}`. Usage: `{} diff <number>`",
ctx.bot_name
ctx.services.bot_name
));
}
@@ -34,14 +34,14 @@ pub(super) fn handle_diff(ctx: &CommandContext) -> Option<String> {
}
};
let wt_path = crate::worktree::worktree_path(ctx.project_root, &story_id);
let wt_path = crate::worktree::worktree_path(ctx.effective_root(), &story_id);
if !wt_path.is_dir() {
return Some(format!(
"Story **{num_str}** has no worktree. The diff is only available once a coder has started working on it."
));
}
let base_branch = resolve_base_branch(ctx.project_root);
let base_branch = resolve_base_branch(ctx.effective_root());
let range = format!("{base_branch}...HEAD");
let stat = run_git(&wt_path, &["diff", "--stat", &range]);
@@ -144,22 +144,16 @@ fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> &str {
#[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 diff_cmd(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 services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, &format!("@timmy diff {args}"))
+10 -19
View File
@@ -18,10 +18,10 @@ pub(super) fn handle_freeze(ctx: &CommandContext) -> Option<String> {
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Usage: `{} freeze <number>` (e.g. `freeze 42`)",
ctx.bot_name
ctx.services.bot_name
));
}
Some(freeze_by_number(ctx.project_root, num_str))
Some(freeze_by_number(ctx.effective_root(), num_str))
}
/// Core freeze logic: find story by numeric prefix and set `frozen: true`.
@@ -80,10 +80,10 @@ pub(super) fn handle_unfreeze(ctx: &CommandContext) -> Option<String> {
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Usage: `{} unfreeze <number>` (e.g. `unfreeze 42`)",
ctx.bot_name
ctx.services.bot_name
));
}
Some(unfreeze_by_number(ctx.project_root, num_str))
Some(unfreeze_by_number(ctx.effective_root(), num_str))
}
/// Core unfreeze logic: find story by numeric prefix and clear `frozen` flag.
@@ -135,38 +135,29 @@ fn unfreeze_by_story_id(story_id: &str) -> String {
#[cfg(test)]
mod tests {
use crate::agents::AgentPool;
use crate::chat::test_helpers::write_story_file;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command};
fn freeze_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 services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, &format!("@timmy freeze {args}"))
}
fn unfreeze_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 services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, &format!("@timmy unfreeze {args}"))
+19 -39
View File
@@ -9,7 +9,7 @@ pub(super) fn handle_git(ctx: &CommandContext) -> Option<String> {
// Current branch
let branch = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(ctx.project_root)
.current_dir(ctx.effective_root())
.output()
.ok()
.filter(|o| o.status.success())
@@ -19,7 +19,7 @@ pub(super) fn handle_git(ctx: &CommandContext) -> Option<String> {
// Porcelain status for staged + unstaged changes
let status_output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(ctx.project_root)
.current_dir(ctx.effective_root())
.output()
.ok()
.filter(|o| o.status.success())
@@ -32,7 +32,7 @@ pub(super) fn handle_git(ctx: &CommandContext) -> Option<String> {
// 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)
.current_dir(ctx.effective_root())
.output()
.ok()
.filter(|o| o.status.success())
@@ -77,20 +77,8 @@ pub(super) fn handle_git(ctx: &CommandContext) -> Option<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 git_command_is_registered() {
use super::super::commands;
@@ -118,15 +106,13 @@ mod tests {
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 services =
crate::services::Services::new_test(repo_root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: repo_root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
let result = try_handle_command(&dispatch, "@timmy git");
@@ -138,15 +124,13 @@ mod tests {
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 services =
crate::services::Services::new_test(repo_root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: repo_root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
let output = try_handle_command(&dispatch, "@timmy git").unwrap();
@@ -161,15 +145,13 @@ mod tests {
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 services =
crate::services::Services::new_test(repo_root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: repo_root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
let output = try_handle_command(&dispatch, "@timmy git").unwrap();
@@ -184,15 +166,13 @@ mod tests {
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 services =
crate::services::Services::new_test(repo_root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: repo_root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
let output = try_handle_command(&dispatch, "@timmy git").unwrap();
+1 -1
View File
@@ -3,7 +3,7 @@
use super::{CommandContext, commands};
pub(super) fn handle_help(ctx: &CommandContext) -> Option<String> {
let mut output = format!("**{} Commands**\n\n", ctx.bot_name);
let mut output = format!("**{} Commands**\n\n", ctx.services.bot_name);
let mut sorted: Vec<_> = commands().iter().collect();
sorted.sort_by_key(|c| c.name);
for cmd in sorted {
+38 -49
View File
@@ -41,7 +41,7 @@ pub(super) fn handle_loc(ctx: &CommandContext) -> Option<String> {
let args = ctx.args.trim();
if args.is_empty() {
return Some(loc_top_n(ctx.project_root, DEFAULT_TOP_N));
return Some(loc_top_n(ctx.effective_root(), DEFAULT_TOP_N));
}
let first_token = args.split_whitespace().next().unwrap_or("");
@@ -49,8 +49,8 @@ pub(super) fn handle_loc(ctx: &CommandContext) -> Option<String> {
Ok(0) => format!(
"Usage: `loc [N]` or `loc <filepath>` — show top N source files by line count (default {DEFAULT_TOP_N}), or line count for a specific file"
),
Ok(n) => loc_top_n(ctx.project_root, n),
Err(_) => loc_single_file(ctx.project_root, args),
Ok(n) => loc_top_n(ctx.effective_root(), n),
Err(_) => loc_single_file(ctx.effective_root(), args),
})
}
@@ -209,24 +209,13 @@ fn is_source_extension(ext: &str) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
fn make_ctx<'a>(
agents: &'a Arc<AgentPool>,
ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
services: &'a crate::services::Services,
project_root: &'a std::path::Path,
args: &'a str,
) -> super::super::CommandContext<'a> {
super::super::CommandContext {
bot_name: "Timmy",
args,
project_root,
agents,
ambient_rooms,
room_id: "!test:example.com",
}
super::super::CommandContext::new_test(services, args, "!test:example.com", project_root)
}
#[test]
@@ -252,12 +241,12 @@ mod tests {
#[test]
fn loc_default_returns_top_10() {
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or(std::path::Path::new("."));
let ctx = make_ctx(&agents, &ambient_rooms, repo_root, "");
let services =
crate::services::Services::new_test(repo_root.to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, repo_root, "");
let output = handle_loc(&ctx).unwrap();
assert!(
output.contains("Top"),
@@ -273,12 +262,12 @@ mod tests {
#[test]
fn loc_with_arg_5_returns_at_most_5() {
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or(std::path::Path::new("."));
let ctx = make_ctx(&agents, &ambient_rooms, repo_root, "5");
let services =
crate::services::Services::new_test(repo_root.to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, repo_root, "5");
let output = handle_loc(&ctx).unwrap();
let count = output.lines().filter(|l| l.contains(". `")).count();
assert!(
@@ -289,12 +278,12 @@ mod tests {
#[test]
fn loc_with_arg_20_returns_at_most_20() {
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or(std::path::Path::new("."));
let ctx = make_ctx(&agents, &ambient_rooms, repo_root, "20");
let services =
crate::services::Services::new_test(repo_root.to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, repo_root, "20");
let output = handle_loc(&ctx).unwrap();
let count = output.lines().filter(|l| l.contains(". `")).count();
assert!(
@@ -305,12 +294,12 @@ mod tests {
#[test]
fn loc_output_contains_rank_and_line_count() {
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or(std::path::Path::new("."));
let ctx = make_ctx(&agents, &ambient_rooms, repo_root, "");
let services =
crate::services::Services::new_test(repo_root.to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, repo_root, "");
let output = handle_loc(&ctx).unwrap();
// Each entry should have "N. `path` — N lines"
assert!(
@@ -325,12 +314,12 @@ mod tests {
#[test]
fn loc_zero_arg_returns_usage() {
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or(std::path::Path::new("."));
let ctx = make_ctx(&agents, &ambient_rooms, repo_root, "0");
let services =
crate::services::Services::new_test(repo_root.to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, repo_root, "0");
let output = handle_loc(&ctx).unwrap();
assert!(
output.contains("Usage"),
@@ -349,9 +338,9 @@ mod tests {
writeln!(f, "fn line_{i}() {{}}").unwrap();
}
}
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let ctx = make_ctx(&agents, &ambient_rooms, dir.path(), "hello.rs");
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, dir.path(), "hello.rs");
let output = handle_loc(&ctx).unwrap();
assert!(
output.contains("42"),
@@ -366,9 +355,9 @@ mod tests {
#[test]
fn loc_filepath_nonexistent_returns_error() {
let dir = tempfile::tempdir().expect("tempdir");
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let ctx = make_ctx(&agents, &ambient_rooms, dir.path(), "does_not_exist.rs");
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, dir.path(), "does_not_exist.rs");
let output = handle_loc(&ctx).unwrap();
assert!(
output.contains("not found") || output.contains("Error"),
@@ -378,12 +367,12 @@ mod tests {
#[test]
fn loc_skips_worktrees_directory() {
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or(std::path::Path::new("."));
let ctx = make_ctx(&agents, &ambient_rooms, repo_root, "");
let services =
crate::services::Services::new_test(repo_root.to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, repo_root, "");
let output = handle_loc(&ctx).unwrap();
assert!(
!output.contains(".huskies/worktrees"),
@@ -413,9 +402,9 @@ mod tests {
writeln!(f, "fn f{i}() {{}}").unwrap();
}
}
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let ctx = make_ctx(&agents, &ambient_rooms, dir.path(), "50");
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, dir.path(), "50");
let output = handle_loc(&ctx).unwrap();
assert!(
!output.contains("target/"),
@@ -447,9 +436,9 @@ mod tests {
writeln!(f, "fn line_{i}() {{}}").unwrap();
}
}
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let ctx = make_ctx(&agents, &ambient_rooms, dir.path(), "50");
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, dir.path(), "50");
let output = handle_loc(&ctx).unwrap();
assert!(
!output.contains("package-lock.json"),
@@ -486,9 +475,9 @@ mod tests {
std::io::Write::write_all(&mut f, format!("fn f{i}() {{}}\n").as_bytes()).unwrap();
}
}
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let ctx = make_ctx(&agents, &ambient_rooms, dir.path(), "50");
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, dir.path(), "50");
let output = handle_loc(&ctx).unwrap();
assert!(
!output.contains("Cargo.lock"),
+5 -11
View File
@@ -16,13 +16,13 @@ pub(super) fn handle_logs(ctx: &CommandContext) -> Option<String> {
if num_str.is_empty() {
return Some(format!(
"Usage: `{} logs <number>`\n\nShows the last agent log lines for a story.",
ctx.bot_name
ctx.services.bot_name
));
}
if !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Invalid story number: `{num_str}`. Usage: `{} logs <number>`",
ctx.bot_name
ctx.services.bot_name
));
}
@@ -115,22 +115,16 @@ fn read_log_tail(path: &Path, n: usize) -> String {
#[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 logs_cmd(root: &Path, args: &str) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, &format!("@timmy logs {args}"))
+72 -46
View File
@@ -28,11 +28,9 @@ mod triage;
pub(crate) mod unblock;
mod unreleased;
use crate::agents::AgentPool;
use crate::chat::util::strip_bot_mention;
use std::collections::HashSet;
use crate::services::Services;
use std::path::Path;
use std::sync::{Arc, Mutex};
/// A bot-level command that is handled without LLM invocation.
pub struct BotCommand {
@@ -48,41 +46,69 @@ pub struct BotCommand {
/// 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.
/// Groups the [`Services`] bundle with per-message dispatch context.
/// 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::chat::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).
/// Shared services bundle (project root, agent pool, ambient rooms, …).
pub services: &'a Services,
/// Effective project root — usually `services.project_root`, but the Matrix
/// transport overrides this in gateway mode to point at the active project.
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>>>,
/// Bot user ID for mention-stripping — transport-specific (e.g. Matrix's
/// `OwnedUserId` string differs from `services.bot_user_id`).
pub bot_user_id: &'a str,
/// The room this message came from (needed by ambient).
pub room_id: &'a str,
}
/// Context passed to individual command handlers.
///
/// Holds a reference to the shared [`Services`] bundle so that handlers access
/// project-wide state via `ctx.services.*`. The effective project root may
/// differ from `services.project_root` in gateway mode — use
/// [`effective_root()`](Self::effective_root) to get the correct path.
pub struct CommandContext<'a> {
/// The bot's display name (e.g., "Timmy").
pub bot_name: &'a str,
/// Shared services bundle.
pub services: &'a Services,
/// 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,
/// Effective project root for this dispatch. Equals `services.project_root`
/// in standalone mode; in gateway mode the Matrix transport sets this to
/// the active-project subdirectory.
project_root: &'a Path,
}
impl<'a> CommandContext<'a> {
/// Returns the effective project root for this command invocation.
///
/// In standalone mode this equals `services.project_root`. In gateway mode
/// (Matrix transport) it resolves to the active project subdirectory.
pub fn effective_root(&self) -> &Path {
self.project_root
}
/// Test-only constructor that allows submodule tests to build a
/// `CommandContext` despite the private `project_root` field.
#[cfg(test)]
pub fn new_test(
services: &'a Services,
args: &'a str,
room_id: &'a str,
project_root: &'a Path,
) -> Self {
Self {
services,
args,
room_id,
project_root,
}
}
}
/// Returns the full list of registered bot commands.
@@ -245,7 +271,8 @@ pub fn try_handle_command_with_html(
dispatch: &CommandDispatch<'_>,
message: &str,
) -> Option<(String, String)> {
let command_text = strip_bot_mention(message, dispatch.bot_name, dispatch.bot_user_id);
let command_text =
strip_bot_mention(message, &dispatch.services.bot_name, dispatch.bot_user_id);
let trimmed = command_text.trim();
if !trimmed.is_empty() {
let (cmd_name, args) = match trimmed.split_once(char::is_whitespace) {
@@ -254,7 +281,8 @@ pub fn try_handle_command_with_html(
};
// Status command: emoji indicators render natively in all clients.
if cmd_name.eq_ignore_ascii_case("status") && args.is_empty() {
let body = status::build_pipeline_status(dispatch.project_root, dispatch.agents);
let body =
status::build_pipeline_status(dispatch.project_root, &dispatch.services.agents);
let html = plain_to_html(&body);
return Some((body, html));
}
@@ -288,7 +316,8 @@ fn plain_to_html(markdown: &str) -> String {
/// 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 command_text =
strip_bot_mention(message, &dispatch.services.bot_name, dispatch.bot_user_id);
let trimmed = command_text.trim();
if trimmed.is_empty() {
return None;
@@ -301,12 +330,10 @@ pub fn try_handle_command(dispatch: &CommandDispatch<'_>, message: &str) -> Opti
let cmd_lower = cmd_name.to_ascii_lowercase();
let ctx = CommandContext {
bot_name: dispatch.bot_name,
services: dispatch.services,
args,
project_root: dispatch.project_root,
agents: dispatch.agents,
ambient_rooms: dispatch.ambient_rooms,
room_id: dispatch.room_id,
project_root: dispatch.project_root,
};
commands()
@@ -382,39 +409,38 @@ fn handle_rebuild_fallback(_ctx: &CommandContext) -> Option<String> {
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::agents::AgentPool;
use crate::services::Services;
use std::sync::Arc;
// -- test helpers (shared with submodule tests) -------------------------
pub fn test_ambient_rooms() -> Arc<Mutex<HashSet<String>>> {
Arc::new(Mutex::new(HashSet::new()))
/// Build a [`Services`] bundle for tests with the given bot name and a `/tmp`
/// project root.
pub fn test_services_named(bot_name: &str) -> Arc<Services> {
Services::new_test(std::path::PathBuf::from("/tmp"), bot_name.to_string())
}
pub fn test_agents() -> Arc<AgentPool> {
Arc::new(AgentPool::new_test(3000))
}
pub fn try_cmd(
bot_name: &str,
/// Dispatch a message through the command registry using the given Services.
pub fn try_cmd_with_services(
bot_user_id: &str,
message: &str,
ambient_rooms: &Arc<Mutex<HashSet<String>>>,
services: &Services,
) -> Option<String> {
let agents = test_agents();
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name,
services,
project_root: &services.project_root,
bot_user_id,
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, message)
}
/// Convenience helper: create a temporary [`Services`] with the given bot
/// name and 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())
let services = test_services_named(bot_name);
try_cmd_with_services(bot_user_id, message, &services)
}
// Re-export commands() for submodule tests
+7 -14
View File
@@ -24,7 +24,7 @@ pub(super) fn handle_move(ctx: &CommandContext) -> Option<String> {
None => {
return Some(format!(
"Usage: `{} move <number> <stage>`\n\nValid stages: {}",
ctx.bot_name,
ctx.services.bot_name,
VALID_STAGES.join(", ")
));
}
@@ -33,7 +33,7 @@ pub(super) fn handle_move(ctx: &CommandContext) -> Option<String> {
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Invalid story number: `{num_str}`. Usage: `{} move <number> <stage>`",
ctx.bot_name
ctx.services.bot_name
));
}
@@ -47,7 +47,7 @@ pub(super) fn handle_move(ctx: &CommandContext) -> Option<String> {
// Find the story by numeric prefix: CRDT → content store → filesystem.
let (story_id, _stage_dir, _path, content) =
match crate::chat::lookup::find_story_by_number(ctx.project_root, num_str) {
match crate::chat::lookup::find_story_by_number(ctx.effective_root(), num_str) {
Some(found) => found,
None => {
return Some(format!(
@@ -62,7 +62,7 @@ pub(super) fn handle_move(ctx: &CommandContext) -> Option<String> {
let display_name = found_name.as_deref().unwrap_or(&story_id);
match move_story_to_stage(ctx.project_root, &story_id, &target_stage) {
match move_story_to_stage(ctx.effective_root(), &story_id, &target_stage) {
Ok((from_stage, to_stage)) => Some(format!(
"Moved **{display_name}** from **{from_stage}** to **{to_stage}**."
)),
@@ -76,22 +76,15 @@ pub(super) fn handle_move(ctx: &CommandContext) -> Option<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 move_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 services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, &format!("@timmy move {args}"))
+9 -15
View File
@@ -13,17 +13,17 @@ pub(super) fn handle_overview(ctx: &CommandContext) -> Option<String> {
if num_str.is_empty() {
return Some(format!(
"Usage: `{} overview <number>`\n\nShows the implementation summary for a story.",
ctx.bot_name
ctx.services.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
ctx.services.bot_name
));
}
let commit_hash = match find_story_merge_commit(ctx.project_root, num_str) {
let commit_hash = match find_story_merge_commit(ctx.effective_root(), num_str) {
Some(h) => h,
None => {
return Some(format!(
@@ -33,9 +33,9 @@ pub(super) fn handle_overview(ctx: &CommandContext) -> Option<String> {
}
};
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 stat_output = get_commit_stat(ctx.effective_root(), &commit_hash);
let symbols = extract_diff_symbols(ctx.effective_root(), &commit_hash);
let story_name = find_story_name(ctx.effective_root(), num_str);
let short_hash = &commit_hash[..commit_hash.len().min(8)];
let mut out = match story_name {
@@ -195,22 +195,16 @@ fn parse_symbol_definition(code: &str) -> Option<String> {
#[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 services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, &format!("@timmy overview {args}"))
+33 -56
View File
@@ -22,7 +22,7 @@ const MAX_OUTPUT_LINES: usize = 80;
fn resolve_run_dir(ctx: &CommandContext) -> Result<PathBuf, String> {
let number = ctx.args.trim();
if number.is_empty() {
return Ok(ctx.project_root.to_path_buf());
return Ok(ctx.effective_root().to_path_buf());
}
// Validate: must be all digits.
@@ -32,7 +32,7 @@ fn resolve_run_dir(ctx: &CommandContext) -> Result<PathBuf, String> {
));
}
let worktrees_dir = ctx.project_root.join(".huskies/worktrees");
let worktrees_dir = ctx.effective_root().join(".huskies/worktrees");
let prefix = format!("{number}_");
match std::fs::read_dir(&worktrees_dir) {
Ok(entries) => {
@@ -145,32 +145,13 @@ fn extract_count(line: &str, label: &str) -> Option<u64> {
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
fn make_ctx<'a>(
agents: &'a Arc<AgentPool>,
ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
services: &'a crate::services::Services,
project_root: &'a std::path::Path,
args: &'a str,
) -> super::super::CommandContext<'a> {
super::super::CommandContext {
bot_name: "Timmy",
args,
project_root,
agents,
ambient_rooms,
room_id: "!test:example.com",
}
}
fn test_agents() -> Arc<AgentPool> {
Arc::new(AgentPool::new_test(3000))
}
fn test_ambient() -> Arc<Mutex<HashSet<String>>> {
Arc::new(Mutex::new(HashSet::new()))
super::super::CommandContext::new_test(services, args, "!test:example.com", project_root)
}
fn write_script(dir: &std::path::Path, content: &str) {
@@ -209,9 +190,9 @@ mod tests {
#[test]
fn test_command_missing_script_returns_error() {
let dir = tempfile::tempdir().unwrap();
let agents = test_agents();
let ambient = test_ambient();
let ctx = make_ctx(&agents, &ambient, dir.path(), "");
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, dir.path(), "");
let output = handle_test(&ctx).unwrap();
assert!(
output.contains("not found") || output.contains("script"),
@@ -226,9 +207,9 @@ mod tests {
dir.path(),
"#!/usr/bin/env bash\necho 'test result: ok. 4 passed; 0 failed'\nexit 0\n",
);
let agents = test_agents();
let ambient = test_ambient();
let ctx = make_ctx(&agents, &ambient, dir.path(), "");
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, dir.path(), "");
let output = handle_test(&ctx).unwrap();
assert!(output.contains("PASS"), "should show PASS: {output}");
assert!(output.contains('4'), "should show test count: {output}");
@@ -241,9 +222,9 @@ mod tests {
dir.path(),
"#!/usr/bin/env bash\necho 'test result: FAILED. 1 passed; 2 failed'\nexit 1\n",
);
let agents = test_agents();
let ambient = test_ambient();
let ctx = make_ctx(&agents, &ambient, dir.path(), "");
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, dir.path(), "");
let output = handle_test(&ctx).unwrap();
assert!(output.contains("FAIL"), "should show FAIL: {output}");
assert!(output.contains('2'), "should show failed count: {output}");
@@ -253,15 +234,13 @@ mod tests {
fn test_command_works_via_dispatch() {
let dir = tempfile::tempdir().unwrap();
write_script(dir.path(), "#!/usr/bin/env bash\necho 'ok'\nexit 0\n");
let agents = test_agents();
let ambient = test_ambient();
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = super::super::CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: dir.path(),
agents: &agents,
ambient_rooms: &ambient,
room_id: &room_id,
};
let result = super::super::try_handle_command(&dispatch, "@timmy run_tests");
@@ -312,9 +291,9 @@ mod tests {
dir.path(),
"#!/usr/bin/env bash\necho 'test result: ok. 7 passed; 0 failed'\nexit 0\n",
);
let agents = test_agents();
let ambient = test_ambient();
let ctx = make_ctx(&agents, &ambient, dir.path(), "");
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, dir.path(), "");
let output = handle_test(&ctx).unwrap();
assert!(
output.contains("PASS"),
@@ -330,9 +309,9 @@ mod tests {
fn run_tests_with_story_number_uses_worktree() {
let dir = tempfile::tempdir().unwrap();
create_worktree(dir.path(), 541, true);
let agents = test_agents();
let ambient = test_ambient();
let ctx = make_ctx(&agents, &ambient, dir.path(), "541");
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, dir.path(), "541");
let output = handle_test(&ctx).unwrap();
assert!(
output.contains("PASS"),
@@ -349,9 +328,9 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
// Create the worktrees dir but no matching entry
std::fs::create_dir_all(dir.path().join(".huskies/worktrees")).unwrap();
let agents = test_agents();
let ambient = test_ambient();
let ctx = make_ctx(&agents, &ambient, dir.path(), "999");
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, dir.path(), "999");
let output = handle_test(&ctx).unwrap();
assert!(
output.contains("No worktree found") || output.contains("999"),
@@ -362,9 +341,9 @@ mod tests {
#[test]
fn run_tests_with_invalid_arg_returns_error() {
let dir = tempfile::tempdir().unwrap();
let agents = test_agents();
let ambient = test_ambient();
let ctx = make_ctx(&agents, &ambient, dir.path(), "notanumber");
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&services, dir.path(), "notanumber");
let output = handle_test(&ctx).unwrap();
assert!(
output.contains("Invalid argument") || output.contains("notanumber"),
@@ -376,15 +355,13 @@ mod tests {
fn run_tests_with_story_number_via_dispatch() {
let dir = tempfile::tempdir().unwrap();
create_worktree(dir.path(), 541, true);
let agents = test_agents();
let ambient = test_ambient();
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = super::super::CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: dir.path(),
agents: &agents,
ambient_rooms: &ambient,
room_id: &room_id,
};
let result = super::super::try_handle_command(&dispatch, "@timmy run_tests 541");
+36 -46
View File
@@ -34,7 +34,7 @@ pub(super) fn handle_setup(ctx: &CommandContext) -> Option<String> {
/// This mirrors `wizard_generate` (with no content) from the MCP tools, making
/// the interview flow accessible from chat transports (Matrix, Slack, WhatsApp).
fn wizard_generate_reply(ctx: &CommandContext) -> String {
let root = ctx.project_root;
let root = ctx.effective_root();
let mut state = match WizardState::load(root) {
Some(s) => s,
None => return "No wizard active.".to_string(),
@@ -63,10 +63,10 @@ fn wizard_generate_reply(ctx: &CommandContext) -> String {
/// If no wizard state exists, automatically initializes it so the user does
/// not need to run `huskies init` manually.
fn wizard_status_reply(ctx: &CommandContext) -> String {
if WizardState::load(ctx.project_root).is_none() {
WizardState::init_if_missing(ctx.project_root);
if WizardState::load(ctx.effective_root()).is_none() {
WizardState::init_if_missing(ctx.effective_root());
}
match WizardState::load(ctx.project_root) {
match WizardState::load(ctx.effective_root()) {
Some(state) => format_wizard_state(&state),
None => "Unable to initialize setup wizard. Ensure the `.huskies/` directory exists."
.to_string(),
@@ -75,7 +75,7 @@ fn wizard_status_reply(ctx: &CommandContext) -> String {
/// Confirm the current wizard step, writing any staged content to disk.
fn wizard_confirm_reply(ctx: &CommandContext) -> String {
let root = ctx.project_root;
let root = ctx.effective_root();
let mut state = match WizardState::load(root) {
Some(s) => s,
None => return "No wizard active.".to_string(),
@@ -124,7 +124,7 @@ fn wizard_confirm_reply(ctx: &CommandContext) -> String {
/// Skip the current wizard step without writing any file.
fn wizard_skip_reply(ctx: &CommandContext) -> String {
let root = ctx.project_root;
let root = ctx.effective_root();
let mut state = match WizardState::load(root) {
Some(s) => s,
None => return "No wizard active.".to_string(),
@@ -157,7 +157,7 @@ fn wizard_skip_reply(ctx: &CommandContext) -> String {
/// Discard staged content and reset the current step to pending.
fn wizard_retry_reply(ctx: &CommandContext) -> String {
let root = ctx.project_root;
let root = ctx.effective_root();
let mut state = match WizardState::load(root) {
Some(s) => s,
None => return "No wizard active.".to_string(),
@@ -189,33 +189,23 @@ fn wizard_retry_reply(ctx: &CommandContext) -> String {
mod tests {
use super::*;
use crate::io::wizard::WizardState;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use tempfile::TempDir;
fn make_ctx<'a>(
args: &'a str,
project_root: &'a std::path::Path,
agents: &'a Arc<crate::agents::AgentPool>,
ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
services: &'a crate::services::Services,
) -> CommandContext<'a> {
CommandContext {
bot_name: "Bot",
args,
project_root,
agents,
ambient_rooms,
room_id: "!test:example.com",
}
CommandContext::new_test(services, args, "!test:example.com", project_root)
}
#[test]
fn setup_no_wizard_auto_initializes() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
let agents = Arc::new(crate::agents::AgentPool::new_test(4000));
let rooms = Arc::new(Mutex::new(HashSet::new()));
let ctx = make_ctx("", dir.path(), &agents, &rooms);
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Bot".to_string());
let ctx = make_ctx("", dir.path(), &services);
let result = handle_setup(&ctx).unwrap();
// Bot should auto-initialize and return wizard status, not ask user to run huskies init.
assert!(result.contains("Setup wizard"));
@@ -229,9 +219,9 @@ mod tests {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
WizardState::init_if_missing(dir.path());
let agents = Arc::new(crate::agents::AgentPool::new_test(4001));
let rooms = Arc::new(Mutex::new(HashSet::new()));
let ctx = make_ctx("", dir.path(), &agents, &rooms);
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Bot".to_string());
let ctx = make_ctx("", dir.path(), &services);
let result = handle_setup(&ctx).unwrap();
assert!(result.contains("Setup wizard"));
}
@@ -241,9 +231,9 @@ mod tests {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
WizardState::init_if_missing(dir.path());
let agents = Arc::new(crate::agents::AgentPool::new_test(4002));
let rooms = Arc::new(Mutex::new(HashSet::new()));
let ctx = make_ctx("skip", dir.path(), &agents, &rooms);
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Bot".to_string());
let ctx = make_ctx("skip", dir.path(), &services);
let result = handle_setup(&ctx).unwrap();
assert!(result.contains("skipped"));
let state = WizardState::load(dir.path()).unwrap();
@@ -255,9 +245,9 @@ mod tests {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
WizardState::init_if_missing(dir.path());
let agents = Arc::new(crate::agents::AgentPool::new_test(4003));
let rooms = Arc::new(Mutex::new(HashSet::new()));
let ctx = make_ctx("confirm", dir.path(), &agents, &rooms);
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Bot".to_string());
let ctx = make_ctx("confirm", dir.path(), &services);
let result = handle_setup(&ctx).unwrap();
assert!(result.contains("confirmed"));
let state = WizardState::load(dir.path()).unwrap();
@@ -279,9 +269,9 @@ mod tests {
);
state.save(dir.path()).unwrap();
}
let agents = Arc::new(crate::agents::AgentPool::new_test(4004));
let rooms = Arc::new(Mutex::new(HashSet::new()));
let ctx = make_ctx("retry", dir.path(), &agents, &rooms);
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Bot".to_string());
let ctx = make_ctx("retry", dir.path(), &services);
let result = handle_setup(&ctx).unwrap();
assert!(result.contains("reset"));
let state = WizardState::load(dir.path()).unwrap();
@@ -294,9 +284,9 @@ mod tests {
#[test]
fn setup_unknown_sub_command_returns_usage() {
let dir = TempDir::new().unwrap();
let agents = Arc::new(crate::agents::AgentPool::new_test(4005));
let rooms = Arc::new(Mutex::new(HashSet::new()));
let ctx = make_ctx("foobar", dir.path(), &agents, &rooms);
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Bot".to_string());
let ctx = make_ctx("foobar", dir.path(), &services);
let result = handle_setup(&ctx).unwrap();
assert!(result.contains("Unknown sub-command"));
assert!(result.contains("Usage"));
@@ -307,9 +297,9 @@ mod tests {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
WizardState::init_if_missing(dir.path());
let agents = Arc::new(crate::agents::AgentPool::new_test(4006));
let rooms = Arc::new(Mutex::new(HashSet::new()));
let ctx = make_ctx("generate", dir.path(), &agents, &rooms);
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Bot".to_string());
let ctx = make_ctx("generate", dir.path(), &services);
let result = handle_setup(&ctx).unwrap();
assert!(result.contains("generating"));
let state = WizardState::load(dir.path()).unwrap();
@@ -325,9 +315,9 @@ mod tests {
// Bare project — only scaffolding files
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
WizardState::init_if_missing(dir.path());
let agents = Arc::new(crate::agents::AgentPool::new_test(4007));
let rooms = Arc::new(Mutex::new(HashSet::new()));
let ctx = make_ctx("generate", dir.path(), &agents, &rooms);
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Bot".to_string());
let ctx = make_ctx("generate", dir.path(), &services);
let result = handle_setup(&ctx).unwrap();
assert!(result.contains("bare project"));
assert!(result.contains("Ask the user"));
@@ -336,9 +326,9 @@ mod tests {
#[test]
fn setup_generate_no_wizard_returns_error() {
let dir = TempDir::new().unwrap();
let agents = Arc::new(crate::agents::AgentPool::new_test(4008));
let rooms = Arc::new(Mutex::new(HashSet::new()));
let ctx = make_ctx("generate", dir.path(), &agents, &rooms);
let services =
crate::services::Services::new_test(dir.path().to_path_buf(), "Bot".to_string());
let ctx = make_ctx("generate", dir.path(), &services);
let result = handle_setup(&ctx).unwrap();
assert!(result.contains("No wizard active"));
}
+6 -13
View File
@@ -70,19 +70,19 @@ pub(super) fn handle_show(ctx: &CommandContext) -> Option<String> {
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
ctx.services.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
ctx.services.bot_name
));
}
// Find the story by numeric prefix: CRDT → content store.
let (story_id, _stage_dir, _path, content) =
match crate::chat::lookup::find_story_by_number(ctx.project_root, num_str) {
match crate::chat::lookup::find_story_by_number(ctx.effective_root(), num_str) {
Some(found) => found,
None => {
return Some(format!(
@@ -129,22 +129,15 @@ pub(super) fn handle_show(ctx: &CommandContext) -> Option<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 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 services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, &format!("@timmy show {args}"))
+4 -1
View File
@@ -9,7 +9,10 @@ use super::CommandContext;
pub(super) fn handle_status(ctx: &CommandContext) -> Option<String> {
if ctx.args.trim().is_empty() {
Some(build_pipeline_status(ctx.project_root, ctx.agents))
Some(build_pipeline_status(
ctx.effective_root(),
&ctx.services.agents,
))
} else {
super::triage::handle_triage(ctx)
}
+6 -12
View File
@@ -21,13 +21,13 @@ pub(super) fn handle_triage(ctx: &CommandContext) -> Option<String> {
if num_str.is_empty() {
return Some(format!(
"Usage: `{} status <number>`\n\nShows pipeline info for a story: stage, ACs, git diff, recent commits.",
ctx.bot_name
ctx.services.bot_name
));
}
if !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Invalid story number: `{num_str}`. Usage: `{} status <number>`",
ctx.bot_name
ctx.services.bot_name
));
}
@@ -139,7 +139,7 @@ fn build_triage_dump(
}
// ---- Worktree and branch ----
let wt_path = crate::worktree::worktree_path(ctx.project_root, story_id);
let wt_path = crate::worktree::worktree_path(ctx.effective_root(), story_id);
let branch = format!("feature/story-{story_id}");
if wt_path.is_dir() {
out.push_str(&format!("**Worktree:** `{}`\n", wt_path.display()));
@@ -216,22 +216,16 @@ fn run_git(dir: &Path, args: &[&str]) -> String {
#[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 status_triage_cmd(root: &Path, args: &str) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, &format!("@timmy status {args}"))
+5 -12
View File
@@ -21,11 +21,11 @@ pub(super) fn handle_unblock(ctx: &CommandContext) -> Option<String> {
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Usage: `{} unblock <number>` (e.g. `unblock 42`)",
ctx.bot_name
ctx.services.bot_name
));
}
Some(unblock_by_number(ctx.project_root, num_str))
Some(unblock_by_number(ctx.effective_root(), num_str))
}
/// Core unblock logic: find story by numeric prefix and reset its blocked state.
@@ -102,22 +102,15 @@ fn unblock_by_story_id(story_id: &str) -> 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 unblock_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 services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, &format!("@timmy unblock {args}"))
+4 -10
View File
@@ -10,7 +10,7 @@ use super::CommandContext;
/// that tag and HEAD on master. Each entry shows the story number and name.
/// Returns a clear message when there are no unreleased stories or no tags.
pub(super) fn handle_unreleased(ctx: &CommandContext) -> Option<String> {
let root = ctx.project_root;
let root = ctx.effective_root();
let tag = find_last_release_tag(root);
let commits = list_merge_commits_since(root, tag.as_deref());
@@ -201,22 +201,16 @@ fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
#[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 unreleased_cmd_with_root(root: &std::path::Path) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000));
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name: "Timmy",
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, "@timmy unreleased")
+10 -22
View File
@@ -67,11 +67,9 @@ pub(super) async fn handle_incoming_message(
}
let dispatch = CommandDispatch {
bot_name: &ctx.services.bot_name,
bot_user_id: &ctx.services.bot_user_id,
services: &ctx.services,
project_root: &ctx.services.project_root,
agents: &ctx.services.agents,
ambient_rooms: &ctx.services.ambient_rooms,
bot_user_id: &ctx.services.bot_user_id,
room_id: channel,
};
@@ -483,35 +481,25 @@ async fn handle_llm_message(ctx: &DiscordContext, channel: &str, user: &str, use
#[cfg(test)]
mod tests {
use super::*;
use std::collections::{HashMap, HashSet};
use std::sync::Mutex;
use std::collections::HashMap;
use tokio::sync::Mutex as TokioMutex;
fn test_agents() -> Arc<crate::agents::AgentPool> {
Arc::new(crate::agents::AgentPool::new_test(3000))
}
fn test_ambient_rooms() -> Arc<Mutex<HashSet<String>>> {
Arc::new(Mutex::new(HashSet::new()))
}
#[test]
fn command_dispatches_through_command_registry() {
use crate::chat::commands::{CommandDispatch, try_handle_command};
let agents = test_agents();
let ambient_rooms = test_ambient_rooms();
let services = crate::services::Services::new_test(
std::path::PathBuf::from("/tmp"),
"Huskies".to_string(),
);
let room_id = "123456789".to_string();
let bot_name = "Huskies";
let synthetic = format!("{bot_name} status");
let synthetic = "Huskies status".to_string();
let dispatch = CommandDispatch {
bot_name,
services: &services,
project_root: &services.project_root,
bot_user_id: "discord-bot",
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
@@ -262,11 +262,9 @@ pub(super) async fn on_room_message(
// the LLM. All commands are registered in commands.rs — no special-casing
// needed here.
let dispatch = super::super::commands::CommandDispatch {
bot_name: &ctx.services.bot_name,
bot_user_id: ctx.matrix_user_id.as_str(),
services: &ctx.services,
project_root: &effective_root,
agents: &ctx.services.agents,
ambient_rooms: &ctx.services.ambient_rooms,
bot_user_id: ctx.matrix_user_id.as_str(),
room_id: &room_id_str,
};
if let Some((response, response_html)) =
+16 -29
View File
@@ -108,11 +108,9 @@ pub(super) async fn handle_incoming_message(
}
let dispatch = CommandDispatch {
bot_name: &ctx.services.bot_name,
bot_user_id: &ctx.services.bot_user_id,
services: &ctx.services,
project_root: &ctx.services.project_root,
agents: &ctx.services.agents,
ambient_rooms: &ctx.services.ambient_rooms,
bot_user_id: &ctx.services.bot_user_id,
room_id: channel,
};
@@ -533,7 +531,6 @@ async fn handle_llm_message(
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
// ── Slash command types ────────────────────────────────────────────
@@ -611,35 +608,26 @@ mod tests {
// ── Slash command shares handlers with mention-based commands ──────
fn test_agents() -> Arc<crate::agents::AgentPool> {
Arc::new(crate::agents::AgentPool::new_test(3000))
}
fn test_ambient_rooms() -> Arc<Mutex<HashSet<String>>> {
Arc::new(Mutex::new(HashSet::new()))
}
#[test]
fn slash_command_dispatches_through_command_registry() {
// Verify that the synthetic message built by the slash handler
// correctly dispatches through try_handle_command.
use crate::chat::commands::{CommandDispatch, try_handle_command};
let agents = test_agents();
let ambient_rooms = test_ambient_rooms();
let services = crate::services::Services::new_test(
std::path::PathBuf::from("/tmp"),
"Huskies".to_string(),
);
let room_id = "C01ABCDEF".to_string();
// Simulate what slash_command_receive does: build a synthetic message.
let bot_name = "Huskies";
let keyword = slash_command_to_bot_keyword("/huskies-status").unwrap();
let synthetic = format!("{bot_name} {keyword}");
let synthetic = format!("Huskies {keyword}");
let dispatch = CommandDispatch {
bot_name,
services: &services,
project_root: &services.project_root,
bot_user_id: "slack-bot",
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
@@ -655,21 +643,20 @@ mod tests {
fn slash_command_show_passes_args_through_registry() {
use crate::chat::commands::{CommandDispatch, try_handle_command};
let agents = test_agents();
let ambient_rooms = test_ambient_rooms();
let services = crate::services::Services::new_test(
std::path::PathBuf::from("/tmp"),
"Huskies".to_string(),
);
let room_id = "C01ABCDEF".to_string();
let bot_name = "Huskies";
let keyword = slash_command_to_bot_keyword("/huskies-show").unwrap();
// Simulate /huskies-show with text "999"
let synthetic = format!("{bot_name} {keyword} 999");
let synthetic = format!("Huskies {keyword} 999");
let dispatch = CommandDispatch {
bot_name,
services: &services,
project_root: &services.project_root,
bot_user_id: "slack-bot",
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id,
};
+2 -4
View File
@@ -209,11 +209,9 @@ pub async fn slash_command_receive(
use crate::chat::commands::{CommandDispatch, try_handle_command};
let dispatch = CommandDispatch {
bot_name: &ctx.services.bot_name,
bot_user_id: &ctx.services.bot_user_id,
services: &ctx.services,
project_root: &ctx.services.project_root,
agents: &ctx.services.agents,
ambient_rooms: &ctx.services.ambient_rooms,
bot_user_id: &ctx.services.bot_user_id,
room_id: &payload.channel_id,
};
@@ -49,11 +49,9 @@ pub(super) async fn handle_incoming_message(
}
let dispatch = CommandDispatch {
bot_name: &ctx.services.bot_name,
bot_user_id: &ctx.services.bot_user_id,
services: &ctx.services,
project_root: &ctx.services.project_root,
agents: &ctx.services.agents,
ambient_rooms: &ctx.services.ambient_rooms,
bot_user_id: &ctx.services.bot_user_id,
room_id: sender,
};