Skip to main content

zeph_tools/
composite.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Composite executor that chains two [`ToolExecutor`] implementations.
5
6use crate::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput};
7use crate::registry::ToolDef;
8
9/// Chains two [`ToolExecutor`] implementations with first-match-wins dispatch.
10///
11/// For each method, `first` is tried first. If it returns `Ok(None)` (i.e. it does not
12/// handle the input), `second` is tried. If `first` returns an `Err`, the error propagates
13/// immediately without consulting `second`.
14///
15/// Use this to compose a chain of specialized executors at startup instead of a dynamic
16/// `Vec<Box<dyn ...>>`. Nest multiple `CompositeExecutor`s to handle more than two backends.
17///
18/// Tool definitions from both executors are merged, with `first` taking precedence when
19/// both define a tool with the same ID.
20///
21/// # Example
22///
23/// ```rust
24/// use zeph_tools::{
25///     CompositeExecutor, ShellExecutor, WebScrapeExecutor, ShellConfig, ScrapeConfig,
26/// };
27///
28/// let shell = ShellExecutor::new(&ShellConfig::default());
29/// let scrape = WebScrapeExecutor::new(&ScrapeConfig::default());
30/// let executor = CompositeExecutor::new(shell, scrape);
31/// // executor handles both bash blocks and scrape/fetch tool calls.
32/// ```
33#[derive(Debug)]
34pub struct CompositeExecutor<A: ToolExecutor, B: ToolExecutor> {
35    first: A,
36    second: B,
37}
38
39impl<A: ToolExecutor, B: ToolExecutor> CompositeExecutor<A, B> {
40    /// Create a new `CompositeExecutor` wrapping `first` and `second`.
41    #[must_use]
42    pub fn new(first: A, second: B) -> Self {
43        Self { first, second }
44    }
45}
46
47impl<A: ToolExecutor, B: ToolExecutor> ToolExecutor for CompositeExecutor<A, B> {
48    async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
49        if let Some(output) = self.first.execute(response).await? {
50            return Ok(Some(output));
51        }
52        self.second.execute(response).await
53    }
54
55    async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
56        if let Some(output) = self.first.execute_confirmed(response).await? {
57            return Ok(Some(output));
58        }
59        self.second.execute_confirmed(response).await
60    }
61
62    fn tool_definitions(&self) -> Vec<ToolDef> {
63        let mut defs = self.first.tool_definitions();
64        let seen: std::collections::HashSet<String> =
65            defs.iter().map(|d| d.id.to_string()).collect();
66        for def in self.second.tool_definitions() {
67            if !seen.contains(def.id.as_ref()) {
68                defs.push(def);
69            }
70        }
71        defs
72    }
73
74    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
75        if let Some(output) = self.first.execute_tool_call(call).await? {
76            return Ok(Some(output));
77        }
78        self.second.execute_tool_call(call).await
79    }
80
81    async fn execute_tool_call_confirmed(
82        &self,
83        call: &ToolCall,
84    ) -> Result<Option<ToolOutput>, ToolError> {
85        if let Some(output) = self.first.execute_tool_call_confirmed(call).await? {
86            return Ok(Some(output));
87        }
88        self.second.execute_tool_call_confirmed(call).await
89    }
90
91    fn is_tool_retryable(&self, tool_id: &str) -> bool {
92        self.first.is_tool_retryable(tool_id) || self.second.is_tool_retryable(tool_id)
93    }
94
95    fn is_tool_speculatable(&self, tool_id: &str) -> bool {
96        self.first.is_tool_speculatable(tool_id) || self.second.is_tool_speculatable(tool_id)
97    }
98
99    /// Return `true` when either inner executor requires confirmation for `call`.
100    ///
101    /// Mirrors the OR-forwarding used by [`Self::is_tool_retryable`] and
102    /// [`Self::is_tool_speculatable`] — without this override the base
103    /// [`ToolExecutor::requires_confirmation`] default (`false`) silently bypasses
104    /// confirmation gating for any executor composed under `CompositeExecutor`. See #5900.
105    fn requires_confirmation(&self, call: &ToolCall) -> bool {
106        self.first.requires_confirmation(call) || self.second.requires_confirmation(call)
107    }
108
109    /// Forward the active skill's env injection to BOTH inner executors.
110    ///
111    /// The base [`ToolExecutor::set_skill_env`] is a no-op, so without this override the
112    /// composition tree built in `agent_setup` silently swallows env injection — the
113    /// underlying `ShellExecutor` never sees `GITHUB_TOKEN` etc. Each layer ignores the call
114    /// if it does not own a `skill_env` slot; layers that do (e.g. `ShellExecutor`) update
115    /// their state. See #3869.
116    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
117        self.first.set_skill_env(env.clone());
118        self.second.set_skill_env(env);
119    }
120
121    /// Forward the active skill's trust level to BOTH inner executors.
122    ///
123    /// Mirrors [`Self::set_skill_env`]: without this override, `TrustGateExecutor` never
124    /// observes a non-`Trusted` level when composed under `CompositeExecutor`, leaving
125    /// quarantine enforcement effectively bypassed. See #3869.
126    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
127        self.first.set_effective_trust(level);
128        self.second.set_effective_trust(level);
129    }
130
131    /// Delegate undo to the first inner executor that supports checkpoints.
132    fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
133        let result = self.first.checkpoint_undo(n);
134        if result.supported {
135            return result;
136        }
137        self.second.checkpoint_undo(n)
138    }
139
140    /// Delegate redo to the first inner executor that supports checkpoints.
141    fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
142        let result = self.first.checkpoint_redo();
143        if result.supported {
144            return result;
145        }
146        self.second.checkpoint_redo()
147    }
148
149    /// Delegate list to the first inner executor that supports checkpoints.
150    fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
151        let result = self.first.checkpoint_list();
152        if result.supported {
153            return result;
154        }
155        self.second.checkpoint_list()
156    }
157}
158
159/// Wraps `Option<T>` so an executor that may fail to construct (disabled by config, or a
160/// backend that fails to initialize — e.g. [`WebSearchExecutor::new`](crate::search::WebSearchExecutor::new)
161/// returning `None`) can still occupy a fixed slot in a static [`CompositeExecutor`] chain.
162///
163/// When `None`, every method behaves as "not handled": empty tool definitions, `Ok(None)`
164/// from the execute paths, unsupported checkpoints, not retryable/speculatable/confirmed.
165/// This lets `build_base_executor_chain`'s concrete nested type stay fixed regardless of
166/// whether the wrapped executor was actually constructed at wiring time.
167#[derive(Debug)]
168pub struct OptionalExecutor<T: ToolExecutor>(pub Option<T>);
169
170impl<T: ToolExecutor> ToolExecutor for OptionalExecutor<T> {
171    async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
172        match &self.0 {
173            Some(inner) => inner.execute(response).await,
174            None => Ok(None),
175        }
176    }
177
178    async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
179        match &self.0 {
180            Some(inner) => inner.execute_confirmed(response).await,
181            None => Ok(None),
182        }
183    }
184
185    fn tool_definitions(&self) -> Vec<ToolDef> {
186        self.0
187            .as_ref()
188            .map(ToolExecutor::tool_definitions)
189            .unwrap_or_default()
190    }
191
192    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
193        match &self.0 {
194            Some(inner) => inner.execute_tool_call(call).await,
195            None => Ok(None),
196        }
197    }
198
199    async fn execute_tool_call_confirmed(
200        &self,
201        call: &ToolCall,
202    ) -> Result<Option<ToolOutput>, ToolError> {
203        match &self.0 {
204            Some(inner) => inner.execute_tool_call_confirmed(call).await,
205            None => Ok(None),
206        }
207    }
208
209    fn is_tool_retryable(&self, tool_id: &str) -> bool {
210        self.0
211            .as_ref()
212            .is_some_and(|inner| inner.is_tool_retryable(tool_id))
213    }
214
215    fn is_tool_speculatable(&self, tool_id: &str) -> bool {
216        self.0
217            .as_ref()
218            .is_some_and(|inner| inner.is_tool_speculatable(tool_id))
219    }
220
221    fn requires_confirmation(&self, call: &ToolCall) -> bool {
222        self.0
223            .as_ref()
224            .is_some_and(|inner| inner.requires_confirmation(call))
225    }
226
227    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
228        if let Some(inner) = &self.0 {
229            inner.set_skill_env(env);
230        }
231    }
232
233    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
234        if let Some(inner) = &self.0 {
235            inner.set_effective_trust(level);
236        }
237    }
238
239    fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
240        self.0.as_ref().map_or_else(
241            crate::executor::CheckpointActionResult::unsupported,
242            |inner| inner.checkpoint_undo(n),
243        )
244    }
245
246    fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
247        self.0.as_ref().map_or_else(
248            crate::executor::CheckpointActionResult::unsupported,
249            ToolExecutor::checkpoint_redo,
250        )
251    }
252
253    fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
254        self.0
255            .as_ref()
256            .map(ToolExecutor::checkpoint_list)
257            .unwrap_or_default()
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::ToolName;
265    use std::assert_matches;
266
267    #[derive(Debug)]
268    struct MatchingExecutor;
269    impl ToolExecutor for MatchingExecutor {
270        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
271            Ok(Some(ToolOutput {
272                tool_name: ToolName::new("test"),
273                summary: "matched".to_owned(),
274                blocks_executed: 1,
275                filter_stats: None,
276                diff: None,
277                streamed: false,
278                terminal_id: None,
279                locations: None,
280                raw_response: None,
281                claim_source: None,
282                ..Default::default()
283            }))
284        }
285
286        crate::tool_executor_no_inner_defaults!();
287    }
288
289    #[derive(Debug)]
290    struct NoMatchExecutor;
291    impl ToolExecutor for NoMatchExecutor {
292        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
293            Ok(None)
294        }
295
296        crate::tool_executor_no_inner_defaults!();
297    }
298
299    #[derive(Debug)]
300    struct ErrorExecutor;
301    impl ToolExecutor for ErrorExecutor {
302        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
303            Err(ToolError::Blocked {
304                command: "test".to_owned(),
305            })
306        }
307
308        crate::tool_executor_no_inner_defaults!();
309    }
310
311    #[derive(Debug)]
312    struct SecondExecutor;
313    impl ToolExecutor for SecondExecutor {
314        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
315            Ok(Some(ToolOutput {
316                tool_name: ToolName::new("test"),
317                summary: "second".to_owned(),
318                blocks_executed: 1,
319                filter_stats: None,
320                diff: None,
321                streamed: false,
322                terminal_id: None,
323                locations: None,
324                raw_response: None,
325                claim_source: None,
326                ..Default::default()
327            }))
328        }
329
330        crate::tool_executor_no_inner_defaults!();
331    }
332
333    #[tokio::test]
334    async fn first_matches_returns_first() {
335        let composite = CompositeExecutor::new(MatchingExecutor, SecondExecutor);
336        let result = composite.execute("anything").await.unwrap();
337        assert_eq!(result.unwrap().summary, "matched");
338    }
339
340    #[tokio::test]
341    async fn first_none_falls_through_to_second() {
342        let composite = CompositeExecutor::new(NoMatchExecutor, SecondExecutor);
343        let result = composite.execute("anything").await.unwrap();
344        assert_eq!(result.unwrap().summary, "second");
345    }
346
347    #[tokio::test]
348    async fn both_none_returns_none() {
349        let composite = CompositeExecutor::new(NoMatchExecutor, NoMatchExecutor);
350        let result = composite.execute("anything").await.unwrap();
351        assert!(result.is_none());
352    }
353
354    #[tokio::test]
355    async fn first_error_propagates_without_trying_second() {
356        let composite = CompositeExecutor::new(ErrorExecutor, SecondExecutor);
357        let result = composite.execute("anything").await;
358        assert_matches!(result, Err(ToolError::Blocked { .. }));
359    }
360
361    #[tokio::test]
362    async fn second_error_propagates_when_first_none() {
363        let composite = CompositeExecutor::new(NoMatchExecutor, ErrorExecutor);
364        let result = composite.execute("anything").await;
365        assert_matches!(result, Err(ToolError::Blocked { .. }));
366    }
367
368    #[tokio::test]
369    async fn execute_confirmed_first_matches() {
370        let composite = CompositeExecutor::new(MatchingExecutor, SecondExecutor);
371        let result = composite.execute_confirmed("anything").await.unwrap();
372        assert_eq!(result.unwrap().summary, "matched");
373    }
374
375    #[tokio::test]
376    async fn execute_confirmed_falls_through() {
377        let composite = CompositeExecutor::new(NoMatchExecutor, SecondExecutor);
378        let result = composite.execute_confirmed("anything").await.unwrap();
379        assert_eq!(result.unwrap().summary, "second");
380    }
381
382    #[test]
383    fn composite_debug() {
384        let composite = CompositeExecutor::new(MatchingExecutor, SecondExecutor);
385        let debug = format!("{composite:?}");
386        assert!(debug.contains("CompositeExecutor"));
387    }
388
389    /// Regression test for #5938: `execute_tool_call_confirmed` must reach the inner
390    /// executor's own `execute_tool_call_confirmed` override, not fall through to the
391    /// trait default (which re-dispatches via `execute_tool_call` and re-runs checks
392    /// the confirmed path is meant to bypass).
393    #[derive(Debug, Default)]
394    struct ConfirmedSpy {
395        confirmed_called: std::sync::Mutex<bool>,
396        unconfirmed_called: std::sync::Mutex<bool>,
397    }
398    impl ToolExecutor for ConfirmedSpy {
399        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
400            Ok(None)
401        }
402        async fn execute_tool_call(
403            &self,
404            call: &ToolCall,
405        ) -> Result<Option<ToolOutput>, ToolError> {
406            *self.unconfirmed_called.lock().unwrap() = true;
407            Ok(Some(ToolOutput {
408                tool_name: call.tool_id.clone(),
409                summary: "unconfirmed".to_owned(),
410                blocks_executed: 1,
411                filter_stats: None,
412                diff: None,
413                streamed: false,
414                terminal_id: None,
415                locations: None,
416                raw_response: None,
417                claim_source: None,
418                ..Default::default()
419            }))
420        }
421        async fn execute_tool_call_confirmed(
422            &self,
423            call: &ToolCall,
424        ) -> Result<Option<ToolOutput>, ToolError> {
425            *self.confirmed_called.lock().unwrap() = true;
426            Ok(Some(ToolOutput {
427                tool_name: call.tool_id.clone(),
428                summary: "confirmed".to_owned(),
429                blocks_executed: 1,
430                filter_stats: None,
431                diff: None,
432                streamed: false,
433                terminal_id: None,
434                locations: None,
435                raw_response: None,
436                claim_source: None,
437                ..Default::default()
438            }))
439        }
440
441        fn checkpoint_undo(&self, _n: usize) -> crate::CheckpointActionResult {
442            crate::CheckpointActionResult::unsupported()
443        }
444        fn checkpoint_redo(&self) -> crate::CheckpointActionResult {
445            crate::CheckpointActionResult::unsupported()
446        }
447        fn checkpoint_list(&self) -> crate::CheckpointListResult {
448            crate::CheckpointListResult::default()
449        }
450        fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
451            false
452        }
453        fn requires_confirmation(&self, _call: &ToolCall) -> bool {
454            false
455        }
456    }
457
458    #[tokio::test]
459    async fn execute_tool_call_confirmed_bypasses_unconfirmed_dispatch() {
460        let spy = ConfirmedSpy::default();
461        let composite = CompositeExecutor::new(spy, NoMatchExecutor);
462        let call = ToolCall {
463            tool_id: ToolName::new("read"),
464            params: serde_json::Map::new(),
465            caller_id: None,
466            context: None,
467            tool_call_id: String::new(),
468            skill_name: None,
469        };
470        let result = composite
471            .execute_tool_call_confirmed(&call)
472            .await
473            .unwrap()
474            .unwrap();
475        assert_eq!(result.summary, "confirmed");
476        assert!(
477            *composite.first.confirmed_called.lock().unwrap(),
478            "execute_tool_call_confirmed must reach the inner executor's confirmed override"
479        );
480        assert!(
481            !*composite.first.unconfirmed_called.lock().unwrap(),
482            "execute_tool_call_confirmed must NOT re-dispatch through execute_tool_call"
483        );
484    }
485
486    #[tokio::test]
487    async fn execute_tool_call_confirmed_falls_through_to_second() {
488        let composite = CompositeExecutor::new(NoMatchExecutor, ConfirmedSpy::default());
489        let call = ToolCall {
490            tool_id: ToolName::new("read"),
491            params: serde_json::Map::new(),
492            caller_id: None,
493            context: None,
494            tool_call_id: String::new(),
495            skill_name: None,
496        };
497        let result = composite
498            .execute_tool_call_confirmed(&call)
499            .await
500            .unwrap()
501            .unwrap();
502        assert_eq!(result.summary, "confirmed");
503        assert!(*composite.second.confirmed_called.lock().unwrap());
504    }
505
506    #[derive(Debug)]
507    struct FileToolExecutor;
508    impl ToolExecutor for FileToolExecutor {
509        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
510            Ok(None)
511        }
512        async fn execute_tool_call(
513            &self,
514            call: &ToolCall,
515        ) -> Result<Option<ToolOutput>, ToolError> {
516            if call.tool_id == "read" || call.tool_id == "write" {
517                Ok(Some(ToolOutput {
518                    tool_name: call.tool_id.clone(),
519                    summary: "file_handler".to_owned(),
520                    blocks_executed: 1,
521                    filter_stats: None,
522                    diff: None,
523                    streamed: false,
524                    terminal_id: None,
525                    locations: None,
526                    raw_response: None,
527                    claim_source: None,
528                    ..Default::default()
529                }))
530            } else {
531                Ok(None)
532            }
533        }
534
535        crate::tool_executor_no_inner_defaults!();
536    }
537
538    #[derive(Debug)]
539    struct ShellToolExecutor;
540    impl ToolExecutor for ShellToolExecutor {
541        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
542            Ok(None)
543        }
544        async fn execute_tool_call(
545            &self,
546            call: &ToolCall,
547        ) -> Result<Option<ToolOutput>, ToolError> {
548            if call.tool_id == "bash" {
549                Ok(Some(ToolOutput {
550                    tool_name: ToolName::new("bash"),
551                    summary: "shell_handler".to_owned(),
552                    blocks_executed: 1,
553                    filter_stats: None,
554                    diff: None,
555                    streamed: false,
556                    terminal_id: None,
557                    locations: None,
558                    raw_response: None,
559                    claim_source: None,
560                    ..Default::default()
561                }))
562            } else {
563                Ok(None)
564            }
565        }
566
567        crate::tool_executor_no_inner_defaults!();
568    }
569
570    #[tokio::test]
571    async fn tool_call_routes_to_file_executor() {
572        let composite = CompositeExecutor::new(FileToolExecutor, ShellToolExecutor);
573        let call = ToolCall {
574            tool_id: ToolName::new("read"),
575            params: serde_json::Map::new(),
576            caller_id: None,
577            context: None,
578
579            tool_call_id: String::new(),
580            skill_name: None,
581        };
582        let result = composite.execute_tool_call(&call).await.unwrap().unwrap();
583        assert_eq!(result.summary, "file_handler");
584    }
585
586    #[tokio::test]
587    async fn tool_call_routes_to_shell_executor() {
588        let composite = CompositeExecutor::new(FileToolExecutor, ShellToolExecutor);
589        let call = ToolCall {
590            tool_id: ToolName::new("bash"),
591            params: serde_json::Map::new(),
592            caller_id: None,
593            context: None,
594
595            tool_call_id: String::new(),
596            skill_name: None,
597        };
598        let result = composite.execute_tool_call(&call).await.unwrap().unwrap();
599        assert_eq!(result.summary, "shell_handler");
600    }
601
602    #[tokio::test]
603    async fn tool_call_unhandled_returns_none() {
604        let composite = CompositeExecutor::new(FileToolExecutor, ShellToolExecutor);
605        let call = ToolCall {
606            tool_id: ToolName::new("unknown"),
607            params: serde_json::Map::new(),
608            caller_id: None,
609            context: None,
610
611            tool_call_id: String::new(),
612            skill_name: None,
613        };
614        let result = composite.execute_tool_call(&call).await.unwrap();
615        assert!(result.is_none());
616    }
617
618    /// Regression test for #3869: state-mutating setters MUST reach both inner executors,
619    /// even across nested compositions. Prior to the fix, `set_skill_env` and
620    /// `set_effective_trust` fell through to the default no-op `ToolExecutor` impls and
621    /// were silently dropped at the `CompositeExecutor` boundary — breaking skill secret
622    /// env injection (`x-requires-secrets`) and quarantine trust enforcement.
623    mod state_forwarding {
624        use super::*;
625        use crate::SkillTrustLevel;
626        use std::sync::Mutex;
627
628        #[derive(Debug, Default)]
629        struct SpyExecutor {
630            last_env: Mutex<Option<std::collections::HashMap<String, String>>>,
631            last_trust: Mutex<Option<SkillTrustLevel>>,
632        }
633        impl ToolExecutor for SpyExecutor {
634            async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
635                Ok(None)
636            }
637            fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
638                *self.last_env.lock().unwrap() = env;
639            }
640            fn set_effective_trust(&self, level: SkillTrustLevel) {
641                *self.last_trust.lock().unwrap() = Some(level);
642            }
643
644            crate::tool_executor_no_inner_defaults!();
645        }
646
647        /// Regression test for #5900: `CompositeExecutor::requires_confirmation` must
648        /// OR-forward to both inner executors, mirroring `is_tool_retryable` and
649        /// `is_tool_speculatable`. Before the fix it fell through to the base
650        /// `ToolExecutor::requires_confirmation` default (`false`), silently dropping
651        /// any confirmation requirement declared by either leaf.
652        #[derive(Debug)]
653        struct FixedConfirmation(bool);
654        impl ToolExecutor for FixedConfirmation {
655            async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
656                Ok(None)
657            }
658            fn requires_confirmation(&self, _call: &ToolCall) -> bool {
659                self.0
660            }
661
662            async fn execute_tool_call_confirmed(
663                &self,
664                call: &ToolCall,
665            ) -> Result<Option<ToolOutput>, ToolError> {
666                self.execute_tool_call(call).await
667            }
668            fn checkpoint_undo(&self, _n: usize) -> crate::CheckpointActionResult {
669                crate::CheckpointActionResult::unsupported()
670            }
671            fn checkpoint_redo(&self) -> crate::CheckpointActionResult {
672                crate::CheckpointActionResult::unsupported()
673            }
674            fn checkpoint_list(&self) -> crate::CheckpointListResult {
675                crate::CheckpointListResult::default()
676            }
677            fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
678                false
679            }
680        }
681
682        fn confirmation_call() -> ToolCall {
683            ToolCall {
684                tool_id: ToolName::new("shell"),
685                params: serde_json::Map::new(),
686                caller_id: None,
687                context: None,
688                tool_call_id: String::new(),
689                skill_name: None,
690            }
691        }
692
693        #[test]
694        fn requires_confirmation_false_when_both_leaves_false() {
695            let composite =
696                CompositeExecutor::new(FixedConfirmation(false), FixedConfirmation(false));
697            assert!(!composite.requires_confirmation(&confirmation_call()));
698        }
699
700        #[test]
701        fn requires_confirmation_true_when_first_leaf_true() {
702            let composite =
703                CompositeExecutor::new(FixedConfirmation(true), FixedConfirmation(false));
704            assert!(composite.requires_confirmation(&confirmation_call()));
705        }
706
707        #[test]
708        fn requires_confirmation_true_when_second_leaf_true() {
709            let composite =
710                CompositeExecutor::new(FixedConfirmation(false), FixedConfirmation(true));
711            assert!(composite.requires_confirmation(&confirmation_call()));
712        }
713
714        #[test]
715        fn requires_confirmation_or_forwards_across_nested_composition() {
716            let nested = CompositeExecutor::new(FixedConfirmation(false), FixedConfirmation(true));
717            let outer = CompositeExecutor::new(nested, FixedConfirmation(false));
718            assert!(
719                outer.requires_confirmation(&confirmation_call()),
720                "a confirmation requirement on a nested leaf must reach the outer composite"
721            );
722        }
723
724        #[test]
725        fn set_skill_env_reaches_both_inner_executors_in_nested_composition() {
726            // Mirrors the production wiring shape: a tree of CompositeExecutor with
727            // multiple leaves. All leaves must observe the call.
728            let leaf_a = SpyExecutor::default();
729            let leaf_b = SpyExecutor::default();
730            let leaf_c = SpyExecutor::default();
731            let nested = CompositeExecutor::new(leaf_a, leaf_b);
732            let outer = CompositeExecutor::new(nested, leaf_c);
733
734            let mut env = std::collections::HashMap::new();
735            env.insert("GITHUB_TOKEN".to_owned(), "tok".to_owned());
736            outer.set_skill_env(Some(env.clone()));
737
738            // first.first (leaf_a)
739            assert_eq!(
740                outer.first.first.last_env.lock().unwrap().as_ref(),
741                Some(&env)
742            );
743            // first.second (leaf_b)
744            assert_eq!(
745                outer.first.second.last_env.lock().unwrap().as_ref(),
746                Some(&env)
747            );
748            // second (leaf_c)
749            assert_eq!(outer.second.last_env.lock().unwrap().as_ref(), Some(&env));
750        }
751
752        #[test]
753        fn set_effective_trust_reaches_both_inner_executors_in_nested_composition() {
754            let leaf_a = SpyExecutor::default();
755            let leaf_b = SpyExecutor::default();
756            let outer = CompositeExecutor::new(leaf_a, leaf_b);
757
758            outer.set_effective_trust(SkillTrustLevel::Quarantined);
759
760            assert_eq!(
761                *outer.first.last_trust.lock().unwrap(),
762                Some(SkillTrustLevel::Quarantined)
763            );
764            assert_eq!(
765                *outer.second.last_trust.lock().unwrap(),
766                Some(SkillTrustLevel::Quarantined)
767            );
768        }
769    }
770
771    mod optional_executor {
772        use super::*;
773
774        #[tokio::test]
775        async fn none_execute_returns_ok_none() {
776            let wrapped: OptionalExecutor<MatchingExecutor> = OptionalExecutor(None);
777            assert!(wrapped.execute("anything").await.unwrap().is_none());
778        }
779
780        #[tokio::test]
781        async fn some_execute_delegates_to_inner() {
782            let wrapped = OptionalExecutor(Some(MatchingExecutor));
783            let result = wrapped.execute("anything").await.unwrap();
784            assert_eq!(result.unwrap().summary, "matched");
785        }
786
787        #[tokio::test]
788        async fn none_execute_tool_call_returns_ok_none() {
789            let wrapped: OptionalExecutor<FileToolExecutor> = OptionalExecutor(None);
790            let call = ToolCall {
791                tool_id: ToolName::new("read"),
792                params: serde_json::Map::new(),
793                caller_id: None,
794                context: None,
795                tool_call_id: String::new(),
796                skill_name: None,
797            };
798            assert!(wrapped.execute_tool_call(&call).await.unwrap().is_none());
799        }
800
801        #[tokio::test]
802        async fn some_execute_tool_call_delegates_to_inner() {
803            let wrapped = OptionalExecutor(Some(FileToolExecutor));
804            let call = ToolCall {
805                tool_id: ToolName::new("read"),
806                params: serde_json::Map::new(),
807                caller_id: None,
808                context: None,
809                tool_call_id: String::new(),
810                skill_name: None,
811            };
812            let result = wrapped.execute_tool_call(&call).await.unwrap();
813            assert_eq!(result.unwrap().summary, "file_handler");
814        }
815
816        #[test]
817        fn none_tool_definitions_is_empty() {
818            let wrapped: OptionalExecutor<MatchingExecutor> = OptionalExecutor(None);
819            assert!(wrapped.tool_definitions().is_empty());
820        }
821
822        #[test]
823        fn none_checkpoint_undo_unsupported() {
824            let wrapped: OptionalExecutor<MatchingExecutor> = OptionalExecutor(None);
825            assert!(!wrapped.checkpoint_undo(1).supported);
826            assert!(!wrapped.checkpoint_redo().supported);
827            assert!(!wrapped.checkpoint_list().supported);
828        }
829
830        #[test]
831        fn none_not_retryable_or_speculatable() {
832            let wrapped: OptionalExecutor<MatchingExecutor> = OptionalExecutor(None);
833            assert!(!wrapped.is_tool_retryable("anything"));
834            assert!(!wrapped.is_tool_speculatable("anything"));
835        }
836
837        #[test]
838        fn none_requires_confirmation_false() {
839            let wrapped: OptionalExecutor<MatchingExecutor> = OptionalExecutor(None);
840            let call = ToolCall {
841                tool_id: ToolName::new("anything"),
842                params: serde_json::Map::new(),
843                caller_id: None,
844                context: None,
845                tool_call_id: String::new(),
846                skill_name: None,
847            };
848            assert!(!wrapped.requires_confirmation(&call));
849        }
850    }
851}