huskies: merge 629_refactor_migrate_commanddispatch_and_commandcontext_to_services_bundle

This commit is contained in:
dave
2026-04-25 20:41:19 +00:00
parent 2a3f88fdcf
commit 14b158d0b2
27 changed files with 407 additions and 544 deletions
+72 -46
View File
@@ -28,11 +28,9 @@ mod triage;
pub(crate) mod unblock;
mod unreleased;
use crate::agents::AgentPool;
use crate::chat::util::strip_bot_mention;
use std::collections::HashSet;
use crate::services::Services;
use std::path::Path;
use std::sync::{Arc, Mutex};
/// A bot-level command that is handled without LLM invocation.
pub struct BotCommand {
@@ -48,41 +46,69 @@ pub struct BotCommand {
/// Dispatch parameters passed to `try_handle_command`.
///
/// Groups all the caller-supplied context needed to dispatch and execute bot
/// commands. Construct one per incoming message and pass it alongside the raw
/// message body.
/// Groups the [`Services`] bundle with per-message dispatch context.
/// Construct one per incoming message and pass it alongside the raw message
/// body.
///
/// All identifiers are platform-agnostic strings so this struct works with
/// any [`ChatTransport`](crate::chat::ChatTransport) implementation.
pub struct CommandDispatch<'a> {
/// The bot's display name (e.g., "Timmy").
pub bot_name: &'a str,
/// The bot's full user ID (e.g., `"@timmy:homeserver.local"` on Matrix).
pub bot_user_id: &'a str,
/// Project root directory (needed by status, ambient).
/// Shared services bundle (project root, agent pool, ambient rooms, …).
pub services: &'a Services,
/// Effective project root — usually `services.project_root`, but the Matrix
/// transport overrides this in gateway mode to point at the active project.
pub project_root: &'a Path,
/// Agent pool (needed by status).
pub agents: &'a AgentPool,
/// Set of room IDs with ambient mode enabled (needed by ambient).
pub ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
/// Bot user ID for mention-stripping — transport-specific (e.g. Matrix's
/// `OwnedUserId` string differs from `services.bot_user_id`).
pub bot_user_id: &'a str,
/// The room this message came from (needed by ambient).
pub room_id: &'a str,
}
/// Context passed to individual command handlers.
///
/// Holds a reference to the shared [`Services`] bundle so that handlers access
/// project-wide state via `ctx.services.*`. The effective project root may
/// differ from `services.project_root` in gateway mode — use
/// [`effective_root()`](Self::effective_root) to get the correct path.
pub struct CommandContext<'a> {
/// The bot's display name (e.g., "Timmy").
pub bot_name: &'a str,
/// Shared services bundle.
pub services: &'a Services,
/// Any text after the command keyword, trimmed.
pub args: &'a str,
/// Project root directory (needed by status, ambient).
pub project_root: &'a Path,
/// Agent pool (needed by status).
pub agents: &'a AgentPool,
/// Set of room IDs with ambient mode enabled (needed by ambient).
pub ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
/// The room this message came from (needed by ambient).
pub room_id: &'a str,
/// Effective project root for this dispatch. Equals `services.project_root`
/// in standalone mode; in gateway mode the Matrix transport sets this to
/// the active-project subdirectory.
project_root: &'a Path,
}
impl<'a> CommandContext<'a> {
/// Returns the effective project root for this command invocation.
///
/// In standalone mode this equals `services.project_root`. In gateway mode
/// (Matrix transport) it resolves to the active project subdirectory.
pub fn effective_root(&self) -> &Path {
self.project_root
}
/// Test-only constructor that allows submodule tests to build a
/// `CommandContext` despite the private `project_root` field.
#[cfg(test)]
pub fn new_test(
services: &'a Services,
args: &'a str,
room_id: &'a str,
project_root: &'a Path,
) -> Self {
Self {
services,
args,
room_id,
project_root,
}
}
}
/// Returns the full list of registered bot commands.
@@ -245,7 +271,8 @@ pub fn try_handle_command_with_html(
dispatch: &CommandDispatch<'_>,
message: &str,
) -> Option<(String, String)> {
let command_text = strip_bot_mention(message, dispatch.bot_name, dispatch.bot_user_id);
let command_text =
strip_bot_mention(message, &dispatch.services.bot_name, dispatch.bot_user_id);
let trimmed = command_text.trim();
if !trimmed.is_empty() {
let (cmd_name, args) = match trimmed.split_once(char::is_whitespace) {
@@ -254,7 +281,8 @@ pub fn try_handle_command_with_html(
};
// Status command: emoji indicators render natively in all clients.
if cmd_name.eq_ignore_ascii_case("status") && args.is_empty() {
let body = status::build_pipeline_status(dispatch.project_root, dispatch.agents);
let body =
status::build_pipeline_status(dispatch.project_root, &dispatch.services.agents);
let html = plain_to_html(&body);
return Some((body, html));
}
@@ -288,7 +316,8 @@ fn plain_to_html(markdown: &str) -> String {
/// Returns `Some(response)` if a command matched and was handled, `None`
/// otherwise (the caller should fall through to the LLM).
pub fn try_handle_command(dispatch: &CommandDispatch<'_>, message: &str) -> Option<String> {
let command_text = strip_bot_mention(message, dispatch.bot_name, dispatch.bot_user_id);
let command_text =
strip_bot_mention(message, &dispatch.services.bot_name, dispatch.bot_user_id);
let trimmed = command_text.trim();
if trimmed.is_empty() {
return None;
@@ -301,12 +330,10 @@ pub fn try_handle_command(dispatch: &CommandDispatch<'_>, message: &str) -> Opti
let cmd_lower = cmd_name.to_ascii_lowercase();
let ctx = CommandContext {
bot_name: dispatch.bot_name,
services: dispatch.services,
args,
project_root: dispatch.project_root,
agents: dispatch.agents,
ambient_rooms: dispatch.ambient_rooms,
room_id: dispatch.room_id,
project_root: dispatch.project_root,
};
commands()
@@ -382,39 +409,38 @@ fn handle_rebuild_fallback(_ctx: &CommandContext) -> Option<String> {
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::agents::AgentPool;
use crate::services::Services;
use std::sync::Arc;
// -- test helpers (shared with submodule tests) -------------------------
pub fn test_ambient_rooms() -> Arc<Mutex<HashSet<String>>> {
Arc::new(Mutex::new(HashSet::new()))
/// Build a [`Services`] bundle for tests with the given bot name and a `/tmp`
/// project root.
pub fn test_services_named(bot_name: &str) -> Arc<Services> {
Services::new_test(std::path::PathBuf::from("/tmp"), bot_name.to_string())
}
pub fn test_agents() -> Arc<AgentPool> {
Arc::new(AgentPool::new_test(3000))
}
pub fn try_cmd(
bot_name: &str,
/// Dispatch a message through the command registry using the given Services.
pub fn try_cmd_with_services(
bot_user_id: &str,
message: &str,
ambient_rooms: &Arc<Mutex<HashSet<String>>>,
services: &Services,
) -> Option<String> {
let agents = test_agents();
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
bot_name,
services,
project_root: &services.project_root,
bot_user_id,
project_root: std::path::Path::new("/tmp"),
agents: &agents,
ambient_rooms,
room_id: &room_id,
};
try_handle_command(&dispatch, message)
}
/// Convenience helper: create a temporary [`Services`] with the given bot
/// name and dispatch `message`.
pub fn try_cmd_addressed(bot_name: &str, bot_user_id: &str, message: &str) -> Option<String> {
try_cmd(bot_name, bot_user_id, message, &test_ambient_rooms())
let services = test_services_named(bot_name);
try_cmd_with_services(bot_user_id, message, &services)
}
// Re-export commands() for submodule tests