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