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