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
18pub 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 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 #[cfg(feature = "sandbox")]
95 pub fn with_sandbox_policy(mut self, policy: SandboxPolicy) -> Self {
96 self.sandbox_policy = policy;
97 self
98 }
99
100 #[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 #[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 pub fn effect(&self, body: impl FnOnce() -> Disposer) {
240 let undo = body();
241 self.disposers.lock().push(undo);
242 }
243
244 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 #[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 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 env.dispose();
386 assert_eq!(*log.lock(), vec!["third", "first"]);
387 }
388
389 #[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 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 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 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 env.add_tool(Arc::new(MockTool)).await;
608
609 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 assert!(!env.remove_tool_if_same("mock", &original).await);
616 assert!(env.tool_registry.read().await.tool_exists("mock"));
617
618 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", ¤t).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 let registry = env.tool_registry.clone();
652 env.effect(move || {
653 let registry = Arc::clone(®istry);
654 let tool = Arc::clone(&tool);
655 Box::new(move || {
656 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(®istry);
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 env.add_tool(Arc::new(MockTool)).await;
686
687 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}