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()), _ => return Some("Usage: `ambient on` or `ambient off`".to_string()),
}; };
let room_ids: Vec<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 { if enable {
ambient.insert(ctx.room_id.to_string()); ambient.insert(ctx.room_id.to_string());
} else { } else {
@@ -24,7 +24,7 @@ pub(super) fn handle_ambient(ctx: &CommandContext) -> Option<String> {
} }
ambient.iter().cloned().collect() ambient.iter().cloned().collect()
}; };
save_ambient_rooms(ctx.project_root, &room_ids); save_ambient_rooms(ctx.effective_root(), &room_ids);
let msg = if enable { let msg = if enable {
"Ambient mode on. I'll respond to all messages in this room." "Ambient mode on. I'll respond to all messages in this room."
} else { } else {
@@ -35,35 +35,23 @@ pub(super) fn handle_ambient(ctx: &CommandContext) -> Option<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command}; 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 // Bug 352: ambient commands were being forwarded to LLM after refactors
// 328/330 because handle_ambient required is_addressed=true, but // 328/330 because handle_ambient required is_addressed=true, but
// mentions_bot() only matches @-prefixed mentions, not bare bot names. // mentions_bot() only matches @-prefixed mentions, not bare bot names.
// "timmy ambient off" sets is_addressed=false even though it names the bot. // "timmy ambient off" sets is_addressed=false even though it names the bot.
#[test] #[test]
fn ambient_on_works_when_unaddressed() { 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 room_id = "!myroom:example.com".to_string();
let agents = test_agents();
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, room_id: &room_id,
}; };
// "timmy ambient on" — bot name mentioned but not @-prefixed, so // "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" "ambient on should fire even when is_addressed=false"
); );
assert!( 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" "room should be in ambient_rooms after ambient on"
); );
} }
#[test] #[test]
fn ambient_off_works_bare_in_ambient_room() { 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(); let room_id = "!myroom:example.com".to_string();
ambient_rooms.lock().unwrap().insert(room_id.clone()); services
let agents = test_agents(); .ambient_rooms
.lock()
.unwrap()
.insert(room_id.clone());
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, room_id: &room_id,
}; };
// Bare "ambient off" in an ambient room (is_addressed=false). // Bare "ambient off" in an ambient room (is_addressed=false).
@@ -105,22 +97,22 @@ mod tests {
"response should confirm ambient off: {output}" "response should confirm ambient off: {output}"
); );
assert!( 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" "room should be removed from ambient_rooms after ambient off"
); );
} }
#[test] #[test]
fn ambient_on_enables_ambient_mode() { fn ambient_on_enables_ambient_mode() {
let ambient_rooms = test_ambient_rooms(); let services = crate::services::Services::new_test(
let agents = test_agents(); std::path::PathBuf::from("/tmp"),
"Timmy".to_string(),
);
let room_id = "!myroom:example.com".to_string(); let room_id = "!myroom:example.com".to_string();
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, room_id: &room_id,
}; };
let result = try_handle_command(&dispatch, "@timmy ambient on"); let result = try_handle_command(&dispatch, "@timmy ambient on");
@@ -131,25 +123,29 @@ mod tests {
"response should confirm ambient on: {output}" "response should confirm ambient on: {output}"
); );
assert!( 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" "room should be in ambient_rooms after ambient on"
); );
} }
#[test] #[test]
fn ambient_off_disables_ambient_mode() { fn ambient_off_disables_ambient_mode() {
let ambient_rooms = test_ambient_rooms(); let services = crate::services::Services::new_test(
let agents = test_agents(); std::path::PathBuf::from("/tmp"),
"Timmy".to_string(),
);
let room_id = "!myroom:example.com".to_string(); let room_id = "!myroom:example.com".to_string();
// Pre-insert the room // Pre-insert the room
ambient_rooms.lock().unwrap().insert(room_id.clone()); services
.ambient_rooms
.lock()
.unwrap()
.insert(room_id.clone());
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, room_id: &room_id,
}; };
let result = try_handle_command(&dispatch, "@timmy ambient off"); let result = try_handle_command(&dispatch, "@timmy ambient off");
@@ -160,7 +156,7 @@ mod tests {
"response should confirm ambient off: {output}" "response should confirm ambient off: {output}"
); );
assert!( 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" "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 /// Show token spend: 24h total, top 5 stories, agent-type breakdown, and
/// all-time total. /// all-time total.
pub(super) fn handle_cost(ctx: &CommandContext) -> Option<String> { 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, Ok(r) => r,
Err(e) => return Some(format!("Failed to read token usage: {e}")), 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::agents::AgentPool;
use std::sync::Arc;
fn write_token_records( fn write_token_records(
root: &std::path::Path, root: &std::path::Path,
@@ -139,18 +137,13 @@ mod tests {
fn cost_cmd_with_root(root: &std::path::Path) -> Option<String> { fn cost_cmd_with_root(root: &std::path::Path) -> Option<String> {
use super::super::{CommandDispatch, try_handle_command}; use super::super::{CommandDispatch, try_handle_command};
use std::collections::HashSet;
use std::sync::Mutex;
let agents = Arc::new(AgentPool::new_test(3000)); let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let room_id = "!test:example.com".to_string(); let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, room_id: &room_id,
}; };
try_handle_command(&dispatch, "@timmy cost") 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(); let args = ctx.args.trim();
match args { match args {
"run" => Some(run_coverage(ctx.project_root)), "run" => Some(run_coverage(ctx.effective_root())),
"" => Some(read_cached_coverage(ctx.project_root)), "" => Some(read_cached_coverage(ctx.effective_root())),
other => Some(format!( other => Some(format!(
"Usage: `coverage` (cached) or `coverage run` (fresh)\n\nUnknown argument: `{other}`" "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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
fn make_ctx<'a>( fn make_ctx<'a>(
agents: &'a Arc<AgentPool>, services: &'a crate::services::Services,
ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
project_root: &'a std::path::Path, project_root: &'a std::path::Path,
args: &'a str, args: &'a str,
) -> super::super::CommandContext<'a> { ) -> super::super::CommandContext<'a> {
super::super::CommandContext { super::super::CommandContext::new_test(services, args, "!test:example.com", project_root)
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()))
} }
fn sample_coverage_report(overall: f64, threshold: f64, files: Vec<(&str, f64)>) -> String { 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(); std::fs::write(dir.path().join(".coverage_report.json"), &report).unwrap();
let agents = test_agents(); let services =
let ambient = test_ambient(); crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&agents, &ambient, dir.path(), ""); let ctx = make_ctx(&services, dir.path(), "");
let output = handle_coverage(&ctx).unwrap(); let output = handle_coverage(&ctx).unwrap();
assert!(output.contains("72.5"), "should include overall: {output}"); 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); let report = sample_coverage_report(40.0, 30.0, files);
std::fs::write(dir.path().join(".coverage_report.json"), &report).unwrap(); std::fs::write(dir.path().join(".coverage_report.json"), &report).unwrap();
let agents = test_agents(); let services =
let ambient = test_ambient(); crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&agents, &ambient, dir.path(), ""); let ctx = make_ctx(&services, dir.path(), "");
let output = handle_coverage(&ctx).unwrap(); let output = handle_coverage(&ctx).unwrap();
assert!(output.contains("a.rs"), "should show lowest file: {output}"); assert!(output.contains("a.rs"), "should show lowest file: {output}");
@@ -396,9 +377,9 @@ mod tests {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join(".coverage_baseline"), "72.5\n").unwrap(); std::fs::write(dir.path().join(".coverage_baseline"), "72.5\n").unwrap();
let agents = test_agents(); let services =
let ambient = test_ambient(); crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&agents, &ambient, dir.path(), ""); let ctx = make_ctx(&services, dir.path(), "");
let output = handle_coverage(&ctx).unwrap(); let output = handle_coverage(&ctx).unwrap();
assert!( assert!(
@@ -416,9 +397,9 @@ mod tests {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join(".coverage_baseline"), "60.00\n65.21\n").unwrap(); std::fs::write(dir.path().join(".coverage_baseline"), "60.00\n65.21\n").unwrap();
let agents = test_agents(); let services =
let ambient = test_ambient(); crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&agents, &ambient, dir.path(), ""); let ctx = make_ctx(&services, dir.path(), "");
let output = handle_coverage(&ctx).unwrap(); let output = handle_coverage(&ctx).unwrap();
assert!( assert!(
@@ -435,9 +416,9 @@ mod tests {
fn coverage_missing_both_files_reports_clearly() { fn coverage_missing_both_files_reports_clearly() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let agents = test_agents(); let services =
let ambient = test_ambient(); crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&agents, &ambient, dir.path(), ""); let ctx = make_ctx(&services, dir.path(), "");
let output = handle_coverage(&ctx).unwrap(); let output = handle_coverage(&ctx).unwrap();
assert!( assert!(
@@ -450,9 +431,9 @@ mod tests {
fn coverage_run_missing_script_reports_error() { fn coverage_run_missing_script_reports_error() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let agents = test_agents(); let services =
let ambient = test_ambient(); crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&agents, &ambient, dir.path(), "run"); let ctx = make_ctx(&services, dir.path(), "run");
let output = handle_coverage(&ctx).unwrap(); let output = handle_coverage(&ctx).unwrap();
assert!( assert!(
@@ -464,9 +445,9 @@ mod tests {
#[test] #[test]
fn coverage_unknown_arg_returns_usage() { fn coverage_unknown_arg_returns_usage() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let agents = test_agents(); let services =
let ambient = test_ambient(); crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&agents, &ambient, dir.path(), "blah"); let ctx = make_ctx(&services, dir.path(), "blah");
let output = handle_coverage(&ctx).unwrap(); let output = handle_coverage(&ctx).unwrap();
assert!( assert!(
output.contains("Usage"), output.contains("Usage"),
@@ -480,15 +461,13 @@ mod tests {
let report = sample_coverage_report(55.0, 50.0, vec![]); let report = sample_coverage_report(55.0, 50.0, vec![]);
std::fs::write(dir.path().join(".coverage_report.json"), &report).unwrap(); std::fs::write(dir.path().join(".coverage_report.json"), &report).unwrap();
let agents = test_agents(); let services =
let ambient = test_ambient(); crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string(); let room_id = "!test:example.com".to_string();
let dispatch = super::super::CommandDispatch { let dispatch = super::super::CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: dir.path(),
agents: &agents,
ambient_rooms: &ambient,
room_id: &room_id, room_id: &room_id,
}; };
let result = super::super::try_handle_command(&dispatch, "@timmy coverage"); 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\ "Usage: `{} depends <number> [dep1 dep2 ...]`\n\nExamples:\n\
• `{0} depends 484 477 478` — set depends_on: [477, 478]\n\ • `{0} depends 484 477 478` — set depends_on: [477, 478]\n\
• `{0} depends 484` — clear all dependencies", • `{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() { if !num_str.chars().all(|c| c.is_ascii_digit()) || num_str.is_empty() {
return Some(format!( return Some(format!(
"Invalid story number: `{num_str}`. Usage: `{} depends <number> [dep1 dep2 ...]`", "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. // Find the story by numeric prefix: CRDT → content store → filesystem.
let (story_id, stage_dir, path, content) = 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, Some(found) => found,
None => { None => {
return Some(format!( return Some(format!(
@@ -115,22 +115,15 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command}; use super::super::{CommandDispatch, try_handle_command};
fn depends_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> { fn depends_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000)); let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let room_id = "!test:example.com".to_string(); let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, room_id: &room_id,
}; };
try_handle_command(&dispatch, &format!("@timmy depends {args}")) 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() { if num_str.is_empty() {
return Some(format!( return Some(format!(
"Usage: `{} diff <number>`\n\nShows the git diff from the main branch to the story's worktree HEAD.", "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()) { if !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!( return Some(format!(
"Invalid story number: `{num_str}`. Usage: `{} diff <number>`", "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() { if !wt_path.is_dir() {
return Some(format!( return Some(format!(
"Story **{num_str}** has no worktree. The diff is only available once a coder has started working on it." "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 range = format!("{base_branch}...HEAD");
let stat = run_git(&wt_path, &["diff", "--stat", &range]); 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command}; use super::super::{CommandDispatch, try_handle_command};
fn diff_cmd(root: &std::path::Path, args: &str) -> Option<String> { fn diff_cmd(root: &std::path::Path, args: &str) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000)); let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let room_id = "!test:example.com".to_string(); let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, room_id: &room_id,
}; };
try_handle_command(&dispatch, &format!("@timmy diff {args}")) 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()) { if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!( return Some(format!(
"Usage: `{} freeze <number>` (e.g. `freeze 42`)", "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`. /// 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()) { if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!( return Some(format!(
"Usage: `{} unfreeze <number>` (e.g. `unfreeze 42`)", "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. /// 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)] #[cfg(test)]
mod tests { mod tests {
use crate::agents::AgentPool;
use crate::chat::test_helpers::write_story_file; use crate::chat::test_helpers::write_story_file;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command}; use super::super::{CommandDispatch, try_handle_command};
fn freeze_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> { fn freeze_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000)); let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let room_id = "!test:example.com".to_string(); let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, room_id: &room_id,
}; };
try_handle_command(&dispatch, &format!("@timmy freeze {args}")) try_handle_command(&dispatch, &format!("@timmy freeze {args}"))
} }
fn unfreeze_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> { fn unfreeze_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000)); let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let room_id = "!test:example.com".to_string(); let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, room_id: &room_id,
}; };
try_handle_command(&dispatch, &format!("@timmy unfreeze {args}")) 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 // Current branch
let branch = Command::new("git") let branch = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"]) .args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(ctx.project_root) .current_dir(ctx.effective_root())
.output() .output()
.ok() .ok()
.filter(|o| o.status.success()) .filter(|o| o.status.success())
@@ -19,7 +19,7 @@ pub(super) fn handle_git(ctx: &CommandContext) -> Option<String> {
// Porcelain status for staged + unstaged changes // Porcelain status for staged + unstaged changes
let status_output = Command::new("git") let status_output = Command::new("git")
.args(["status", "--porcelain"]) .args(["status", "--porcelain"])
.current_dir(ctx.project_root) .current_dir(ctx.effective_root())
.output() .output()
.ok() .ok()
.filter(|o| o.status.success()) .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) // Ahead/behind: --left-right gives "N\tM" (ahead\tbehind)
let ahead_behind = Command::new("git") let ahead_behind = Command::new("git")
.args(["rev-list", "--count", "--left-right", "HEAD...@{u}"]) .args(["rev-list", "--count", "--left-right", "HEAD...@{u}"])
.current_dir(ctx.project_root) .current_dir(ctx.effective_root())
.output() .output()
.ok() .ok()
.filter(|o| o.status.success()) .filter(|o| o.status.success())
@@ -77,20 +77,8 @@ pub(super) fn handle_git(ctx: &CommandContext) -> Option<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command}; 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] #[test]
fn git_command_is_registered() { fn git_command_is_registered() {
use super::super::commands; use super::super::commands;
@@ -118,15 +106,13 @@ mod tests {
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent() .parent()
.unwrap_or(std::path::Path::new(".")); .unwrap_or(std::path::Path::new("."));
let agents = test_agents(); let services =
let ambient_rooms = test_ambient_rooms(); crate::services::Services::new_test(repo_root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string(); let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: repo_root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, room_id: &room_id,
}; };
let result = try_handle_command(&dispatch, "@timmy git"); 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")) let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent() .parent()
.unwrap_or(std::path::Path::new(".")); .unwrap_or(std::path::Path::new("."));
let agents = test_agents(); let services =
let ambient_rooms = test_ambient_rooms(); crate::services::Services::new_test(repo_root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string(); let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: repo_root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, room_id: &room_id,
}; };
let output = try_handle_command(&dispatch, "@timmy git").unwrap(); 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")) let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent() .parent()
.unwrap_or(std::path::Path::new(".")); .unwrap_or(std::path::Path::new("."));
let agents = test_agents(); let services =
let ambient_rooms = test_ambient_rooms(); crate::services::Services::new_test(repo_root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string(); let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: repo_root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, room_id: &room_id,
}; };
let output = try_handle_command(&dispatch, "@timmy git").unwrap(); 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")) let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent() .parent()
.unwrap_or(std::path::Path::new(".")); .unwrap_or(std::path::Path::new("."));
let agents = test_agents(); let services =
let ambient_rooms = test_ambient_rooms(); crate::services::Services::new_test(repo_root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string(); let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: repo_root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, room_id: &room_id,
}; };
let output = try_handle_command(&dispatch, "@timmy git").unwrap(); let output = try_handle_command(&dispatch, "@timmy git").unwrap();
+1 -1
View File
@@ -3,7 +3,7 @@
use super::{CommandContext, commands}; use super::{CommandContext, commands};
pub(super) fn handle_help(ctx: &CommandContext) -> Option<String> { 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(); let mut sorted: Vec<_> = commands().iter().collect();
sorted.sort_by_key(|c| c.name); sorted.sort_by_key(|c| c.name);
for cmd in sorted { 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(); let args = ctx.args.trim();
if args.is_empty() { 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(""); 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!( 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" "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), Ok(n) => loc_top_n(ctx.effective_root(), n),
Err(_) => loc_single_file(ctx.project_root, args), Err(_) => loc_single_file(ctx.effective_root(), args),
}) })
} }
@@ -209,24 +209,13 @@ fn is_source_extension(ext: &str) -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
fn make_ctx<'a>( fn make_ctx<'a>(
agents: &'a Arc<AgentPool>, services: &'a crate::services::Services,
ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
project_root: &'a std::path::Path, project_root: &'a std::path::Path,
args: &'a str, args: &'a str,
) -> super::super::CommandContext<'a> { ) -> super::super::CommandContext<'a> {
super::super::CommandContext { super::super::CommandContext::new_test(services, args, "!test:example.com", project_root)
bot_name: "Timmy",
args,
project_root,
agents,
ambient_rooms,
room_id: "!test:example.com",
}
} }
#[test] #[test]
@@ -252,12 +241,12 @@ mod tests {
#[test] #[test]
fn loc_default_returns_top_10() { 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")) let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent() .parent()
.unwrap_or(std::path::Path::new(".")); .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(); let output = handle_loc(&ctx).unwrap();
assert!( assert!(
output.contains("Top"), output.contains("Top"),
@@ -273,12 +262,12 @@ mod tests {
#[test] #[test]
fn loc_with_arg_5_returns_at_most_5() { 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")) let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent() .parent()
.unwrap_or(std::path::Path::new(".")); .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 output = handle_loc(&ctx).unwrap();
let count = output.lines().filter(|l| l.contains(". `")).count(); let count = output.lines().filter(|l| l.contains(". `")).count();
assert!( assert!(
@@ -289,12 +278,12 @@ mod tests {
#[test] #[test]
fn loc_with_arg_20_returns_at_most_20() { 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")) let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent() .parent()
.unwrap_or(std::path::Path::new(".")); .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 output = handle_loc(&ctx).unwrap();
let count = output.lines().filter(|l| l.contains(". `")).count(); let count = output.lines().filter(|l| l.contains(". `")).count();
assert!( assert!(
@@ -305,12 +294,12 @@ mod tests {
#[test] #[test]
fn loc_output_contains_rank_and_line_count() { 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")) let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent() .parent()
.unwrap_or(std::path::Path::new(".")); .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(); let output = handle_loc(&ctx).unwrap();
// Each entry should have "N. `path` — N lines" // Each entry should have "N. `path` — N lines"
assert!( assert!(
@@ -325,12 +314,12 @@ mod tests {
#[test] #[test]
fn loc_zero_arg_returns_usage() { 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")) let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent() .parent()
.unwrap_or(std::path::Path::new(".")); .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(); let output = handle_loc(&ctx).unwrap();
assert!( assert!(
output.contains("Usage"), output.contains("Usage"),
@@ -349,9 +338,9 @@ mod tests {
writeln!(f, "fn line_{i}() {{}}").unwrap(); writeln!(f, "fn line_{i}() {{}}").unwrap();
} }
} }
let agents = Arc::new(AgentPool::new_test(3000)); let services =
let ambient_rooms = Arc::new(Mutex::new(HashSet::new())); crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&agents, &ambient_rooms, dir.path(), "hello.rs"); let ctx = make_ctx(&services, dir.path(), "hello.rs");
let output = handle_loc(&ctx).unwrap(); let output = handle_loc(&ctx).unwrap();
assert!( assert!(
output.contains("42"), output.contains("42"),
@@ -366,9 +355,9 @@ mod tests {
#[test] #[test]
fn loc_filepath_nonexistent_returns_error() { fn loc_filepath_nonexistent_returns_error() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let agents = Arc::new(AgentPool::new_test(3000)); let services =
let ambient_rooms = Arc::new(Mutex::new(HashSet::new())); crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&agents, &ambient_rooms, dir.path(), "does_not_exist.rs"); let ctx = make_ctx(&services, dir.path(), "does_not_exist.rs");
let output = handle_loc(&ctx).unwrap(); let output = handle_loc(&ctx).unwrap();
assert!( assert!(
output.contains("not found") || output.contains("Error"), output.contains("not found") || output.contains("Error"),
@@ -378,12 +367,12 @@ mod tests {
#[test] #[test]
fn loc_skips_worktrees_directory() { 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")) let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent() .parent()
.unwrap_or(std::path::Path::new(".")); .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(); let output = handle_loc(&ctx).unwrap();
assert!( assert!(
!output.contains(".huskies/worktrees"), !output.contains(".huskies/worktrees"),
@@ -413,9 +402,9 @@ mod tests {
writeln!(f, "fn f{i}() {{}}").unwrap(); writeln!(f, "fn f{i}() {{}}").unwrap();
} }
} }
let agents = Arc::new(AgentPool::new_test(3000)); let services =
let ambient_rooms = Arc::new(Mutex::new(HashSet::new())); crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&agents, &ambient_rooms, dir.path(), "50"); let ctx = make_ctx(&services, dir.path(), "50");
let output = handle_loc(&ctx).unwrap(); let output = handle_loc(&ctx).unwrap();
assert!( assert!(
!output.contains("target/"), !output.contains("target/"),
@@ -447,9 +436,9 @@ mod tests {
writeln!(f, "fn line_{i}() {{}}").unwrap(); writeln!(f, "fn line_{i}() {{}}").unwrap();
} }
} }
let agents = Arc::new(AgentPool::new_test(3000)); let services =
let ambient_rooms = Arc::new(Mutex::new(HashSet::new())); crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&agents, &ambient_rooms, dir.path(), "50"); let ctx = make_ctx(&services, dir.path(), "50");
let output = handle_loc(&ctx).unwrap(); let output = handle_loc(&ctx).unwrap();
assert!( assert!(
!output.contains("package-lock.json"), !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(); std::io::Write::write_all(&mut f, format!("fn f{i}() {{}}\n").as_bytes()).unwrap();
} }
} }
let agents = Arc::new(AgentPool::new_test(3000)); let services =
let ambient_rooms = Arc::new(Mutex::new(HashSet::new())); crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&agents, &ambient_rooms, dir.path(), "50"); let ctx = make_ctx(&services, dir.path(), "50");
let output = handle_loc(&ctx).unwrap(); let output = handle_loc(&ctx).unwrap();
assert!( assert!(
!output.contains("Cargo.lock"), !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() { if num_str.is_empty() {
return Some(format!( return Some(format!(
"Usage: `{} logs <number>`\n\nShows the last agent log lines for a story.", "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()) { if !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!( return Some(format!(
"Invalid story number: `{num_str}`. Usage: `{} logs <number>`", "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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command}; use super::super::{CommandDispatch, try_handle_command};
fn logs_cmd(root: &Path, args: &str) -> Option<String> { fn logs_cmd(root: &Path, args: &str) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000)); let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let room_id = "!test:example.com".to_string(); let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, room_id: &room_id,
}; };
try_handle_command(&dispatch, &format!("@timmy logs {args}")) try_handle_command(&dispatch, &format!("@timmy logs {args}"))
+72 -46
View File
@@ -28,11 +28,9 @@ mod triage;
pub(crate) mod unblock; pub(crate) mod unblock;
mod unreleased; mod unreleased;
use crate::agents::AgentPool;
use crate::chat::util::strip_bot_mention; use crate::chat::util::strip_bot_mention;
use std::collections::HashSet; use crate::services::Services;
use std::path::Path; use std::path::Path;
use std::sync::{Arc, Mutex};
/// A bot-level command that is handled without LLM invocation. /// A bot-level command that is handled without LLM invocation.
pub struct BotCommand { pub struct BotCommand {
@@ -48,41 +46,69 @@ pub struct BotCommand {
/// Dispatch parameters passed to `try_handle_command`. /// Dispatch parameters passed to `try_handle_command`.
/// ///
/// Groups all the caller-supplied context needed to dispatch and execute bot /// Groups the [`Services`] bundle with per-message dispatch context.
/// commands. Construct one per incoming message and pass it alongside the raw /// Construct one per incoming message and pass it alongside the raw message
/// message body. /// body.
/// ///
/// All identifiers are platform-agnostic strings so this struct works with /// All identifiers are platform-agnostic strings so this struct works with
/// any [`ChatTransport`](crate::chat::ChatTransport) implementation. /// any [`ChatTransport`](crate::chat::ChatTransport) implementation.
pub struct CommandDispatch<'a> { pub struct CommandDispatch<'a> {
/// The bot's display name (e.g., "Timmy"). /// Shared services bundle (project root, agent pool, ambient rooms, …).
pub bot_name: &'a str, pub services: &'a Services,
/// The bot's full user ID (e.g., `"@timmy:homeserver.local"` on Matrix). /// Effective project root — usually `services.project_root`, but the Matrix
pub bot_user_id: &'a str, /// transport overrides this in gateway mode to point at the active project.
/// Project root directory (needed by status, ambient).
pub project_root: &'a Path, pub project_root: &'a Path,
/// Agent pool (needed by status). /// Bot user ID for mention-stripping — transport-specific (e.g. Matrix's
pub agents: &'a AgentPool, /// `OwnedUserId` string differs from `services.bot_user_id`).
/// Set of room IDs with ambient mode enabled (needed by ambient). pub bot_user_id: &'a str,
pub ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
/// The room this message came from (needed by ambient). /// The room this message came from (needed by ambient).
pub room_id: &'a str, pub room_id: &'a str,
} }
/// Context passed to individual command handlers. /// 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> { pub struct CommandContext<'a> {
/// The bot's display name (e.g., "Timmy"). /// Shared services bundle.
pub bot_name: &'a str, pub services: &'a Services,
/// Any text after the command keyword, trimmed. /// Any text after the command keyword, trimmed.
pub args: &'a str, 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). /// The room this message came from (needed by ambient).
pub room_id: &'a str, 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. /// Returns the full list of registered bot commands.
@@ -245,7 +271,8 @@ pub fn try_handle_command_with_html(
dispatch: &CommandDispatch<'_>, dispatch: &CommandDispatch<'_>,
message: &str, message: &str,
) -> Option<(String, String)> { ) -> 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(); let trimmed = command_text.trim();
if !trimmed.is_empty() { if !trimmed.is_empty() {
let (cmd_name, args) = match trimmed.split_once(char::is_whitespace) { 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. // Status command: emoji indicators render natively in all clients.
if cmd_name.eq_ignore_ascii_case("status") && args.is_empty() { 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); let html = plain_to_html(&body);
return Some((body, html)); 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` /// Returns `Some(response)` if a command matched and was handled, `None`
/// otherwise (the caller should fall through to the LLM). /// otherwise (the caller should fall through to the LLM).
pub fn try_handle_command(dispatch: &CommandDispatch<'_>, message: &str) -> Option<String> { 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(); let trimmed = command_text.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
return None; 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 cmd_lower = cmd_name.to_ascii_lowercase();
let ctx = CommandContext { let ctx = CommandContext {
bot_name: dispatch.bot_name, services: dispatch.services,
args, args,
project_root: dispatch.project_root,
agents: dispatch.agents,
ambient_rooms: dispatch.ambient_rooms,
room_id: dispatch.room_id, room_id: dispatch.room_id,
project_root: dispatch.project_root,
}; };
commands() commands()
@@ -382,39 +409,38 @@ fn handle_rebuild_fallback(_ctx: &CommandContext) -> Option<String> {
#[cfg(test)] #[cfg(test)]
pub(crate) mod tests { pub(crate) mod tests {
use super::*; use super::*;
use crate::agents::AgentPool; use crate::services::Services;
use std::sync::Arc;
// -- test helpers (shared with submodule tests) ------------------------- // -- test helpers (shared with submodule tests) -------------------------
pub fn test_ambient_rooms() -> Arc<Mutex<HashSet<String>>> { /// Build a [`Services`] bundle for tests with the given bot name and a `/tmp`
Arc::new(Mutex::new(HashSet::new())) /// 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> { /// Dispatch a message through the command registry using the given Services.
Arc::new(AgentPool::new_test(3000)) pub fn try_cmd_with_services(
}
pub fn try_cmd(
bot_name: &str,
bot_user_id: &str, bot_user_id: &str,
message: &str, message: &str,
ambient_rooms: &Arc<Mutex<HashSet<String>>>, services: &Services,
) -> Option<String> { ) -> Option<String> {
let agents = test_agents();
let room_id = "!test:example.com".to_string(); let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name, services,
project_root: &services.project_root,
bot_user_id, bot_user_id,
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms,
room_id: &room_id, room_id: &room_id,
}; };
try_handle_command(&dispatch, message) 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> { 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 // Re-export commands() for submodule tests
+7 -14
View File
@@ -24,7 +24,7 @@ pub(super) fn handle_move(ctx: &CommandContext) -> Option<String> {
None => { None => {
return Some(format!( return Some(format!(
"Usage: `{} move <number> <stage>`\n\nValid stages: {}", "Usage: `{} move <number> <stage>`\n\nValid stages: {}",
ctx.bot_name, ctx.services.bot_name,
VALID_STAGES.join(", ") 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()) { if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!( return Some(format!(
"Invalid story number: `{num_str}`. Usage: `{} move <number> <stage>`", "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. // Find the story by numeric prefix: CRDT → content store → filesystem.
let (story_id, _stage_dir, _path, content) = 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, Some(found) => found,
None => { None => {
return Some(format!( 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); 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!( Ok((from_stage, to_stage)) => Some(format!(
"Moved **{display_name}** from **{from_stage}** to **{to_stage}**." "Moved **{display_name}** from **{from_stage}** to **{to_stage}**."
)), )),
@@ -76,22 +76,15 @@ pub(super) fn handle_move(ctx: &CommandContext) -> Option<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command}; use super::super::{CommandDispatch, try_handle_command};
fn move_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> { fn move_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000)); let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let room_id = "!test:example.com".to_string(); let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, room_id: &room_id,
}; };
try_handle_command(&dispatch, &format!("@timmy move {args}")) 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() { if num_str.is_empty() {
return Some(format!( return Some(format!(
"Usage: `{} overview <number>`\n\nShows the implementation summary for a story.", "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()) { if !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!( return Some(format!(
"Invalid story number: `{num_str}`. Usage: `{} overview <number>`", "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, Some(h) => h,
None => { None => {
return Some(format!( 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 stat_output = get_commit_stat(ctx.effective_root(), &commit_hash);
let symbols = extract_diff_symbols(ctx.project_root, &commit_hash); let symbols = extract_diff_symbols(ctx.effective_root(), &commit_hash);
let story_name = find_story_name(ctx.project_root, num_str); let story_name = find_story_name(ctx.effective_root(), num_str);
let short_hash = &commit_hash[..commit_hash.len().min(8)]; let short_hash = &commit_hash[..commit_hash.len().min(8)];
let mut out = match story_name { let mut out = match story_name {
@@ -195,22 +195,16 @@ fn parse_symbol_definition(code: &str) -> Option<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command}; use super::super::{CommandDispatch, try_handle_command};
fn overview_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> { fn overview_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000)); let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let room_id = "!test:example.com".to_string(); let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, room_id: &room_id,
}; };
try_handle_command(&dispatch, &format!("@timmy overview {args}")) 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> { fn resolve_run_dir(ctx: &CommandContext) -> Result<PathBuf, String> {
let number = ctx.args.trim(); let number = ctx.args.trim();
if number.is_empty() { 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. // 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}_"); let prefix = format!("{number}_");
match std::fs::read_dir(&worktrees_dir) { match std::fs::read_dir(&worktrees_dir) {
Ok(entries) => { Ok(entries) => {
@@ -145,32 +145,13 @@ fn extract_count(line: &str, label: &str) -> Option<u64> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
fn make_ctx<'a>( fn make_ctx<'a>(
agents: &'a Arc<AgentPool>, services: &'a crate::services::Services,
ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
project_root: &'a std::path::Path, project_root: &'a std::path::Path,
args: &'a str, args: &'a str,
) -> super::super::CommandContext<'a> { ) -> super::super::CommandContext<'a> {
super::super::CommandContext { super::super::CommandContext::new_test(services, args, "!test:example.com", project_root)
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()))
} }
fn write_script(dir: &std::path::Path, content: &str) { fn write_script(dir: &std::path::Path, content: &str) {
@@ -209,9 +190,9 @@ mod tests {
#[test] #[test]
fn test_command_missing_script_returns_error() { fn test_command_missing_script_returns_error() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let agents = test_agents(); let services =
let ambient = test_ambient(); crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&agents, &ambient, dir.path(), ""); let ctx = make_ctx(&services, dir.path(), "");
let output = handle_test(&ctx).unwrap(); let output = handle_test(&ctx).unwrap();
assert!( assert!(
output.contains("not found") || output.contains("script"), output.contains("not found") || output.contains("script"),
@@ -226,9 +207,9 @@ mod tests {
dir.path(), dir.path(),
"#!/usr/bin/env bash\necho 'test result: ok. 4 passed; 0 failed'\nexit 0\n", "#!/usr/bin/env bash\necho 'test result: ok. 4 passed; 0 failed'\nexit 0\n",
); );
let agents = test_agents(); let services =
let ambient = test_ambient(); crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&agents, &ambient, dir.path(), ""); let ctx = make_ctx(&services, dir.path(), "");
let output = handle_test(&ctx).unwrap(); let output = handle_test(&ctx).unwrap();
assert!(output.contains("PASS"), "should show PASS: {output}"); assert!(output.contains("PASS"), "should show PASS: {output}");
assert!(output.contains('4'), "should show test count: {output}"); assert!(output.contains('4'), "should show test count: {output}");
@@ -241,9 +222,9 @@ mod tests {
dir.path(), dir.path(),
"#!/usr/bin/env bash\necho 'test result: FAILED. 1 passed; 2 failed'\nexit 1\n", "#!/usr/bin/env bash\necho 'test result: FAILED. 1 passed; 2 failed'\nexit 1\n",
); );
let agents = test_agents(); let services =
let ambient = test_ambient(); crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&agents, &ambient, dir.path(), ""); let ctx = make_ctx(&services, dir.path(), "");
let output = handle_test(&ctx).unwrap(); let output = handle_test(&ctx).unwrap();
assert!(output.contains("FAIL"), "should show FAIL: {output}"); assert!(output.contains("FAIL"), "should show FAIL: {output}");
assert!(output.contains('2'), "should show failed count: {output}"); assert!(output.contains('2'), "should show failed count: {output}");
@@ -253,15 +234,13 @@ mod tests {
fn test_command_works_via_dispatch() { fn test_command_works_via_dispatch() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
write_script(dir.path(), "#!/usr/bin/env bash\necho 'ok'\nexit 0\n"); write_script(dir.path(), "#!/usr/bin/env bash\necho 'ok'\nexit 0\n");
let agents = test_agents(); let services =
let ambient = test_ambient(); crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string(); let room_id = "!test:example.com".to_string();
let dispatch = super::super::CommandDispatch { let dispatch = super::super::CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: dir.path(),
agents: &agents,
ambient_rooms: &ambient,
room_id: &room_id, room_id: &room_id,
}; };
let result = super::super::try_handle_command(&dispatch, "@timmy run_tests"); let result = super::super::try_handle_command(&dispatch, "@timmy run_tests");
@@ -312,9 +291,9 @@ mod tests {
dir.path(), dir.path(),
"#!/usr/bin/env bash\necho 'test result: ok. 7 passed; 0 failed'\nexit 0\n", "#!/usr/bin/env bash\necho 'test result: ok. 7 passed; 0 failed'\nexit 0\n",
); );
let agents = test_agents(); let services =
let ambient = test_ambient(); crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&agents, &ambient, dir.path(), ""); let ctx = make_ctx(&services, dir.path(), "");
let output = handle_test(&ctx).unwrap(); let output = handle_test(&ctx).unwrap();
assert!( assert!(
output.contains("PASS"), output.contains("PASS"),
@@ -330,9 +309,9 @@ mod tests {
fn run_tests_with_story_number_uses_worktree() { fn run_tests_with_story_number_uses_worktree() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
create_worktree(dir.path(), 541, true); create_worktree(dir.path(), 541, true);
let agents = test_agents(); let services =
let ambient = test_ambient(); crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&agents, &ambient, dir.path(), "541"); let ctx = make_ctx(&services, dir.path(), "541");
let output = handle_test(&ctx).unwrap(); let output = handle_test(&ctx).unwrap();
assert!( assert!(
output.contains("PASS"), output.contains("PASS"),
@@ -349,9 +328,9 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
// Create the worktrees dir but no matching entry // Create the worktrees dir but no matching entry
std::fs::create_dir_all(dir.path().join(".huskies/worktrees")).unwrap(); std::fs::create_dir_all(dir.path().join(".huskies/worktrees")).unwrap();
let agents = test_agents(); let services =
let ambient = test_ambient(); crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&agents, &ambient, dir.path(), "999"); let ctx = make_ctx(&services, dir.path(), "999");
let output = handle_test(&ctx).unwrap(); let output = handle_test(&ctx).unwrap();
assert!( assert!(
output.contains("No worktree found") || output.contains("999"), output.contains("No worktree found") || output.contains("999"),
@@ -362,9 +341,9 @@ mod tests {
#[test] #[test]
fn run_tests_with_invalid_arg_returns_error() { fn run_tests_with_invalid_arg_returns_error() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let agents = test_agents(); let services =
let ambient = test_ambient(); crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let ctx = make_ctx(&agents, &ambient, dir.path(), "notanumber"); let ctx = make_ctx(&services, dir.path(), "notanumber");
let output = handle_test(&ctx).unwrap(); let output = handle_test(&ctx).unwrap();
assert!( assert!(
output.contains("Invalid argument") || output.contains("notanumber"), output.contains("Invalid argument") || output.contains("notanumber"),
@@ -376,15 +355,13 @@ mod tests {
fn run_tests_with_story_number_via_dispatch() { fn run_tests_with_story_number_via_dispatch() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
create_worktree(dir.path(), 541, true); create_worktree(dir.path(), 541, true);
let agents = test_agents(); let services =
let ambient = test_ambient(); crate::services::Services::new_test(dir.path().to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string(); let room_id = "!test:example.com".to_string();
let dispatch = super::super::CommandDispatch { let dispatch = super::super::CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: dir.path(),
agents: &agents,
ambient_rooms: &ambient,
room_id: &room_id, room_id: &room_id,
}; };
let result = super::super::try_handle_command(&dispatch, "@timmy run_tests 541"); 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 /// This mirrors `wizard_generate` (with no content) from the MCP tools, making
/// the interview flow accessible from chat transports (Matrix, Slack, WhatsApp). /// the interview flow accessible from chat transports (Matrix, Slack, WhatsApp).
fn wizard_generate_reply(ctx: &CommandContext) -> String { fn wizard_generate_reply(ctx: &CommandContext) -> String {
let root = ctx.project_root; let root = ctx.effective_root();
let mut state = match WizardState::load(root) { let mut state = match WizardState::load(root) {
Some(s) => s, Some(s) => s,
None => return "No wizard active.".to_string(), 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 /// If no wizard state exists, automatically initializes it so the user does
/// not need to run `huskies init` manually. /// not need to run `huskies init` manually.
fn wizard_status_reply(ctx: &CommandContext) -> String { fn wizard_status_reply(ctx: &CommandContext) -> String {
if WizardState::load(ctx.project_root).is_none() { if WizardState::load(ctx.effective_root()).is_none() {
WizardState::init_if_missing(ctx.project_root); 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), Some(state) => format_wizard_state(&state),
None => "Unable to initialize setup wizard. Ensure the `.huskies/` directory exists." None => "Unable to initialize setup wizard. Ensure the `.huskies/` directory exists."
.to_string(), .to_string(),
@@ -75,7 +75,7 @@ fn wizard_status_reply(ctx: &CommandContext) -> String {
/// Confirm the current wizard step, writing any staged content to disk. /// Confirm the current wizard step, writing any staged content to disk.
fn wizard_confirm_reply(ctx: &CommandContext) -> String { fn wizard_confirm_reply(ctx: &CommandContext) -> String {
let root = ctx.project_root; let root = ctx.effective_root();
let mut state = match WizardState::load(root) { let mut state = match WizardState::load(root) {
Some(s) => s, Some(s) => s,
None => return "No wizard active.".to_string(), 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. /// Skip the current wizard step without writing any file.
fn wizard_skip_reply(ctx: &CommandContext) -> String { fn wizard_skip_reply(ctx: &CommandContext) -> String {
let root = ctx.project_root; let root = ctx.effective_root();
let mut state = match WizardState::load(root) { let mut state = match WizardState::load(root) {
Some(s) => s, Some(s) => s,
None => return "No wizard active.".to_string(), 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. /// Discard staged content and reset the current step to pending.
fn wizard_retry_reply(ctx: &CommandContext) -> String { fn wizard_retry_reply(ctx: &CommandContext) -> String {
let root = ctx.project_root; let root = ctx.effective_root();
let mut state = match WizardState::load(root) { let mut state = match WizardState::load(root) {
Some(s) => s, Some(s) => s,
None => return "No wizard active.".to_string(), None => return "No wizard active.".to_string(),
@@ -189,33 +189,23 @@ fn wizard_retry_reply(ctx: &CommandContext) -> String {
mod tests { mod tests {
use super::*; use super::*;
use crate::io::wizard::WizardState; use crate::io::wizard::WizardState;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use tempfile::TempDir; use tempfile::TempDir;
fn make_ctx<'a>( fn make_ctx<'a>(
args: &'a str, args: &'a str,
project_root: &'a std::path::Path, project_root: &'a std::path::Path,
agents: &'a Arc<crate::agents::AgentPool>, services: &'a crate::services::Services,
ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
) -> CommandContext<'a> { ) -> CommandContext<'a> {
CommandContext { CommandContext::new_test(services, args, "!test:example.com", project_root)
bot_name: "Bot",
args,
project_root,
agents,
ambient_rooms,
room_id: "!test:example.com",
}
} }
#[test] #[test]
fn setup_no_wizard_auto_initializes() { fn setup_no_wizard_auto_initializes() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
let agents = Arc::new(crate::agents::AgentPool::new_test(4000)); let services =
let rooms = Arc::new(Mutex::new(HashSet::new())); crate::services::Services::new_test(dir.path().to_path_buf(), "Bot".to_string());
let ctx = make_ctx("", dir.path(), &agents, &rooms); let ctx = make_ctx("", dir.path(), &services);
let result = handle_setup(&ctx).unwrap(); let result = handle_setup(&ctx).unwrap();
// Bot should auto-initialize and return wizard status, not ask user to run huskies init. // Bot should auto-initialize and return wizard status, not ask user to run huskies init.
assert!(result.contains("Setup wizard")); assert!(result.contains("Setup wizard"));
@@ -229,9 +219,9 @@ mod tests {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
WizardState::init_if_missing(dir.path()); WizardState::init_if_missing(dir.path());
let agents = Arc::new(crate::agents::AgentPool::new_test(4001)); let services =
let rooms = Arc::new(Mutex::new(HashSet::new())); crate::services::Services::new_test(dir.path().to_path_buf(), "Bot".to_string());
let ctx = make_ctx("", dir.path(), &agents, &rooms); let ctx = make_ctx("", dir.path(), &services);
let result = handle_setup(&ctx).unwrap(); let result = handle_setup(&ctx).unwrap();
assert!(result.contains("Setup wizard")); assert!(result.contains("Setup wizard"));
} }
@@ -241,9 +231,9 @@ mod tests {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
WizardState::init_if_missing(dir.path()); WizardState::init_if_missing(dir.path());
let agents = Arc::new(crate::agents::AgentPool::new_test(4002)); let services =
let rooms = Arc::new(Mutex::new(HashSet::new())); crate::services::Services::new_test(dir.path().to_path_buf(), "Bot".to_string());
let ctx = make_ctx("skip", dir.path(), &agents, &rooms); let ctx = make_ctx("skip", dir.path(), &services);
let result = handle_setup(&ctx).unwrap(); let result = handle_setup(&ctx).unwrap();
assert!(result.contains("skipped")); assert!(result.contains("skipped"));
let state = WizardState::load(dir.path()).unwrap(); let state = WizardState::load(dir.path()).unwrap();
@@ -255,9 +245,9 @@ mod tests {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
WizardState::init_if_missing(dir.path()); WizardState::init_if_missing(dir.path());
let agents = Arc::new(crate::agents::AgentPool::new_test(4003)); let services =
let rooms = Arc::new(Mutex::new(HashSet::new())); crate::services::Services::new_test(dir.path().to_path_buf(), "Bot".to_string());
let ctx = make_ctx("confirm", dir.path(), &agents, &rooms); let ctx = make_ctx("confirm", dir.path(), &services);
let result = handle_setup(&ctx).unwrap(); let result = handle_setup(&ctx).unwrap();
assert!(result.contains("confirmed")); assert!(result.contains("confirmed"));
let state = WizardState::load(dir.path()).unwrap(); let state = WizardState::load(dir.path()).unwrap();
@@ -279,9 +269,9 @@ mod tests {
); );
state.save(dir.path()).unwrap(); state.save(dir.path()).unwrap();
} }
let agents = Arc::new(crate::agents::AgentPool::new_test(4004)); let services =
let rooms = Arc::new(Mutex::new(HashSet::new())); crate::services::Services::new_test(dir.path().to_path_buf(), "Bot".to_string());
let ctx = make_ctx("retry", dir.path(), &agents, &rooms); let ctx = make_ctx("retry", dir.path(), &services);
let result = handle_setup(&ctx).unwrap(); let result = handle_setup(&ctx).unwrap();
assert!(result.contains("reset")); assert!(result.contains("reset"));
let state = WizardState::load(dir.path()).unwrap(); let state = WizardState::load(dir.path()).unwrap();
@@ -294,9 +284,9 @@ mod tests {
#[test] #[test]
fn setup_unknown_sub_command_returns_usage() { fn setup_unknown_sub_command_returns_usage() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let agents = Arc::new(crate::agents::AgentPool::new_test(4005)); let services =
let rooms = Arc::new(Mutex::new(HashSet::new())); crate::services::Services::new_test(dir.path().to_path_buf(), "Bot".to_string());
let ctx = make_ctx("foobar", dir.path(), &agents, &rooms); let ctx = make_ctx("foobar", dir.path(), &services);
let result = handle_setup(&ctx).unwrap(); let result = handle_setup(&ctx).unwrap();
assert!(result.contains("Unknown sub-command")); assert!(result.contains("Unknown sub-command"));
assert!(result.contains("Usage")); assert!(result.contains("Usage"));
@@ -307,9 +297,9 @@ mod tests {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
WizardState::init_if_missing(dir.path()); WizardState::init_if_missing(dir.path());
let agents = Arc::new(crate::agents::AgentPool::new_test(4006)); let services =
let rooms = Arc::new(Mutex::new(HashSet::new())); crate::services::Services::new_test(dir.path().to_path_buf(), "Bot".to_string());
let ctx = make_ctx("generate", dir.path(), &agents, &rooms); let ctx = make_ctx("generate", dir.path(), &services);
let result = handle_setup(&ctx).unwrap(); let result = handle_setup(&ctx).unwrap();
assert!(result.contains("generating")); assert!(result.contains("generating"));
let state = WizardState::load(dir.path()).unwrap(); let state = WizardState::load(dir.path()).unwrap();
@@ -325,9 +315,9 @@ mod tests {
// Bare project — only scaffolding files // Bare project — only scaffolding files
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
WizardState::init_if_missing(dir.path()); WizardState::init_if_missing(dir.path());
let agents = Arc::new(crate::agents::AgentPool::new_test(4007)); let services =
let rooms = Arc::new(Mutex::new(HashSet::new())); crate::services::Services::new_test(dir.path().to_path_buf(), "Bot".to_string());
let ctx = make_ctx("generate", dir.path(), &agents, &rooms); let ctx = make_ctx("generate", dir.path(), &services);
let result = handle_setup(&ctx).unwrap(); let result = handle_setup(&ctx).unwrap();
assert!(result.contains("bare project")); assert!(result.contains("bare project"));
assert!(result.contains("Ask the user")); assert!(result.contains("Ask the user"));
@@ -336,9 +326,9 @@ mod tests {
#[test] #[test]
fn setup_generate_no_wizard_returns_error() { fn setup_generate_no_wizard_returns_error() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let agents = Arc::new(crate::agents::AgentPool::new_test(4008)); let services =
let rooms = Arc::new(Mutex::new(HashSet::new())); crate::services::Services::new_test(dir.path().to_path_buf(), "Bot".to_string());
let ctx = make_ctx("generate", dir.path(), &agents, &rooms); let ctx = make_ctx("generate", dir.path(), &services);
let result = handle_setup(&ctx).unwrap(); let result = handle_setup(&ctx).unwrap();
assert!(result.contains("No wizard active")); 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() { if num_str.is_empty() {
return Some(format!( return Some(format!(
"Usage: `{} show <number>`\n\nDisplays the full text of a story, bug, or spike.", "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()) { if !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!( return Some(format!(
"Invalid story number: `{num_str}`. Usage: `{} show <number>`", "Invalid story number: `{num_str}`. Usage: `{} show <number>`",
ctx.bot_name ctx.services.bot_name
)); ));
} }
// Find the story by numeric prefix: CRDT → content store. // Find the story by numeric prefix: CRDT → content store.
let (story_id, _stage_dir, _path, content) = 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, Some(found) => found,
None => { None => {
return Some(format!( return Some(format!(
@@ -129,22 +129,15 @@ pub(super) fn handle_show(ctx: &CommandContext) -> Option<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command}; use super::super::{CommandDispatch, try_handle_command};
fn show_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> { fn show_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000)); let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let room_id = "!test:example.com".to_string(); let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, room_id: &room_id,
}; };
try_handle_command(&dispatch, &format!("@timmy show {args}")) 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> { pub(super) fn handle_status(ctx: &CommandContext) -> Option<String> {
if ctx.args.trim().is_empty() { 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 { } else {
super::triage::handle_triage(ctx) 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() { if num_str.is_empty() {
return Some(format!( return Some(format!(
"Usage: `{} status <number>`\n\nShows pipeline info for a story: stage, ACs, git diff, recent commits.", "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()) { if !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!( return Some(format!(
"Invalid story number: `{num_str}`. Usage: `{} status <number>`", "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 ---- // ---- 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}"); let branch = format!("feature/story-{story_id}");
if wt_path.is_dir() { if wt_path.is_dir() {
out.push_str(&format!("**Worktree:** `{}`\n", wt_path.display())); out.push_str(&format!("**Worktree:** `{}`\n", wt_path.display()));
@@ -216,22 +216,16 @@ fn run_git(dir: &Path, args: &[&str]) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command}; use super::super::{CommandDispatch, try_handle_command};
fn status_triage_cmd(root: &Path, args: &str) -> Option<String> { fn status_triage_cmd(root: &Path, args: &str) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000)); let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let room_id = "!test:example.com".to_string(); let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, room_id: &room_id,
}; };
try_handle_command(&dispatch, &format!("@timmy status {args}")) 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()) { if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!( return Some(format!(
"Usage: `{} unblock <number>` (e.g. `unblock 42`)", "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. /// 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)] #[cfg(test)]
mod tests { mod tests {
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command}; use super::super::{CommandDispatch, try_handle_command};
fn unblock_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> { fn unblock_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000)); let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let room_id = "!test:example.com".to_string(); let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, room_id: &room_id,
}; };
try_handle_command(&dispatch, &format!("@timmy unblock {args}")) 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. /// 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. /// Returns a clear message when there are no unreleased stories or no tags.
pub(super) fn handle_unreleased(ctx: &CommandContext) -> Option<String> { 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 tag = find_last_release_tag(root);
let commits = list_merge_commits_since(root, tag.as_deref()); 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::agents::AgentPool;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use super::super::{CommandDispatch, try_handle_command}; use super::super::{CommandDispatch, try_handle_command};
fn unreleased_cmd_with_root(root: &std::path::Path) -> Option<String> { fn unreleased_cmd_with_root(root: &std::path::Path) -> Option<String> {
let agents = Arc::new(AgentPool::new_test(3000)); let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
let room_id = "!test:example.com".to_string(); let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: "Timmy", services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local", bot_user_id: "@timmy:homeserver.local",
project_root: root,
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, room_id: &room_id,
}; };
try_handle_command(&dispatch, "@timmy unreleased") try_handle_command(&dispatch, "@timmy unreleased")
+10 -22
View File
@@ -67,11 +67,9 @@ pub(super) async fn handle_incoming_message(
} }
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: &ctx.services.bot_name, services: &ctx.services,
bot_user_id: &ctx.services.bot_user_id,
project_root: &ctx.services.project_root, project_root: &ctx.services.project_root,
agents: &ctx.services.agents, bot_user_id: &ctx.services.bot_user_id,
ambient_rooms: &ctx.services.ambient_rooms,
room_id: channel, room_id: channel,
}; };
@@ -483,35 +481,25 @@ async fn handle_llm_message(ctx: &DiscordContext, channel: &str, user: &str, use
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::collections::{HashMap, HashSet}; use std::collections::HashMap;
use std::sync::Mutex;
use tokio::sync::Mutex as TokioMutex; 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] #[test]
fn command_dispatches_through_command_registry() { fn command_dispatches_through_command_registry() {
use crate::chat::commands::{CommandDispatch, try_handle_command}; use crate::chat::commands::{CommandDispatch, try_handle_command};
let agents = test_agents(); let services = crate::services::Services::new_test(
let ambient_rooms = test_ambient_rooms(); std::path::PathBuf::from("/tmp"),
"Huskies".to_string(),
);
let room_id = "123456789".to_string(); let room_id = "123456789".to_string();
let bot_name = "Huskies"; let synthetic = "Huskies status".to_string();
let synthetic = format!("{bot_name} status");
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name, services: &services,
project_root: &services.project_root,
bot_user_id: "discord-bot", bot_user_id: "discord-bot",
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, 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 // the LLM. All commands are registered in commands.rs — no special-casing
// needed here. // needed here.
let dispatch = super::super::commands::CommandDispatch { let dispatch = super::super::commands::CommandDispatch {
bot_name: &ctx.services.bot_name, services: &ctx.services,
bot_user_id: ctx.matrix_user_id.as_str(),
project_root: &effective_root, project_root: &effective_root,
agents: &ctx.services.agents, bot_user_id: ctx.matrix_user_id.as_str(),
ambient_rooms: &ctx.services.ambient_rooms,
room_id: &room_id_str, room_id: &room_id_str,
}; };
if let Some((response, response_html)) = if let Some((response, response_html)) =
+16 -29
View File
@@ -108,11 +108,9 @@ pub(super) async fn handle_incoming_message(
} }
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: &ctx.services.bot_name, services: &ctx.services,
bot_user_id: &ctx.services.bot_user_id,
project_root: &ctx.services.project_root, project_root: &ctx.services.project_root,
agents: &ctx.services.agents, bot_user_id: &ctx.services.bot_user_id,
ambient_rooms: &ctx.services.ambient_rooms,
room_id: channel, room_id: channel,
}; };
@@ -533,7 +531,6 @@ async fn handle_llm_message(
mod tests { mod tests {
use super::*; use super::*;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Mutex;
// ── Slash command types ──────────────────────────────────────────── // ── Slash command types ────────────────────────────────────────────
@@ -611,35 +608,26 @@ mod tests {
// ── Slash command shares handlers with mention-based commands ────── // ── 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] #[test]
fn slash_command_dispatches_through_command_registry() { fn slash_command_dispatches_through_command_registry() {
// Verify that the synthetic message built by the slash handler // Verify that the synthetic message built by the slash handler
// correctly dispatches through try_handle_command. // correctly dispatches through try_handle_command.
use crate::chat::commands::{CommandDispatch, try_handle_command}; use crate::chat::commands::{CommandDispatch, try_handle_command};
let agents = test_agents(); let services = crate::services::Services::new_test(
let ambient_rooms = test_ambient_rooms(); std::path::PathBuf::from("/tmp"),
"Huskies".to_string(),
);
let room_id = "C01ABCDEF".to_string(); let room_id = "C01ABCDEF".to_string();
// Simulate what slash_command_receive does: build a synthetic message. // 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 keyword = slash_command_to_bot_keyword("/huskies-status").unwrap();
let synthetic = format!("{bot_name} {keyword}"); let synthetic = format!("Huskies {keyword}");
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name, services: &services,
project_root: &services.project_root,
bot_user_id: "slack-bot", bot_user_id: "slack-bot",
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, room_id: &room_id,
}; };
@@ -655,21 +643,20 @@ mod tests {
fn slash_command_show_passes_args_through_registry() { fn slash_command_show_passes_args_through_registry() {
use crate::chat::commands::{CommandDispatch, try_handle_command}; use crate::chat::commands::{CommandDispatch, try_handle_command};
let agents = test_agents(); let services = crate::services::Services::new_test(
let ambient_rooms = test_ambient_rooms(); std::path::PathBuf::from("/tmp"),
"Huskies".to_string(),
);
let room_id = "C01ABCDEF".to_string(); let room_id = "C01ABCDEF".to_string();
let bot_name = "Huskies";
let keyword = slash_command_to_bot_keyword("/huskies-show").unwrap(); let keyword = slash_command_to_bot_keyword("/huskies-show").unwrap();
// Simulate /huskies-show with text "999" // Simulate /huskies-show with text "999"
let synthetic = format!("{bot_name} {keyword} 999"); let synthetic = format!("Huskies {keyword} 999");
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name, services: &services,
project_root: &services.project_root,
bot_user_id: "slack-bot", bot_user_id: "slack-bot",
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms: &ambient_rooms,
room_id: &room_id, 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}; use crate::chat::commands::{CommandDispatch, try_handle_command};
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: &ctx.services.bot_name, services: &ctx.services,
bot_user_id: &ctx.services.bot_user_id,
project_root: &ctx.services.project_root, project_root: &ctx.services.project_root,
agents: &ctx.services.agents, bot_user_id: &ctx.services.bot_user_id,
ambient_rooms: &ctx.services.ambient_rooms,
room_id: &payload.channel_id, room_id: &payload.channel_id,
}; };
@@ -49,11 +49,9 @@ pub(super) async fn handle_incoming_message(
} }
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name: &ctx.services.bot_name, services: &ctx.services,
bot_user_id: &ctx.services.bot_user_id,
project_root: &ctx.services.project_root, project_root: &ctx.services.project_root,
agents: &ctx.services.agents, bot_user_id: &ctx.services.bot_user_id,
ambient_rooms: &ctx.services.ambient_rooms,
room_id: sender, room_id: sender,
}; };
+15 -6
View File
@@ -129,20 +129,29 @@ pub(super) fn call_sync(
agents: &Arc<AgentPool>, agents: &Arc<AgentPool>,
) -> Option<String> { ) -> Option<String> {
use crate::chat::commands::CommandDispatch; use crate::chat::commands::CommandDispatch;
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
use std::sync::Mutex; use std::sync::Mutex;
let ambient_rooms: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
let bot_name = "__web_ui__"; let bot_name = "__web_ui__";
let bot_user_id = "@__web_ui__:localhost"; let bot_user_id = "@__web_ui__:localhost";
let room_id = "__web_ui__"; let room_id = "__web_ui__";
let (_, perm_rx) = tokio::sync::mpsc::unbounded_channel();
let services = Arc::new(crate::services::Services {
project_root: project_root.to_path_buf(),
agents: Arc::clone(agents),
bot_name: bot_name.to_string(),
bot_user_id: bot_user_id.to_string(),
ambient_rooms: Arc::new(Mutex::new(HashSet::new())),
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
pending_perm_replies: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
permission_timeout_secs: 120,
});
let dispatch = CommandDispatch { let dispatch = CommandDispatch {
bot_name, services: &services,
bot_user_id,
project_root, project_root,
agents, bot_user_id,
ambient_rooms: &ambient_rooms,
room_id, room_id,
}; };
+19
View File
@@ -36,3 +36,22 @@ pub struct Services {
/// auto-denying (fail-closed). /// auto-denying (fail-closed).
pub permission_timeout_secs: u64, pub permission_timeout_secs: u64,
} }
#[cfg(test)]
impl Services {
/// Build a minimal `Services` for testing with the given project root and
/// bot display name.
pub fn new_test(project_root: std::path::PathBuf, bot_name: String) -> std::sync::Arc<Self> {
let (_perm_tx, perm_rx) = mpsc::unbounded_channel();
std::sync::Arc::new(Self {
project_root,
agents: std::sync::Arc::new(crate::agents::AgentPool::new_test(3000)),
bot_name,
bot_user_id: String::new(),
ambient_rooms: std::sync::Arc::new(std::sync::Mutex::new(HashSet::new())),
perm_rx: std::sync::Arc::new(TokioMutex::new(perm_rx)),
pending_perm_replies: std::sync::Arc::new(TokioMutex::new(HashMap::new())),
permission_timeout_secs: 120,
})
}
}