Skip to main content

brainos_cortex/
actions.rs

1//! Action dispatch — tool execution.
2//!
3//! Dispatches tool calls from LLM: command execution (sandboxed),
4//! web search, scheduling, memory operations, and message sending.
5
6use std::sync::Arc;
7
8use thiserror::Error;
9
10// ─── Errors ─────────────────────────────────────────────────────────────────
11
12/// Errors from action execution.
13#[derive(Debug, Error)]
14pub enum ActionError {
15    #[error("Command not allowed: {0}")]
16    CommandNotAllowed(String),
17
18    #[error("Command execution failed: {0}")]
19    ExecutionFailed(String),
20
21    #[error("Timeout")]
22    Timeout,
23
24    #[error("Invalid arguments: {0}")]
25    InvalidArguments(String),
26
27    #[error("IO error: {0}")]
28    Io(#[from] std::io::Error),
29}
30
31// ─── Action Types ───────────────────────────────────────────────────────────
32
33/// Available actions/tools.
34#[derive(Debug, Clone, PartialEq)]
35pub enum Action {
36    /// Execute a shell command (sandboxed).
37    ExecuteCommand { command: String, args: Vec<String> },
38    /// Search the web.
39    WebSearch { query: String },
40    /// Schedule a task.
41    ScheduleTask {
42        description: String,
43        cron: Option<String>,
44    },
45    /// Store a fact in semantic memory.
46    StoreFact {
47        subject: String,
48        predicate: String,
49        object: String,
50    },
51    /// Recall from memory.
52    Recall { query: String },
53    /// Send a message to an external endpoint (via protocol adapters).
54    SendMessage {
55        channel: String,
56        recipient: String,
57        content: String,
58    },
59}
60
61/// Result of an action execution.
62#[derive(Debug, Clone)]
63pub struct ActionResult {
64    pub success: bool,
65    pub output: String,
66    pub error: Option<String>,
67}
68
69/// Normalized memory fact used by action backends.
70#[derive(Debug, Clone)]
71pub struct MemoryFact {
72    pub namespace: String,
73    pub subject: String,
74    pub predicate: String,
75    pub object: String,
76    pub confidence: f64,
77}
78
79/// Optional backend that provides real memory read/write operations.
80#[async_trait::async_trait]
81pub trait MemoryBackend: Send + Sync {
82    async fn store_fact(
83        &self,
84        namespace: &str,
85        category: &str,
86        subject: &str,
87        predicate: &str,
88        object: &str,
89    ) -> Result<String, ActionError>;
90
91    async fn recall(
92        &self,
93        query: &str,
94        top_k: usize,
95        namespace: Option<&str>,
96    ) -> Result<Vec<MemoryFact>, ActionError>;
97}
98
99/// Structured web-search hit returned by WebSearchBackend.
100#[derive(Debug, Clone)]
101pub struct SearchHit {
102    pub title: String,
103    pub url: String,
104    pub snippet: String,
105}
106
107/// Optional backend for web search actions.
108#[async_trait::async_trait]
109pub trait WebSearchBackend: Send + Sync {
110    async fn search(&self, query: &str, top_k: usize) -> Result<Vec<SearchHit>, ActionError>;
111}
112
113/// Structured scheduling outcome returned by SchedulingBackend.
114#[derive(Debug, Clone)]
115pub struct ScheduleOutcome {
116    pub schedule_id: String,
117    pub status: String,
118}
119
120/// Optional backend for scheduling actions.
121#[async_trait::async_trait]
122pub trait SchedulingBackend: Send + Sync {
123    async fn schedule(
124        &self,
125        description: &str,
126        cron: Option<&str>,
127        namespace: &str,
128    ) -> Result<ScheduleOutcome, ActionError>;
129}
130
131/// Structured message-delivery outcome returned by MessageBackend.
132#[derive(Debug, Clone)]
133pub struct MessageOutcome {
134    pub delivery_id: String,
135    pub status: String,
136}
137
138/// Optional backend for outbound message actions.
139#[async_trait::async_trait]
140pub trait MessageBackend: Send + Sync {
141    async fn send(
142        &self,
143        channel: &str,
144        recipient: &str,
145        content: &str,
146        namespace: &str,
147    ) -> Result<MessageOutcome, ActionError>;
148}
149
150impl ActionResult {
151    /// Create a successful result.
152    pub fn success(output: impl Into<String>) -> Self {
153        Self {
154            success: true,
155            output: output.into(),
156            error: None,
157        }
158    }
159
160    /// Create a failed result.
161    pub fn failure(error: impl Into<String>) -> Self {
162        Self {
163            success: false,
164            output: String::new(),
165            error: Some(error.into()),
166        }
167    }
168}
169
170// ─── Action Dispatcher ──────────────────────────────────────────────────────
171
172/// Configuration for action execution.
173#[derive(Debug, Clone)]
174pub struct ActionConfig {
175    /// Allowed commands for execution.
176    pub command_allowlist: Vec<String>,
177    /// Timeout for command execution (seconds).
178    pub command_timeout_secs: u64,
179    /// Enable web search.
180    pub enable_web_search: bool,
181    /// Enable scheduling.
182    pub enable_scheduling: bool,
183    /// Enable channel sends.
184    pub enable_channel_send: bool,
185    /// Default number of hits to request from the web search backend.
186    pub web_search_top_k: usize,
187}
188
189impl Default for ActionConfig {
190    fn default() -> Self {
191        Self {
192            command_allowlist: vec![
193                "ls".to_string(),
194                "cat".to_string(),
195                "grep".to_string(),
196                "find".to_string(),
197                "git".to_string(),
198                "cargo".to_string(),
199                "rustc".to_string(),
200                "pwd".to_string(),
201                "echo".to_string(),
202                "head".to_string(),
203                "tail".to_string(),
204            ],
205            command_timeout_secs: 30,
206            enable_web_search: true,
207            enable_scheduling: false,
208            enable_channel_send: false,
209            web_search_top_k: 5,
210        }
211    }
212}
213
214/// Dispatches actions/tools.
215pub struct ActionDispatcher {
216    config: ActionConfig,
217    memory_backend: Option<Arc<dyn MemoryBackend>>,
218    web_search_backend: Option<Arc<dyn WebSearchBackend>>,
219    scheduling_backend: Option<Arc<dyn SchedulingBackend>>,
220    message_backend: Option<Arc<dyn MessageBackend>>,
221    namespace: String,
222}
223
224impl ActionDispatcher {
225    /// Create a new dispatcher.
226    pub fn new(config: ActionConfig) -> Self {
227        Self {
228            config,
229            memory_backend: None,
230            web_search_backend: None,
231            scheduling_backend: None,
232            message_backend: None,
233            namespace: "personal".to_string(),
234        }
235    }
236
237    /// Create a new dispatcher with a memory backend attached.
238    pub fn with_memory_backend(
239        config: ActionConfig,
240        memory_backend: Arc<dyn MemoryBackend>,
241    ) -> Self {
242        Self::new(config).with_memory(memory_backend)
243    }
244
245    /// Create with default config.
246    pub fn with_defaults() -> Self {
247        Self::new(ActionConfig::default())
248    }
249
250    /// Attach a memory backend.
251    pub fn with_memory(mut self, memory_backend: Arc<dyn MemoryBackend>) -> Self {
252        self.memory_backend = Some(memory_backend);
253        self
254    }
255
256    /// Attach a web-search backend.
257    pub fn with_web_search_backend(mut self, backend: Arc<dyn WebSearchBackend>) -> Self {
258        self.web_search_backend = Some(backend);
259        self
260    }
261
262    /// Attach a scheduling backend.
263    pub fn with_scheduling_backend(mut self, backend: Arc<dyn SchedulingBackend>) -> Self {
264        self.scheduling_backend = Some(backend);
265        self
266    }
267
268    /// Attach a message backend.
269    pub fn with_message_backend(mut self, backend: Arc<dyn MessageBackend>) -> Self {
270        self.message_backend = Some(backend);
271        self
272    }
273
274    /// Set the default namespace used by action backends.
275    pub fn set_namespace(&mut self, namespace: impl Into<String>) {
276        self.namespace = namespace.into();
277    }
278
279    fn active_namespace(&self) -> &str {
280        let trimmed = self.namespace.trim();
281        if trimmed.is_empty() {
282            "personal"
283        } else {
284            trimmed
285        }
286    }
287
288    /// Execute an action.
289    pub async fn dispatch(&self, action: &Action) -> ActionResult {
290        match action {
291            Action::ExecuteCommand { command, args } => self.execute_command(command, args).await,
292            Action::WebSearch { query } => self.web_search(query).await,
293            Action::ScheduleTask { description, cron } => {
294                self.schedule_task(description, cron.as_deref()).await
295            }
296            Action::StoreFact {
297                subject,
298                predicate,
299                object,
300            } => self.store_fact(subject, predicate, object).await,
301            Action::Recall { query } => self.recall(query).await,
302            Action::SendMessage {
303                channel,
304                recipient,
305                content,
306            } => self.send_message(channel, recipient, content).await,
307        }
308    }
309
310    /// Execute a sandboxed command.
311    async fn execute_command(&self, command: &str, args: &[String]) -> ActionResult {
312        // Check allowlist
313        if !self.config.command_allowlist.contains(&command.to_string()) {
314            return ActionResult::failure(format!("Command '{}' is not in the allowlist", command));
315        }
316
317        // Build command
318        let mut cmd = tokio::process::Command::new(command);
319        cmd.args(args)
320            .stdout(std::process::Stdio::piped())
321            .stderr(std::process::Stdio::piped());
322
323        // Execute with timeout
324        match tokio::time::timeout(
325            tokio::time::Duration::from_secs(self.config.command_timeout_secs),
326            cmd.output(),
327        )
328        .await
329        {
330            Ok(Ok(output)) => {
331                let stdout = String::from_utf8_lossy(&output.stdout);
332                let stderr = String::from_utf8_lossy(&output.stderr);
333
334                if output.status.success() {
335                    ActionResult::success(stdout.to_string())
336                } else {
337                    ActionResult::failure(format!(
338                        "Exit code: {:?}\nstderr: {}",
339                        output.status.code(),
340                        stderr
341                    ))
342                }
343            }
344            Ok(Err(e)) => ActionResult::failure(format!("Failed to execute: {}", e)),
345            Err(_) => ActionResult::failure("Command timed out"),
346        }
347    }
348
349    /// Search the web.
350    async fn web_search(&self, query: &str) -> ActionResult {
351        if !self.config.enable_web_search {
352            return ActionResult::failure("Web search is disabled by config");
353        }
354        let Some(backend) = &self.web_search_backend else {
355            return ActionResult::failure("Web search backend not configured");
356        };
357        let top_k = self.config.web_search_top_k.max(1);
358        match backend.search(query, top_k).await {
359            Ok(hits) => {
360                if hits.is_empty() {
361                    return ActionResult::success(format!(
362                        "web_search ok query=\"{}\" top_k={} hits=0",
363                        query, top_k
364                    ));
365                }
366                let lines = hits
367                    .iter()
368                    .enumerate()
369                    .map(|(i, hit)| {
370                        format!("{}. {} ({}) - {}", i + 1, hit.title, hit.url, hit.snippet)
371                    })
372                    .collect::<Vec<_>>()
373                    .join("\n");
374                ActionResult::success(format!(
375                    "web_search ok query=\"{}\" top_k={} hits={}\n{}",
376                    query,
377                    top_k,
378                    hits.len(),
379                    lines
380                ))
381            }
382            Err(e) => ActionResult::failure(format!("Web search failed: {e}")),
383        }
384    }
385
386    /// Schedule a task.
387    async fn schedule_task(&self, description: &str, cron: Option<&str>) -> ActionResult {
388        if !self.config.enable_scheduling {
389            return ActionResult::failure("Scheduling is disabled by config");
390        }
391        let Some(backend) = &self.scheduling_backend else {
392            return ActionResult::failure("Scheduling backend not configured");
393        };
394        let namespace = self.active_namespace();
395        match backend.schedule(description, cron, namespace).await {
396            Ok(outcome) => ActionResult::success(format!(
397                "schedule_task ok id={} status={} namespace={} cron={} description=\"{}\"",
398                outcome.schedule_id,
399                outcome.status,
400                namespace,
401                cron.unwrap_or("none"),
402                description
403            )),
404            Err(e) => ActionResult::failure(format!("Schedule task failed: {e}")),
405        }
406    }
407
408    /// Store a fact in semantic memory.
409    async fn store_fact(&self, subject: &str, predicate: &str, object: &str) -> ActionResult {
410        let Some(memory) = &self.memory_backend else {
411            return ActionResult::failure("Memory backend not available");
412        };
413        let namespace = self.active_namespace();
414
415        match memory
416            .store_fact(namespace, "action", subject, predicate, object)
417            .await
418        {
419            Ok(id) => ActionResult::success(format!(
420                "Fact stored [{}] [{}]: {} {} {}",
421                id, namespace, subject, predicate, object
422            )),
423            Err(e) => ActionResult::failure(format!("Failed to store fact: {e}")),
424        }
425    }
426
427    /// Recall from memory.
428    async fn recall(&self, query: &str) -> ActionResult {
429        let Some(memory) = &self.memory_backend else {
430            return ActionResult::failure("Memory backend not available");
431        };
432        let namespace = self.active_namespace();
433
434        match memory.recall(query, 10, Some(namespace)).await {
435            Ok(results) if results.is_empty() => ActionResult::success("No matching facts found."),
436            Ok(results) => {
437                let lines = results
438                    .iter()
439                    .map(|r| {
440                        format!(
441                            "[{}] {} {} {} (confidence: {:.2})",
442                            r.namespace, r.subject, r.predicate, r.object, r.confidence
443                        )
444                    })
445                    .collect::<Vec<_>>()
446                    .join("\n");
447                ActionResult::success(format!("Found {} fact(s):\n{}", results.len(), lines))
448            }
449            Err(e) => ActionResult::failure(format!("Recall failed: {e}")),
450        }
451    }
452
453    /// Send a message via channel.
454    async fn send_message(&self, channel: &str, recipient: &str, content: &str) -> ActionResult {
455        if !self.config.enable_channel_send {
456            return ActionResult::failure("Channel sending is disabled by config");
457        }
458        let Some(backend) = &self.message_backend else {
459            return ActionResult::failure("Message backend not configured");
460        };
461        let namespace = self.active_namespace();
462        match backend.send(channel, recipient, content, namespace).await {
463            Ok(outcome) => ActionResult::success(format!(
464                "send_message ok id={} status={} channel={} recipient={} namespace={}",
465                outcome.delivery_id, outcome.status, channel, recipient, namespace
466            )),
467            Err(e) => ActionResult::failure(format!("Send message failed: {e}")),
468        }
469    }
470}
471
472// ─── Tool Definition for LLM ────────────────────────────────────────────────
473
474/// Tool definition for LLM function calling.
475#[derive(Debug, Clone, serde::Serialize)]
476pub struct ToolDefinition {
477    pub name: String,
478    pub description: String,
479    pub parameters: serde_json::Value,
480}
481
482/// Get available tools as LLM function definitions.
483pub fn get_available_tools() -> Vec<ToolDefinition> {
484    vec![
485        ToolDefinition {
486            name: "execute_command".to_string(),
487            description: "Execute a sandboxed shell command".to_string(),
488            parameters: serde_json::json!({
489                "type": "object",
490                "properties": {
491                    "command": {
492                        "type": "string",
493                        "description": "The command to execute"
494                    },
495                    "args": {
496                        "type": "array",
497                        "items": {"type": "string"},
498                        "description": "Command arguments"
499                    }
500                },
501                "required": ["command"]
502            }),
503        },
504        ToolDefinition {
505            name: "web_search".to_string(),
506            description: "Search the web for information".to_string(),
507            parameters: serde_json::json!({
508                "type": "object",
509                "properties": {
510                    "query": {
511                        "type": "string",
512                        "description": "The search query"
513                    }
514                },
515                "required": ["query"]
516            }),
517        },
518        ToolDefinition {
519            name: "store_fact".to_string(),
520            description: "Store a fact in memory".to_string(),
521            parameters: serde_json::json!({
522                "type": "object",
523                "properties": {
524                    "subject": {"type": "string"},
525                    "predicate": {"type": "string"},
526                    "object": {"type": "string"}
527                },
528                "required": ["subject", "predicate", "object"]
529            }),
530        },
531        ToolDefinition {
532            name: "recall".to_string(),
533            description: "Search memory for relevant information".to_string(),
534            parameters: serde_json::json!({
535                "type": "object",
536                "properties": {
537                    "query": {
538                        "type": "string",
539                        "description": "What to search for"
540                    }
541                },
542                "required": ["query"]
543            }),
544        },
545    ]
546}
547
548// ─── Tests ──────────────────────────────────────────────────────────────────
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use std::sync::{Arc, Mutex};
554
555    struct MockMemoryBackend {
556        facts: Mutex<Vec<MemoryFact>>,
557    }
558
559    #[async_trait::async_trait]
560    impl MemoryBackend for MockMemoryBackend {
561        async fn store_fact(
562            &self,
563            namespace: &str,
564            _category: &str,
565            subject: &str,
566            predicate: &str,
567            object: &str,
568        ) -> Result<String, ActionError> {
569            self.facts.lock().unwrap().push(MemoryFact {
570                namespace: namespace.to_string(),
571                subject: subject.to_string(),
572                predicate: predicate.to_string(),
573                object: object.to_string(),
574                confidence: 1.0,
575            });
576            Ok("fact-1".to_string())
577        }
578
579        async fn recall(
580            &self,
581            query: &str,
582            _top_k: usize,
583            namespace: Option<&str>,
584        ) -> Result<Vec<MemoryFact>, ActionError> {
585            let facts = self.facts.lock().unwrap();
586            Ok(facts
587                .iter()
588                .filter(|f| {
589                    namespace.is_none_or(|ns| f.namespace == ns)
590                        && (f.subject.contains(query)
591                            || f.predicate.contains(query)
592                            || f.object.contains(query))
593                })
594                .cloned()
595                .collect())
596        }
597    }
598
599    struct MockWebSearchBackend;
600
601    #[async_trait::async_trait]
602    impl WebSearchBackend for MockWebSearchBackend {
603        async fn search(&self, query: &str, top_k: usize) -> Result<Vec<SearchHit>, ActionError> {
604            Ok((0..top_k)
605                .map(|i| SearchHit {
606                    title: format!("{query} hit {}", i + 1),
607                    url: format!("https://example.com/{i}"),
608                    snippet: "snippet".to_string(),
609                })
610                .collect())
611        }
612    }
613
614    struct MockSchedulingBackend {
615        calls: Mutex<Vec<(String, Option<String>, String)>>,
616    }
617
618    #[async_trait::async_trait]
619    impl SchedulingBackend for MockSchedulingBackend {
620        async fn schedule(
621            &self,
622            description: &str,
623            cron: Option<&str>,
624            namespace: &str,
625        ) -> Result<ScheduleOutcome, ActionError> {
626            self.calls.lock().expect("calls lock").push((
627                description.to_string(),
628                cron.map(|c| c.to_string()),
629                namespace.to_string(),
630            ));
631            Ok(ScheduleOutcome {
632                schedule_id: "sched-1".to_string(),
633                status: "scheduled".to_string(),
634            })
635        }
636    }
637
638    struct MockMessageBackend {
639        calls: Mutex<Vec<(String, String, String, String)>>,
640    }
641
642    #[async_trait::async_trait]
643    impl MessageBackend for MockMessageBackend {
644        async fn send(
645            &self,
646            channel: &str,
647            recipient: &str,
648            content: &str,
649            namespace: &str,
650        ) -> Result<MessageOutcome, ActionError> {
651            self.calls.lock().expect("calls lock").push((
652                channel.to_string(),
653                recipient.to_string(),
654                content.to_string(),
655                namespace.to_string(),
656            ));
657            Ok(MessageOutcome {
658                delivery_id: "msg-1".to_string(),
659                status: "accepted".to_string(),
660            })
661        }
662    }
663
664    #[test]
665    fn test_action_result_success() {
666        let result = ActionResult::success("output");
667        assert!(result.success);
668        assert_eq!(result.output, "output");
669        assert!(result.error.is_none());
670    }
671
672    #[test]
673    fn test_action_result_failure() {
674        let result = ActionResult::failure("error");
675        assert!(!result.success);
676        assert_eq!(result.error, Some("error".to_string()));
677    }
678
679    #[test]
680    fn test_action_config_default() {
681        let config = ActionConfig::default();
682        assert!(config.command_allowlist.contains(&"ls".to_string()));
683        assert_eq!(config.command_timeout_secs, 30);
684        assert!(config.enable_web_search);
685        assert_eq!(config.web_search_top_k, 5);
686    }
687
688    #[tokio::test]
689    async fn test_execute_allowed_command() {
690        let dispatcher = ActionDispatcher::with_defaults();
691        let action = Action::ExecuteCommand {
692            command: "echo".to_string(),
693            args: vec!["hello".to_string()],
694        };
695
696        let result = dispatcher.dispatch(&action).await;
697        assert!(result.success);
698        assert!(result.output.contains("hello"));
699    }
700
701    #[tokio::test]
702    async fn test_execute_disallowed_command() {
703        let dispatcher = ActionDispatcher::with_defaults();
704        let action = Action::ExecuteCommand {
705            command: "rm".to_string(),
706            args: vec!["-rf".to_string(), "/".to_string()],
707        };
708
709        let result = dispatcher.dispatch(&action).await;
710        assert!(!result.success);
711        assert!(result
712            .error
713            .as_ref()
714            .unwrap()
715            .contains("not in the allowlist"));
716    }
717
718    #[test]
719    fn test_get_available_tools() {
720        let tools = get_available_tools();
721        assert!(!tools.is_empty());
722        assert!(tools.iter().any(|t| t.name == "execute_command"));
723        assert!(tools.iter().any(|t| t.name == "web_search"));
724    }
725
726    #[tokio::test]
727    async fn test_store_fact_with_memory_backend() {
728        let backend = Arc::new(MockMemoryBackend {
729            facts: Mutex::new(Vec::new()),
730        });
731        let dispatcher = ActionDispatcher::with_memory_backend(ActionConfig::default(), backend);
732
733        let action = Action::StoreFact {
734            subject: "user".to_string(),
735            predicate: "likes".to_string(),
736            object: "Rust".to_string(),
737        };
738        let result = dispatcher.dispatch(&action).await;
739        assert!(result.success);
740        assert!(result.output.contains("Fact stored"));
741    }
742
743    #[tokio::test]
744    async fn test_recall_with_memory_backend() {
745        let backend = Arc::new(MockMemoryBackend {
746            facts: Mutex::new(Vec::new()),
747        });
748        let mut dispatcher =
749            ActionDispatcher::with_memory_backend(ActionConfig::default(), backend.clone());
750
751        dispatcher.set_namespace("work");
752        let store = Action::StoreFact {
753            subject: "user".to_string(),
754            predicate: "likes".to_string(),
755            object: "Rust".to_string(),
756        };
757        let _ = dispatcher.dispatch(&store).await;
758
759        dispatcher.set_namespace("personal");
760        let store_personal = Action::StoreFact {
761            subject: "user".to_string(),
762            predicate: "likes".to_string(),
763            object: "Go".to_string(),
764        };
765        let _ = dispatcher.dispatch(&store_personal).await;
766
767        dispatcher.set_namespace("work");
768        let recall = Action::Recall {
769            query: "Rust".to_string(),
770        };
771        let result = dispatcher.dispatch(&recall).await;
772        assert!(result.success);
773        assert!(result.output.contains("Found 1 fact"));
774        assert!(result.output.contains("[work]"));
775    }
776
777    #[tokio::test]
778    async fn test_memory_actions_fail_without_backend() {
779        let dispatcher = ActionDispatcher::with_defaults();
780        let action = Action::Recall {
781            query: "anything".to_string(),
782        };
783        let result = dispatcher.dispatch(&action).await;
784        assert!(!result.success);
785        assert!(result
786            .error
787            .as_deref()
788            .unwrap_or_default()
789            .contains("Memory backend not available"));
790    }
791
792    #[tokio::test]
793    async fn test_web_search_disabled() {
794        let mut cfg = ActionConfig::default();
795        cfg.enable_web_search = false;
796        let dispatcher = ActionDispatcher::new(cfg);
797        let result = dispatcher
798            .dispatch(&Action::WebSearch {
799                query: "rust".to_string(),
800            })
801            .await;
802        assert!(!result.success);
803        assert!(result
804            .error
805            .as_deref()
806            .unwrap_or_default()
807            .contains("disabled by config"));
808    }
809
810    #[tokio::test]
811    async fn test_web_search_backend_not_configured() {
812        let dispatcher = ActionDispatcher::with_defaults();
813        let result = dispatcher
814            .dispatch(&Action::WebSearch {
815                query: "rust".to_string(),
816            })
817            .await;
818        assert!(!result.success);
819        assert!(result
820            .error
821            .as_deref()
822            .unwrap_or_default()
823            .contains("backend not configured"));
824    }
825
826    #[tokio::test]
827    async fn test_web_search_success_with_backend() {
828        let dispatcher = ActionDispatcher::with_defaults()
829            .with_web_search_backend(Arc::new(MockWebSearchBackend));
830        let result = dispatcher
831            .dispatch(&Action::WebSearch {
832                query: "rust".to_string(),
833            })
834            .await;
835        assert!(result.success);
836        assert!(result.output.contains("web_search ok"));
837        assert!(result.output.contains("hits=5"));
838    }
839
840    #[tokio::test]
841    async fn test_schedule_task_backend_matrix() {
842        let mut disabled = ActionConfig::default();
843        disabled.enable_scheduling = false;
844        let dispatcher = ActionDispatcher::new(disabled.clone());
845        let result = dispatcher
846            .dispatch(&Action::ScheduleTask {
847                description: "ship release".to_string(),
848                cron: Some("0 10 * * 1".to_string()),
849            })
850            .await;
851        assert!(!result.success);
852        assert!(result
853            .error
854            .as_deref()
855            .unwrap_or_default()
856            .contains("disabled by config"));
857
858        disabled.enable_scheduling = true;
859        let unconfigured = ActionDispatcher::new(disabled.clone());
860        let result = unconfigured
861            .dispatch(&Action::ScheduleTask {
862                description: "ship release".to_string(),
863                cron: Some("0 10 * * 1".to_string()),
864            })
865            .await;
866        assert!(!result.success);
867        assert!(result
868            .error
869            .as_deref()
870            .unwrap_or_default()
871            .contains("backend not configured"));
872
873        let backend = Arc::new(MockSchedulingBackend {
874            calls: Mutex::new(Vec::new()),
875        });
876        let backend_trait: Arc<dyn SchedulingBackend> = backend.clone();
877        let mut configured = ActionDispatcher::new(disabled).with_scheduling_backend(backend_trait);
878        configured.set_namespace("work");
879        let result = configured
880            .dispatch(&Action::ScheduleTask {
881                description: "ship release".to_string(),
882                cron: Some("0 10 * * 1".to_string()),
883            })
884            .await;
885        assert!(result.success);
886        let calls = backend.calls.lock().expect("calls lock");
887        assert_eq!(calls.len(), 1);
888        assert_eq!(calls[0].2, "work");
889    }
890
891    #[tokio::test]
892    async fn test_send_message_backend_matrix() {
893        let mut disabled = ActionConfig::default();
894        disabled.enable_channel_send = false;
895        let dispatcher = ActionDispatcher::new(disabled.clone());
896        let result = dispatcher
897            .dispatch(&Action::SendMessage {
898                channel: "ops".to_string(),
899                recipient: "alice".to_string(),
900                content: "deploy now".to_string(),
901            })
902            .await;
903        assert!(!result.success);
904        assert!(result
905            .error
906            .as_deref()
907            .unwrap_or_default()
908            .contains("disabled by config"));
909
910        disabled.enable_channel_send = true;
911        let unconfigured = ActionDispatcher::new(disabled.clone());
912        let result = unconfigured
913            .dispatch(&Action::SendMessage {
914                channel: "ops".to_string(),
915                recipient: "alice".to_string(),
916                content: "deploy now".to_string(),
917            })
918            .await;
919        assert!(!result.success);
920        assert!(result
921            .error
922            .as_deref()
923            .unwrap_or_default()
924            .contains("backend not configured"));
925
926        let backend = Arc::new(MockMessageBackend {
927            calls: Mutex::new(Vec::new()),
928        });
929        let backend_trait: Arc<dyn MessageBackend> = backend.clone();
930        let mut configured = ActionDispatcher::new(disabled).with_message_backend(backend_trait);
931        configured.set_namespace("project-x");
932        let result = configured
933            .dispatch(&Action::SendMessage {
934                channel: "ops".to_string(),
935                recipient: "alice".to_string(),
936                content: "deploy now".to_string(),
937            })
938            .await;
939        assert!(result.success);
940        let calls = backend.calls.lock().expect("calls lock");
941        assert_eq!(calls.len(), 1);
942        assert_eq!(calls[0].3, "project-x");
943    }
944}