Skip to main content

funera_core/
env.rs

1use std::sync::Arc;
2
3use async_openai::config::OpenAIConfig;
4use parking_lot::Mutex;
5
6#[cfg(feature = "skill")]
7use crate::re_act::skills::{Skill, SkillRegistry};
8#[cfg(feature = "tool")]
9use crate::re_act::tool::{Tool, ToolRegistry};
10#[cfg(feature = "sandbox")]
11use crate::security::sandbox::SandboxPolicy;
12#[cfg(feature = "tool")]
13use serde_json::Value as JsonValue;
14#[cfg(any(feature = "tool", feature = "skill"))]
15use tokio::sync::RwLock;
16use tokio::sync::watch::{self, error::RecvError};
17
18/// A teardown action that undoes one effect.
19///
20/// Returned by [`FuneraEnv::effect`] bodies and run in reverse registration
21/// order when [`FuneraEnv::dispose`] is called (LIFO recovery).
22pub type Disposer = Box<dyn FnOnce() + Send + 'static>;
23
24#[derive(Clone)]
25pub struct FuneraEnv {
26    #[cfg(feature = "tool")]
27    pub(crate) tool_registry: Arc<RwLock<ToolRegistry>>,
28    #[cfg(feature = "skill")]
29    pub(crate) skill_registry: Arc<RwLock<SkillRegistry>>,
30    llm_client: async_openai::Client<OpenAIConfig>,
31    model: String,
32    #[cfg(feature = "tool")]
33    tool_tx: watch::Sender<JsonValue>,
34    client_tx: watch::Sender<async_openai::Client<OpenAIConfig>>,
35    model_tx: watch::Sender<String>,
36    #[cfg(feature = "skill")]
37    skill_tx: watch::Sender<String>,
38    #[cfg(feature = "sandbox")]
39    sandbox_policy: SandboxPolicy,
40    /// Accumulator of registered reversible effects, drained in reverse (LIFO)
41    /// order by [`FuneraEnv::dispose`].
42    disposers: Arc<Mutex<Vec<Disposer>>>,
43}
44
45impl FuneraEnv {
46    pub fn new(
47        llm_client: async_openai::Client<OpenAIConfig>,
48        model: impl Into<String>,
49    ) -> (Self, FuneraEnvWatcher) {
50        let model = model.into();
51        let (client_tx, client_rx) = watch::channel(llm_client.clone());
52        let (model_tx, model_rx) = watch::channel(model.clone());
53
54        #[cfg(feature = "tool")]
55        let tool_registry = Arc::new(RwLock::new(ToolRegistry::new()));
56        #[cfg(feature = "tool")]
57        let (tool_tx, tool_rx) = watch::channel(JsonValue::Array(Vec::new()));
58
59        #[cfg(feature = "skill")]
60        let skill_registry = Arc::new(RwLock::new(SkillRegistry::new()));
61        #[cfg(feature = "skill")]
62        let (skill_tx, skill_rx) = watch::channel(String::new());
63
64        (
65            Self {
66                #[cfg(feature = "tool")]
67                tool_registry,
68                #[cfg(feature = "skill")]
69                skill_registry,
70                llm_client,
71                model,
72                #[cfg(feature = "tool")]
73                tool_tx,
74                client_tx,
75                model_tx,
76                #[cfg(feature = "skill")]
77                skill_tx,
78                #[cfg(feature = "sandbox")]
79                sandbox_policy: SandboxPolicy::default(),
80                disposers: Arc::new(Mutex::new(Vec::new())),
81            },
82            FuneraEnvWatcher {
83                #[cfg(feature = "tool")]
84                tool_rx,
85                client_rx,
86                model_rx,
87                #[cfg(feature = "skill")]
88                skill_rx,
89            },
90        )
91    }
92
93    /// Set a custom sandbox policy.
94    #[cfg(feature = "sandbox")]
95    pub fn with_sandbox_policy(mut self, policy: SandboxPolicy) -> Self {
96        self.sandbox_policy = policy;
97        self
98    }
99
100    /// The currently configured sandbox policy.
101    #[cfg(feature = "sandbox")]
102    pub fn sandbox_policy(&self) -> &SandboxPolicy {
103        &self.sandbox_policy
104    }
105
106    #[cfg(feature = "tool")]
107    pub fn with_tool_registry(self, tool_registry: ToolRegistry) -> Self {
108        let snapshot = tool_registry.available_tools_json();
109        let _ = self.tool_tx.send(snapshot);
110        Self {
111            tool_registry: Arc::new(RwLock::new(tool_registry)),
112            ..self
113        }
114    }
115
116    #[cfg(feature = "skill")]
117    pub fn with_skill_registry(self, skill_registry: SkillRegistry) -> Self {
118        let prompt = skill_registry.get_active_skills_prompt();
119        let _ = self.skill_tx.send(prompt);
120        Self {
121            skill_registry: Arc::new(RwLock::new(skill_registry)),
122            ..self
123        }
124    }
125
126    #[cfg(feature = "tool")]
127    pub(crate) async fn add_tool(&mut self, tool: Arc<dyn Tool>) {
128        let mut registry = self.tool_registry.write().await;
129        registry.add_tool(tool);
130        let _ = self.tool_tx.send(registry.available_tools_json());
131    }
132
133    #[cfg(feature = "tool")]
134    pub(crate) async fn remove_tool(&mut self, name: &str) {
135        let mut registry = self.tool_registry.write().await;
136        registry.remove_tool(name);
137        let _ = self.tool_tx.send(registry.available_tools_json());
138    }
139
140    /// Remove the tool only if the registered entry is the same `Arc` value.
141    ///
142    /// This is the safe inverse of [`add_tool`](Self::add_tool) for a
143    /// [`Disposer`]: a stale teardown cannot delete a replacement tool that
144    /// reuses the same name.
145    ///
146    /// Exercised by the reversible-effects tests; kept on the env so callers
147    /// inside the crate can pair registrations with their exact inverse.
148    #[cfg(feature = "tool")]
149    #[cfg_attr(not(test), allow(dead_code))]
150    pub(crate) async fn remove_tool_if_same(&mut self, name: &str, tool: &Arc<dyn Tool>) -> bool {
151        let mut registry = self.tool_registry.write().await;
152        let removed = registry.remove_tool_if_same(name, tool);
153        if removed {
154            let _ = self.tool_tx.send(registry.available_tools_json());
155        }
156        removed
157    }
158
159    #[cfg(feature = "tool")]
160    pub(crate) async fn set_tool_availability(&mut self, _name: &str, _available: bool) {
161        let registry = self.tool_registry.read().await;
162        let _ = self.tool_tx.send(registry.available_tools_json());
163    }
164
165    pub(crate) fn set_client(&mut self, client: async_openai::Client<OpenAIConfig>) {
166        self.llm_client = client.clone();
167        let _ = self.client_tx.send(client);
168    }
169
170    pub(crate) fn set_model(&mut self, model: impl Into<String>) {
171        let model = model.into();
172        self.model = model.clone();
173        let _ = self.model_tx.send(model);
174    }
175
176    #[cfg(feature = "skill")]
177    pub(crate) async fn add_skill(&mut self, skill: Skill) {
178        let mut registry = self.skill_registry.write().await;
179        registry.add(skill);
180        let _ = self.skill_tx.send(registry.get_active_skills_prompt());
181    }
182
183    #[cfg(feature = "skill")]
184    pub(crate) async fn remove_skill(&mut self, name: &str) {
185        let mut registry = self.skill_registry.write().await;
186        registry.remove(name);
187        let _ = self.skill_tx.send(registry.get_active_skills_prompt());
188    }
189
190    #[cfg(feature = "skill")]
191    pub(crate) async fn activate_skill(&mut self, name: &str) -> bool {
192        let mut registry = self.skill_registry.write().await;
193        let ok = registry.activate(name);
194        if ok {
195            let _ = self.skill_tx.send(registry.get_active_skills_prompt());
196        }
197        ok
198    }
199
200    #[cfg(feature = "skill")]
201    pub(crate) async fn deactivate_skill(&mut self, name: &str) -> bool {
202        let mut registry = self.skill_registry.write().await;
203        let ok = registry.deactivate(name);
204        if ok {
205            let _ = self.skill_tx.send(registry.get_active_skills_prompt());
206        }
207        ok
208    }
209
210    #[cfg(feature = "skill")]
211    pub(crate) fn skill_prompt_now(&self) -> String {
212        self.skill_tx.borrow().clone()
213    }
214
215    #[cfg(feature = "skill")]
216    pub(crate) fn set_skill_prompt(&mut self, prompt: String) {
217        let _ = self.skill_tx.send(prompt);
218    }
219
220    pub(crate) fn model(&self) -> &str {
221        &self.model
222    }
223
224    // ── Reversible effects (LIFO teardown) ─────────────────────
225
226    /// Register a reversible effect.
227    ///
228    /// `body` runs now (setup) and returns a [`Disposer`] that undoes it. The
229    /// disposer is pushed onto the env's accumulator and run in reverse (LIFO)
230    /// order by [`dispose`](Self::dispose) when the env is torn down, so later
231    /// registrations — which may depend on earlier ones — are undone first.
232    ///
233    /// ```rust,ignore
234    /// env.effect(|| {
235    ///     let resource = acquire();             // setup: the effect
236    ///     Box::new(move || release(resource))   // teardown: its inverse
237    /// });
238    /// ```
239    pub fn effect(&self, body: impl FnOnce() -> Disposer) {
240        let undo = body();
241        self.disposers.lock().push(undo);
242    }
243
244    /// Run every registered disposer in reverse (LIFO) order.
245    ///
246    /// This undoes each effect in the reverse of the order it was registered,
247    /// preventing memory / service leaks from registrations that were never
248    /// explicitly reverted. Disposal is idempotent (the accumulator is drained)
249    /// and panic-isolated: a panicking disposer is caught and logged while the
250    /// remaining disposers still run.
251    pub fn dispose(&self) {
252        let disposers = std::mem::take(&mut *self.disposers.lock());
253        for undo in disposers.into_iter().rev() {
254            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(undo));
255            if result.is_err() {
256                tracing::warn!("a disposer panicked during FuneraEnv::dispose; continuing");
257            }
258        }
259    }
260}
261
262#[derive(Debug, Clone)]
263pub struct FuneraEnvWatcher {
264    #[cfg(feature = "tool")]
265    tool_rx: watch::Receiver<JsonValue>,
266    client_rx: watch::Receiver<async_openai::Client<OpenAIConfig>>,
267    model_rx: watch::Receiver<String>,
268    #[cfg(feature = "skill")]
269    skill_rx: watch::Receiver<String>,
270}
271
272impl FuneraEnvWatcher {
273    #[cfg(feature = "tool")]
274    pub fn watch_tool(&mut self) -> JsonValue {
275        self.tool_rx.borrow_and_update().clone()
276    }
277
278    pub fn watch_client(&mut self) -> async_openai::Client<OpenAIConfig> {
279        self.client_rx.borrow_and_update().clone()
280    }
281
282    pub fn watch_model(&mut self) -> String {
283        self.model_rx.borrow_and_update().clone()
284    }
285
286    #[cfg(feature = "skill")]
287    pub fn watch_skill(&mut self) -> String {
288        self.skill_rx.borrow_and_update().clone()
289    }
290
291    #[cfg(feature = "tool")]
292    pub fn has_tool_changed(&self) -> bool {
293        self.tool_rx.has_changed().unwrap_or(false)
294    }
295
296    pub fn has_client_changed(&self) -> bool {
297        self.client_rx.has_changed().unwrap_or(false)
298    }
299
300    pub fn has_model_changed(&self) -> bool {
301        self.model_rx.has_changed().unwrap_or(false)
302    }
303
304    #[cfg(feature = "skill")]
305    pub fn has_skill_changed(&self) -> bool {
306        self.skill_rx.has_changed().unwrap_or(false)
307    }
308
309    pub fn use_client(&mut self) -> async_openai::Client<OpenAIConfig> {
310        self.watch_client()
311    }
312
313    #[cfg(feature = "tool")]
314    pub async fn tool_changed(&mut self) -> Result<(), RecvError> {
315        self.tool_rx.changed().await
316    }
317
318    pub async fn client_changed(&mut self) -> Result<(), RecvError> {
319        self.client_rx.changed().await
320    }
321
322    pub async fn model_changed(&mut self) -> Result<(), RecvError> {
323        self.model_rx.changed().await
324    }
325
326    #[cfg(feature = "skill")]
327    pub async fn skill_changed(&mut self) -> Result<(), RecvError> {
328        self.skill_rx.changed().await
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use std::sync::atomic::{AtomicUsize, Ordering};
336
337    fn test_env() -> (FuneraEnv, FuneraEnvWatcher) {
338        FuneraEnv::new(async_openai::Client::new(), "test-model")
339    }
340
341    // ── reversible effects (LIFO teardown) ─────────────────────
342
343    #[test]
344    fn effect_runs_in_lifo_order() {
345        let (env, _watcher) = test_env();
346        let log: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
347
348        let l1 = Arc::clone(&log);
349        env.effect(|| Box::new(move || l1.lock().push("first")));
350        let l2 = Arc::clone(&log);
351        env.effect(|| Box::new(move || l2.lock().push("second")));
352
353        env.dispose();
354        assert_eq!(*log.lock(), vec!["second", "first"]);
355    }
356
357    #[test]
358    fn dispose_idempotent() {
359        let (env, _watcher) = test_env();
360        let ran = Arc::new(AtomicUsize::new(0));
361        let r = Arc::clone(&ran);
362        env.effect(|| {
363            Box::new(move || {
364                r.fetch_add(1, Ordering::Relaxed);
365            })
366        });
367        env.dispose();
368        // Second dispose is a no-op: the accumulator was drained.
369        env.dispose();
370        assert_eq!(ran.load(Ordering::Relaxed), 1);
371    }
372
373    #[test]
374    fn dispose_isolates_panicking_disposer() {
375        let (env, _watcher) = test_env();
376        let log: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
377
378        let l1 = Arc::clone(&log);
379        env.effect(|| Box::new(move || l1.lock().push("first")));
380        env.effect(|| Box::new(move || panic!("boom")));
381        let l3 = Arc::clone(&log);
382        env.effect(|| Box::new(move || l3.lock().push("third")));
383
384        // Must not propagate the panic; remaining disposers still run (LIFO).
385        env.dispose();
386        assert_eq!(*log.lock(), vec!["third", "first"]);
387    }
388
389    // ── model / client hot-reload ──────────────────────────────
390
391    #[test]
392    fn set_model_updates_model_and_watcher() {
393        let (mut env, mut watcher) = test_env();
394        assert_eq!(env.model(), "test-model");
395        env.set_model("m2");
396        assert_eq!(env.model(), "m2");
397        assert_eq!(watcher.watch_model(), "m2");
398    }
399
400    #[test]
401    fn has_model_changed_reflects_changes() {
402        let (mut env, mut watcher) = test_env();
403        assert!(!watcher.has_model_changed());
404        env.set_model("m2");
405        assert!(watcher.has_model_changed());
406        let _ = watcher.watch_model();
407        assert!(!watcher.has_model_changed());
408    }
409
410    #[test]
411    fn set_client_marks_client_changed() {
412        let (mut env, mut watcher) = test_env();
413        assert!(!watcher.has_client_changed());
414        env.set_client(async_openai::Client::new());
415        assert!(watcher.has_client_changed());
416        let _ = watcher.watch_client();
417        assert!(!watcher.has_client_changed());
418    }
419
420    #[tokio::test]
421    async fn model_changed_blocks_then_resolves() {
422        let (env, mut watcher) = test_env();
423
424        // Blocks while no change is pending.
425        let early = tokio::time::timeout(
426            std::time::Duration::from_millis(50),
427            watcher.model_changed(),
428        )
429        .await;
430        assert!(
431            early.is_err(),
432            "model_changed should block with no pending change"
433        );
434
435        // Resolves once a change is sent.
436        let mut env2 = env.clone();
437        let handle = tokio::spawn(async move {
438            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
439            env2.set_model("m2");
440        });
441        let result =
442            tokio::time::timeout(std::time::Duration::from_secs(5), watcher.model_changed()).await;
443        assert!(
444            result.is_ok(),
445            "model_changed should resolve after set_model"
446        );
447        assert_eq!(watcher.watch_model(), "m2");
448        handle.await.unwrap();
449    }
450
451    #[tokio::test]
452    async fn client_changed_blocks_then_resolves() {
453        let (env, mut watcher) = test_env();
454
455        let early = tokio::time::timeout(
456            std::time::Duration::from_millis(50),
457            watcher.client_changed(),
458        )
459        .await;
460        assert!(
461            early.is_err(),
462            "client_changed should block with no pending change"
463        );
464
465        let mut env2 = env.clone();
466        let handle = tokio::spawn(async move {
467            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
468            env2.set_client(async_openai::Client::new());
469        });
470        let result =
471            tokio::time::timeout(std::time::Duration::from_secs(5), watcher.client_changed()).await;
472        assert!(
473            result.is_ok(),
474            "client_changed should resolve after set_client"
475        );
476        handle.await.unwrap();
477    }
478
479    #[cfg(feature = "sandbox")]
480    #[test]
481    fn with_sandbox_policy_roundtrips() {
482        let (env, _watcher) = test_env();
483        // Use a non-default policy so a getter that always returns
484        // `Default::default()` is caught.
485        let policy = SandboxPolicy {
486            enabled: false,
487            read_paths: vec![std::path::PathBuf::from("/trusted")],
488            block_network: true,
489            ..Default::default()
490        };
491        let env = env.with_sandbox_policy(policy.clone());
492        assert_eq!(env.sandbox_policy().read_paths, policy.read_paths);
493        assert!(env.sandbox_policy().block_network);
494        assert!(!env.sandbox_policy().enabled);
495    }
496
497    #[cfg(feature = "tool")]
498    mod tool_tests {
499        use super::*;
500        use crate::re_act::tool::{Tool, ToolCallError, ToolRegistry};
501        use serde_json::json;
502
503        struct MockTool;
504        #[async_trait::async_trait]
505        impl Tool for MockTool {
506            fn name(&self) -> &str {
507                "mock"
508            }
509            fn description(&self) -> &str {
510                "mock tool"
511            }
512            fn schema(&self) -> JsonValue {
513                json!({"type": "function", "function": {"name": "mock"}})
514            }
515            async fn execute(&self, _args: JsonValue) -> Result<String, ToolCallError> {
516                Ok("ok".into())
517            }
518        }
519
520        #[test]
521        fn with_tool_registry_updates_watcher_and_registry() {
522            let (env, mut watcher) = FuneraEnv::new(async_openai::Client::new(), "m");
523            let mut reg = ToolRegistry::new();
524            reg.add_tool(Arc::new(MockTool));
525            let env = env.with_tool_registry(reg);
526            assert!(
527                watcher
528                    .watch_tool()
529                    .as_array()
530                    .is_some_and(|a| a.len() == 1)
531            );
532            assert!(env.tool_registry.blocking_read().tool_exists("mock"));
533        }
534
535        #[tokio::test]
536        async fn add_then_remove_tool_updates_watcher() {
537            let (mut env, mut watcher) = FuneraEnv::new(async_openai::Client::new(), "m");
538            env.add_tool(Arc::new(MockTool)).await;
539            assert!(
540                watcher
541                    .watch_tool()
542                    .as_array()
543                    .is_some_and(|a| a.len() == 1)
544            );
545            env.remove_tool("mock").await;
546            assert!(
547                watcher
548                    .watch_tool()
549                    .as_array()
550                    .is_some_and(|a| a.is_empty())
551            );
552        }
553
554        #[tokio::test]
555        async fn set_tool_availability_rebroadcasts_snapshot() {
556            let (mut env, mut watcher) = FuneraEnv::new(async_openai::Client::new(), "m");
557            env.add_tool(Arc::new(MockTool)).await;
558            let _ = watcher.watch_tool();
559            assert!(!watcher.has_tool_changed());
560            env.set_tool_availability("mock", false).await;
561            assert!(watcher.has_tool_changed());
562        }
563
564        #[test]
565        fn has_tool_changed_reflects_changes() {
566            let (env, mut watcher) = FuneraEnv::new(async_openai::Client::new(), "m");
567            assert!(!watcher.has_tool_changed());
568            let mut reg = ToolRegistry::new();
569            reg.add_tool(Arc::new(MockTool));
570            let _env = env.with_tool_registry(reg);
571            assert!(watcher.has_tool_changed());
572            let _ = watcher.watch_tool();
573            assert!(!watcher.has_tool_changed());
574        }
575
576        #[tokio::test]
577        async fn tool_changed_blocks_then_resolves() {
578            let (env, mut watcher) = FuneraEnv::new(async_openai::Client::new(), "m");
579
580            let early =
581                tokio::time::timeout(std::time::Duration::from_millis(50), watcher.tool_changed())
582                    .await;
583            assert!(
584                early.is_err(),
585                "tool_changed should block with no pending change"
586            );
587
588            let mut env2 = env.clone();
589            let handle = tokio::spawn(async move {
590                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
591                env2.add_tool(Arc::new(MockTool)).await;
592            });
593            let result =
594                tokio::time::timeout(std::time::Duration::from_secs(5), watcher.tool_changed())
595                    .await;
596            assert!(result.is_ok(), "tool_changed should resolve after add_tool");
597            handle.await.unwrap();
598        }
599
600        #[tokio::test]
601        async fn env_remove_tool_if_same_removes_only_matching_arc() {
602            let (mut env, mut watcher) = FuneraEnv::new(async_openai::Client::new(), "m");
603            let original: Arc<dyn Tool> = Arc::new(MockTool);
604            env.add_tool(Arc::clone(&original)).await;
605            // A replacement reuses the same name: the registered Arc is now the
606            // second tool.
607            env.add_tool(Arc::new(MockTool)).await;
608
609            // A different Arc must not remove anything.
610            let other: Arc<dyn Tool> = Arc::new(MockTool);
611            assert!(!env.remove_tool_if_same("mock", &other).await);
612            assert!(env.tool_registry.read().await.tool_exists("mock"));
613
614            // The stale (original) Arc must not remove the replacement either.
615            assert!(!env.remove_tool_if_same("mock", &original).await);
616            assert!(env.tool_registry.read().await.tool_exists("mock"));
617
618            // Removing the currently registered Arc succeeds and notifies.
619            let current = env
620                .tool_registry
621                .read()
622                .await
623                .get_tool("mock")
624                .unwrap()
625                .tool
626                .clone();
627            assert!(env.remove_tool_if_same("mock", &current).await);
628            assert!(!env.tool_registry.read().await.tool_exists("mock"));
629            assert!(
630                watcher
631                    .watch_tool()
632                    .as_array()
633                    .is_some_and(|a| a.is_empty())
634            );
635        }
636
637        #[tokio::test]
638        async fn disposer_can_revert_tool_registration() {
639            let (mut env, mut watcher) = FuneraEnv::new(async_openai::Client::new(), "m");
640            let tool: Arc<dyn Tool> = Arc::new(MockTool);
641            env.add_tool(Arc::clone(&tool)).await;
642            assert!(
643                watcher
644                    .watch_tool()
645                    .as_array()
646                    .is_some_and(|a| a.len() == 1)
647            );
648
649            // Register the inverse of the registration as a disposer, so
650            // dispose() removes exactly this tool — the leak-safe pattern.
651            let registry = env.tool_registry.clone();
652            env.effect(move || {
653                let registry = Arc::clone(&registry);
654                let tool = Arc::clone(&tool);
655                Box::new(move || {
656                    // Best-effort, non-blocking undo over the async registry.
657                    if let Ok(mut guard) = registry.try_write() {
658                        guard.remove_tool_if_same("mock", &tool);
659                    }
660                })
661            });
662
663            env.dispose();
664            assert!(!env.tool_registry.read().await.tool_exists("mock"));
665        }
666
667        #[tokio::test]
668        async fn stale_disposer_does_not_remove_replacement_tool() {
669            let (mut env, _watcher) = FuneraEnv::new(async_openai::Client::new(), "m");
670            let original: Arc<dyn Tool> = Arc::new(MockTool);
671            env.add_tool(Arc::clone(&original)).await;
672
673            let registry = env.tool_registry.clone();
674            env.effect(move || {
675                let registry = Arc::clone(&registry);
676                let original = Arc::clone(&original);
677                Box::new(move || {
678                    if let Ok(mut guard) = registry.try_write() {
679                        guard.remove_tool_if_same("mock", &original);
680                    }
681                })
682            });
683
684            // A replacement tool reuses the same name before disposal.
685            env.add_tool(Arc::new(MockTool)).await;
686
687            // The stale disposer must NOT remove the replacement.
688            env.dispose();
689            assert!(env.tool_registry.read().await.tool_exists("mock"));
690        }
691    }
692
693    #[cfg(feature = "skill")]
694    mod skill_tests {
695        use super::*;
696        use crate::re_act::skills::{Skill, SkillRegistry};
697
698        fn skill(name: &str, content: &str) -> Skill {
699            Skill::new(name, "", content)
700        }
701
702        #[tokio::test]
703        async fn add_and_activate_skill_updates_prompt() {
704            let (mut env, mut watcher) = FuneraEnv::new(async_openai::Client::new(), "m");
705            env.add_skill(skill("s1", "part one")).await;
706            assert!(env.activate_skill("s1").await);
707            assert_eq!(watcher.watch_skill(), "part one");
708            assert_eq!(env.skill_prompt_now(), "part one");
709        }
710
711        #[tokio::test]
712        async fn activate_deactivate_skill_returns_bool() {
713            let (mut env, mut watcher) = FuneraEnv::new(async_openai::Client::new(), "m");
714            env.add_skill(skill("s1", "content")).await;
715            assert!(!env.activate_skill("ghost").await);
716            assert!(env.activate_skill("s1").await);
717            assert_eq!(watcher.watch_skill(), "content");
718            assert!(env.deactivate_skill("s1").await);
719            assert_eq!(watcher.watch_skill(), "");
720            assert!(!env.deactivate_skill("s1").await);
721        }
722
723        #[tokio::test]
724        async fn remove_skill_clears_prompt() {
725            let (mut env, mut watcher) = FuneraEnv::new(async_openai::Client::new(), "m");
726            env.add_skill(skill("s1", "content")).await;
727            env.activate_skill("s1").await;
728            env.remove_skill("s1").await;
729            assert_eq!(watcher.watch_skill(), "");
730        }
731
732        #[test]
733        fn with_skill_registry_updates_watcher_and_registry() {
734            let (env, mut watcher) = FuneraEnv::new(async_openai::Client::new(), "m");
735            let mut reg = SkillRegistry::new();
736            reg.add(skill("s1", "content"));
737            reg.activate("s1");
738            let env = env.with_skill_registry(reg);
739            assert_eq!(watcher.watch_skill(), "content");
740            assert!(env.skill_registry.blocking_read().contains("s1"));
741        }
742
743        #[test]
744        fn set_skill_prompt_and_has_changed() {
745            let (mut env, mut watcher) = FuneraEnv::new(async_openai::Client::new(), "m");
746            assert!(!watcher.has_skill_changed());
747            env.set_skill_prompt("hello".into());
748            assert!(watcher.has_skill_changed());
749            assert_eq!(watcher.watch_skill(), "hello");
750        }
751
752        #[tokio::test]
753        async fn skill_changed_blocks_then_resolves() {
754            let (env, mut watcher) = FuneraEnv::new(async_openai::Client::new(), "m");
755
756            let early = tokio::time::timeout(
757                std::time::Duration::from_millis(50),
758                watcher.skill_changed(),
759            )
760            .await;
761            assert!(
762                early.is_err(),
763                "skill_changed should block with no pending change"
764            );
765
766            let mut env2 = env.clone();
767            let handle = tokio::spawn(async move {
768                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
769                env2.add_skill(skill("s1", "content")).await;
770            });
771            let result =
772                tokio::time::timeout(std::time::Duration::from_secs(5), watcher.skill_changed())
773                    .await;
774            assert!(
775                result.is_ok(),
776                "skill_changed should resolve after add_skill"
777            );
778            handle.await.unwrap();
779        }
780    }
781}