From 4b710b02f2d6efb48b36ceb60da1b18b891bd992 Mon Sep 17 00:00:00 2001 From: dave Date: Thu, 16 Apr 2026 16:09:13 +0000 Subject: [PATCH] huskies: merge 591_story_gateway_chat_commands_use_active_project_root_instead_of_gateway_config_dir --- .../src/chat/transport/matrix/bot/context.rs | 137 ++++++++++++++++++ .../src/chat/transport/matrix/bot/messages.rs | 22 ++- 2 files changed, 153 insertions(+), 6 deletions(-) diff --git a/server/src/chat/transport/matrix/bot/context.rs b/server/src/chat/transport/matrix/bot/context.rs index a91c31c7..e8d862e5 100644 --- a/server/src/chat/transport/matrix/bot/context.rs +++ b/server/src/chat/transport/matrix/bot/context.rs @@ -67,6 +67,23 @@ pub struct BotContext { pub gateway_projects: Vec, } +impl BotContext { + /// Resolve the effective project root for command dispatch. + /// + /// In gateway mode the bot's `project_root` is the gateway config directory. + /// Each project lives in a subdirectory named after the project, so the + /// effective root for commands is `project_root / active_project_name`. + /// In standalone (single-project) mode this returns `project_root` unchanged. + pub async fn effective_project_root(&self) -> PathBuf { + if let Some(ref ap) = self.gateway_active_project { + let name = ap.read().await.clone(); + self.project_root.join(&name) + } else { + self.project_root.clone() + } + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -88,6 +105,126 @@ mod tests { assert_clone::(); } + #[tokio::test] + async fn effective_project_root_standalone_returns_project_root() { + // In standalone mode (gateway_active_project is None), the effective root + // must equal the project_root exactly. + let (_perm_tx, perm_rx) = mpsc::unbounded_channel(); + let ctx = BotContext { + bot_user_id: make_user_id("@bot:example.com"), + target_room_ids: vec![], + project_root: PathBuf::from("/projects/myapp"), + allowed_users: vec![], + history: Arc::new(TokioMutex::new(std::collections::HashMap::new())), + history_size: 20, + bot_sent_event_ids: Arc::new(TokioMutex::new(std::collections::HashSet::new())), + perm_rx: Arc::new(TokioMutex::new(perm_rx)), + pending_perm_replies: Arc::new(TokioMutex::new(std::collections::HashMap::new())), + permission_timeout_secs: 120, + bot_name: "Assistant".to_string(), + ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())), + agents: Arc::new(crate::agents::AgentPool::new_test(3000)), + htop_sessions: Arc::new(TokioMutex::new(std::collections::HashMap::new())), + transport: Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new( + "test-phone".to_string(), + "test-token".to_string(), + "pipeline_notification".to_string(), + )), + timer_store: Arc::new(crate::chat::timer::TimerStore::load( + std::path::PathBuf::from("/tmp/timers.json"), + )), + gateway_active_project: None, + gateway_projects: vec![], + }; + assert_eq!( + ctx.effective_project_root().await, + PathBuf::from("/projects/myapp") + ); + } + + #[tokio::test] + async fn effective_project_root_gateway_uses_active_project_subdir() { + // In gateway mode, the effective root must be config_dir / active_project_name. + let (_perm_tx, perm_rx) = mpsc::unbounded_channel(); + let active = Arc::new(RwLock::new("huskies".to_string())); + let ctx = BotContext { + bot_user_id: make_user_id("@bot:example.com"), + target_room_ids: vec![], + project_root: PathBuf::from("/gateway"), + allowed_users: vec![], + history: Arc::new(TokioMutex::new(std::collections::HashMap::new())), + history_size: 20, + bot_sent_event_ids: Arc::new(TokioMutex::new(std::collections::HashSet::new())), + perm_rx: Arc::new(TokioMutex::new(perm_rx)), + pending_perm_replies: Arc::new(TokioMutex::new(std::collections::HashMap::new())), + permission_timeout_secs: 120, + bot_name: "Assistant".to_string(), + ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())), + agents: Arc::new(crate::agents::AgentPool::new_test(3000)), + htop_sessions: Arc::new(TokioMutex::new(std::collections::HashMap::new())), + transport: Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new( + "test-phone".to_string(), + "test-token".to_string(), + "pipeline_notification".to_string(), + )), + timer_store: Arc::new(crate::chat::timer::TimerStore::load( + std::path::PathBuf::from("/tmp/timers.json"), + )), + gateway_active_project: Some(Arc::clone(&active)), + gateway_projects: vec!["huskies".into(), "robot-studio".into()], + }; + assert_eq!( + ctx.effective_project_root().await, + PathBuf::from("/gateway/huskies") + ); + } + + #[tokio::test] + async fn effective_project_root_gateway_reflects_project_switch() { + // Switching the active project must change the effective root. + let (_perm_tx, perm_rx) = mpsc::unbounded_channel(); + let active = Arc::new(RwLock::new("huskies".to_string())); + let ctx = BotContext { + bot_user_id: make_user_id("@bot:example.com"), + target_room_ids: vec![], + project_root: PathBuf::from("/gateway"), + allowed_users: vec![], + history: Arc::new(TokioMutex::new(std::collections::HashMap::new())), + history_size: 20, + bot_sent_event_ids: Arc::new(TokioMutex::new(std::collections::HashSet::new())), + perm_rx: Arc::new(TokioMutex::new(perm_rx)), + pending_perm_replies: Arc::new(TokioMutex::new(std::collections::HashMap::new())), + permission_timeout_secs: 120, + bot_name: "Assistant".to_string(), + ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())), + agents: Arc::new(crate::agents::AgentPool::new_test(3000)), + htop_sessions: Arc::new(TokioMutex::new(std::collections::HashMap::new())), + transport: Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new( + "test-phone".to_string(), + "test-token".to_string(), + "pipeline_notification".to_string(), + )), + timer_store: Arc::new(crate::chat::timer::TimerStore::load( + std::path::PathBuf::from("/tmp/timers.json"), + )), + gateway_active_project: Some(Arc::clone(&active)), + gateway_projects: vec!["huskies".into(), "robot-studio".into()], + }; + + assert_eq!( + ctx.effective_project_root().await, + PathBuf::from("/gateway/huskies") + ); + + // Simulate switch_project changing the active project. + *active.write().await = "robot-studio".to_string(); + + assert_eq!( + ctx.effective_project_root().await, + PathBuf::from("/gateway/robot-studio") + ); + } + #[test] fn bot_context_has_no_require_verified_devices_field() { // Verification is always on — BotContext no longer has a toggle field. diff --git a/server/src/chat/transport/matrix/bot/messages.rs b/server/src/chat/transport/matrix/bot/messages.rs index c14f1e08..e7ef18cd 100644 --- a/server/src/chat/transport/matrix/bot/messages.rs +++ b/server/src/chat/transport/matrix/bot/messages.rs @@ -174,13 +174,18 @@ pub(super) async fn on_room_message( let user_message = body; slog!("[matrix-bot] Message from {sender}: {user_message}"); + // In gateway mode, resolve commands against the active project's root directory. + // The gateway's own project_root is the gateway config dir; each project lives in + // a subdirectory named after the project. Standalone mode is unaffected. + let effective_root = ctx.effective_project_root().await; + // Check for bot-level commands (help, status, ambient, …) before invoking // the LLM. All commands are registered in commands.rs — no special-casing // needed here. let dispatch = super::super::commands::CommandDispatch { bot_name: &ctx.bot_name, bot_user_id: ctx.bot_user_id.as_str(), - project_root: &ctx.project_root, + project_root: &effective_root, agents: &ctx.agents, ambient_rooms: &ctx.ambient_rooms, room_id: &room_id_str, @@ -219,7 +224,7 @@ pub(super) async fn on_room_message( &ctx.bot_name, &story_number, &model, - &ctx.project_root, + &effective_root, &ctx.agents, ) .await @@ -287,7 +292,7 @@ pub(super) async fn on_room_message( super::super::delete::handle_delete( &ctx.bot_name, &story_number, - &ctx.project_root, + &effective_root, &ctx.agents, ) .await @@ -321,7 +326,7 @@ pub(super) async fn on_room_message( super::super::rmtree::handle_rmtree( &ctx.bot_name, &story_number, - &ctx.project_root, + &effective_root, &ctx.agents, ) .await @@ -361,7 +366,7 @@ pub(super) async fn on_room_message( &ctx.bot_name, &story_number, agent_hint.as_deref(), - &ctx.project_root, + &effective_root, &ctx.agents, ) .await @@ -587,7 +592,12 @@ pub(super) async fn handle_message( let sent_any_chunk = Arc::new(AtomicBool::new(false)); let sent_any_chunk_for_callback = Arc::clone(&sent_any_chunk); - let project_root_str = ctx.project_root.to_string_lossy().to_string(); + // In gateway mode, run Claude Code in the active project's directory. + let project_root_str = ctx + .effective_project_root() + .await + .to_string_lossy() + .to_string(); let chat_fut = provider.chat_stream( &prompt, &project_root_str,