1pub mod events;
9
10use std::collections::HashMap;
11use std::sync::Arc;
12use std::time::{Duration, Instant};
13
14use futures::future::join_all;
15use tokio::sync::RwLock;
16
17use self::events::{HookEvent, HookKind, HookResult};
18use crate::extensions::manifest::HookMatcher;
19use crate::extensions::permissions::{Permission, PermissionSet};
20
21const HANDLER_TIMEOUT: Duration = Duration::from_secs(5);
23
24fn extensions_trace_enabled() -> bool {
25 std::env::var("SYNAPS_EXTENSIONS_TRACE")
26 .map(|value| {
27 let normalized = value.trim().to_ascii_lowercase();
28 matches!(normalized.as_str(), "1" | "true" | "yes" | "on")
29 })
30 .unwrap_or(false)
31}
32
33fn hook_result_action(result: &HookResult) -> &'static str {
34 match result {
35 HookResult::Continue => "continue",
36 HookResult::Block { .. } => "block",
37 HookResult::Inject { .. } => "inject",
38 HookResult::Confirm { .. } => "confirm",
39 HookResult::Modify { .. } => "modify",
40 HookResult::Replace { .. } => "replace",
41 HookResult::ContextPhase { .. } => "context_phase",
42 }
43}
44
45fn accept_context_phase(
51 reg: &HandlerRegistration,
52 kind: HookKind,
53 phase: String,
54) -> Option<String> {
55 if !reg.permissions.has(Permission::SessionLifecycle) {
56 tracing::warn!(
57 hook = %kind.as_str(),
58 extension = %reg.handler.id(),
59 "Extension returned context_phase without session.lifecycle permission — ignoring report"
60 );
61 return None;
62 }
63 tracing::info!(
64 hook = %kind.as_str(),
65 extension = %reg.handler.id(),
66 phase = %crate::runtime::continuation::phase_for_log(&phase),
67 "Extension reported advisory context phase"
68 );
69 Some(phase)
70}
71
72fn tool_filter_matches(filter: &str, ev: &HookEvent) -> bool {
79 let (api, runtime) = (ev.tool_name.as_deref(), ev.tool_runtime_name.as_deref());
80 if api == Some(filter) || runtime == Some(filter) {
81 return true;
82 }
83 ev.kind == HookKind::BeforeToolCall
84 && matches!(filter, "edit" | "write")
85 && runtime.or(api) == Some("multi_edit")
86}
87
88#[derive(Clone)]
90pub struct HandlerRegistration {
91 pub handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
93 pub tool_filter: Option<String>,
95 pub matcher: Option<HookMatcher>,
97 pub permissions: PermissionSet,
99}
100
101pub struct HookBus {
106 handlers: RwLock<HashMap<HookKind, Vec<HandlerRegistration>>>,
107 generations: std::sync::Mutex<HashMap<String, u64>>,
112 session_injection: RwLock<HashMap<String, String>>,
124 transforms_of: Option<Arc<HookBus>>,
127}
128
129impl HookBus {
130 pub fn new() -> Self {
132 Self {
133 handlers: RwLock::new(HashMap::new()),
134 session_injection: RwLock::new(HashMap::new()),
135 generations: std::sync::Mutex::new(HashMap::new()),
136 transforms_of: None,
137 }
138 }
139
140 pub fn worker_transforms(host: &Arc<HookBus>) -> Arc<HookBus> {
159 let host = host.transforms_of.clone().unwrap_or_else(|| Arc::clone(host));
161 Arc::new(Self {
162 transforms_of: Some(host),
163 ..Self::new()
164 })
165 }
166
167 pub fn is_worker_view(&self) -> bool {
169 self.transforms_of.is_some()
170 }
171
172 async fn worker_transform_registrations(
176 &self,
177 kind: HookKind,
178 ) -> Option<Vec<HandlerRegistration>> {
179 if kind != HookKind::AfterToolCall {
180 return None;
181 }
182 let handlers = self.handlers.read().await;
183 let regs: Vec<HandlerRegistration> = handlers
184 .get(&kind)?
185 .iter()
186 .filter(|reg| reg.permissions.has(Permission::ToolsTransformOutput))
187 .cloned()
188 .collect();
189 (!regs.is_empty()).then_some(regs)
190 }
191
192 pub fn extension_generation(&self, extension_id: &str) -> u64 {
194 self.generations
195 .lock()
196 .unwrap_or_else(|e| e.into_inner())
197 .get(extension_id)
198 .copied()
199 .unwrap_or(0)
200 }
201
202 pub async fn set_session_injection_for(&self, session_id: &str, content: String) {
209 self.session_injection
210 .write()
211 .await
212 .insert(session_id.to_string(), content);
213 }
214
215 pub async fn session_injection_for(&self, session_id: &str) -> Option<String> {
218 self.session_injection.read().await.get(session_id).cloned()
219 }
220
221 pub async fn clear_session_injection(&self, session_id: &str) {
223 self.session_injection.write().await.remove(session_id);
224 }
225
226 #[deprecated(note = "use set_session_injection_for")]
228 pub async fn set_session_injection(&self, content: String) {
229 self.set_session_injection_for("", content).await;
230 }
231
232 #[deprecated(note = "use session_injection_for")]
234 pub async fn session_injection(&self) -> Option<String> {
235 self.session_injection_for("").await
236 }
237
238 pub async fn subscribe(
243 &self,
244 kind: HookKind,
245 handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
246 tool_filter: Option<String>,
247 matcher: Option<HookMatcher>,
248 permissions: PermissionSet,
249 ) -> Result<(), String> {
250 if self.is_worker_view() {
251 return Err(format!(
252 "Extension '{}' cannot subscribe to a worker's hook view; subscribe to the host bus",
253 handler.id(),
254 ));
255 }
256 if !permissions.allows_hook(kind) {
258 return Err(format!(
259 "Extension '{}' lacks permission '{}' required for hook '{}'",
260 handler.id(),
261 kind.required_permission().as_str(),
262 kind.as_str(),
263 ));
264 }
265
266 let reg = HandlerRegistration {
267 handler,
268 tool_filter,
269 matcher,
270 permissions,
271 };
272
273 let mut handlers = self.handlers.write().await;
274 handlers.entry(kind).or_default().push(reg);
275 Ok(())
276 }
277
278 pub async fn emit(&self, event: &HookEvent) -> HookResult {
301 let no_spawn = self.is_worker_view();
306 let registrations = match &self.transforms_of {
307 Some(host) => match host.worker_transform_registrations(event.kind).await {
308 Some(regs) => regs,
309 None => return HookResult::Continue,
310 },
311 None => {
312 let handlers = self.handlers.read().await;
313 match handlers.get(&event.kind) {
314 Some(regs) if !regs.is_empty() => regs.clone(),
315 _ => return HookResult::Continue, }
317 }
318 }; let mut injections: Vec<String> = Vec::new();
322 let mut context_phase: Option<String> = None;
325 let mut confirms: Vec<(String, String)> = Vec::new();
327 let mut source_keys: Vec<(String, u64)> = Vec::new();
329
330 let mut modified_event: Option<HookEvent> = None;
334 let mut modified_by: Vec<String> = Vec::new();
336
337 for reg in ®istrations {
338 let ev: &HookEvent = modified_event.as_ref().unwrap_or(event);
339 if let Some(ref filter) = reg.tool_filter {
343 if !tool_filter_matches(filter, ev) {
344 continue;
345 }
346 }
347
348 if let Some(ref matcher) = reg.matcher {
349 if !matcher.matches(ev) {
350 continue;
351 }
352 }
353
354 let handler = reg.handler.clone();
356 let event_clone = ev.clone();
357 let trace_enabled = extensions_trace_enabled();
358 let started_at = trace_enabled.then(Instant::now);
359 let call = async {
360 if no_spawn {
361 handler.handle_no_spawn(&event_clone).await
362 } else {
363 handler.handle(&event_clone).await
364 }
365 };
366 let result = tokio::time::timeout(HANDLER_TIMEOUT, call).await;
367
368 if trace_enabled {
369 let health = reg.handler.health().await;
370 let health = health.as_str();
371 let restart_count = reg.handler.restart_count().await;
372 let duration_ms = started_at
373 .map(|start| start.elapsed().as_millis() as u64)
374 .unwrap_or(0);
375 match &result {
376 Ok(hook_result) => {
377 let action = hook_result_action(hook_result);
378 tracing::info!(
379 extension_trace = true,
380 hook = %ev.kind.as_str(),
381 extension = %reg.handler.id(),
382 action = action,
383 duration_ms = duration_ms,
384 health = health,
385 restart_count = restart_count,
386 "Extension hook trace"
387 );
388 }
389 Err(_) => {
390 tracing::warn!(
391 extension_trace = true,
392 hook = %ev.kind.as_str(),
393 extension = %reg.handler.id(),
394 action = "timeout",
395 duration_ms = duration_ms,
396 timeout_secs = HANDLER_TIMEOUT.as_secs(),
397 health = health,
398 restart_count = restart_count,
399 "Extension hook trace"
400 );
401 }
402 }
403 }
404
405 match result {
406 Ok(result) if !ev.kind.allows_result(&result) => {
407 tracing::warn!(
408 hook = %ev.kind.as_str(),
409 extension = %reg.handler.id(),
410 action = hook_result_action(&result),
411 "Extension returned action not allowed for hook — ignoring"
412 );
413 continue;
414 }
415 Ok(HookResult::Block { reason }) => {
416 tracing::info!(
417 hook = %ev.kind.as_str(),
418 extension = %reg.handler.id(),
419 reason = %reason,
420 "Hook blocked by extension"
421 );
422 return HookResult::Block { reason };
423 }
424 Ok(HookResult::Continue) => {}
425 Ok(HookResult::Inject { content }) => {
426 tracing::debug!(
427 hook = %ev.kind.as_str(),
428 extension = %reg.handler.id(),
429 len = content.len(),
430 "Extension injected context"
431 );
432 injections.push(content);
434 }
435 Ok(HookResult::Modify { input }) => {
436 if !confirms.is_empty() {
437 tracing::warn!(
441 hook = %ev.kind.as_str(),
442 extension = %reg.handler.id(),
443 confirming = ?confirms.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
444 "Extension Modify after a pending Confirm IGNORED: the user confirms the input as it was when the first Confirm was raised"
445 );
446 continue;
447 }
448 tracing::info!(
449 hook = %ev.kind.as_str(),
450 extension = %reg.handler.id(),
451 "Hook modified tool input by extension; later handlers vet the modified input"
452 );
453 let mut next = ev.clone();
454 next.tool_input = Some(input);
455 modified_event = Some(next);
456 let id = reg.handler.id().to_string();
457 if !modified_by.contains(&id) {
458 modified_by.push(id);
459 }
460 }
461 Ok(HookResult::Replace { output }) => {
462 if !reg.permissions.has(Permission::ToolsTransformOutput) {
468 tracing::warn!(
469 hook = %ev.kind.as_str(),
470 extension = %reg.handler.id(),
471 "Extension returned Replace without tools.transform_output permission — ignoring transform"
472 );
473 continue;
474 }
475 let observed_len = ev.tool_output.as_ref().map_or(0, String::len);
476 tracing::info!(
477 hook = %ev.kind.as_str(),
478 extension = %reg.handler.id(),
479 observed_len,
480 replacement_len = output.len(),
481 "Hook replaced tool output by extension"
482 );
483 let _ = crate::extensions::audit::record_tool_output_replace(
487 reg.handler.id(),
488 ev.kind.as_str(),
489 ev.tool_name.clone(),
490 observed_len,
491 output.len(),
492 );
493 return HookResult::Replace { output };
495 }
496 Ok(HookResult::Confirm { message, .. }) => {
497 tracing::info!(
498 hook = %ev.kind.as_str(),
499 extension = %reg.handler.id(),
500 "Hook requested confirmation by extension"
501 );
502 confirms.push((reg.handler.id().to_string(), message));
506 let id = reg.handler.id();
507 if !source_keys.iter().any(|(k, _)| k == id) {
508 source_keys.push((id.to_string(), self.extension_generation(id)));
509 }
510 }
511 Ok(HookResult::ContextPhase { phase }) => {
512 if let Some(phase) = accept_context_phase(reg, ev.kind, phase) {
515 if context_phase.is_none() {
516 context_phase = Some(phase);
517 } else {
518 tracing::debug!(
519 hook = %ev.kind.as_str(),
520 extension = %reg.handler.id(),
521 "Extension context_phase superseded by an earlier report — ignoring"
522 );
523 }
524 }
525 }
526 Err(_timeout) => {
527 tracing::warn!(
528 hook = %ev.kind.as_str(),
529 extension = %reg.handler.id(),
530 timeout_secs = HANDLER_TIMEOUT.as_secs(),
531 "Hook handler timed out — skipping"
532 );
533 }
535 }
536 }
537
538 if !confirms.is_empty() {
540 let tool = event
541 .tool_runtime_name
542 .clone()
543 .or_else(|| event.tool_name.clone());
544 let message = if confirms.len() == 1 {
545 confirms[0].1.clone()
546 } else {
547 confirms
548 .iter()
549 .map(|(id, m)| format!("[{id}] {m}"))
550 .collect::<Vec<_>>()
551 .join("\n\n")
552 };
553 let sources = source_keys.iter().map(|(id, _)| id.clone()).collect();
554 let generations = source_keys.iter().map(|(_, g)| *g).collect();
555 return HookResult::Confirm {
556 message,
557 sources,
558 generations,
559 tool,
560 modified_input: modified_event.and_then(|e| e.tool_input),
561 modified_by,
562 };
563 }
564 if let Some(input) = modified_event.and_then(|e| e.tool_input) {
565 return HookResult::Modify { input };
566 }
567
568 if !injections.is_empty() {
572 HookResult::Inject {
573 content: injections.join("\n\n"),
574 }
575 } else if let Some(phase) = context_phase {
576 HookResult::ContextPhase { phase }
577 } else {
578 HookResult::Continue
579 }
580 }
581
582 pub async fn emit_concurrent(&self, event: &HookEvent) -> HookResult {
606 assert!(
609 !matches!(
610 event.kind,
611 HookKind::BeforeToolCall | HookKind::AfterToolCall | HookKind::BeforeMessage
612 ),
613 "emit_concurrent must not be used for transform-capable hooks \
614 ({:?}); their Block/Modify/Replace/Inject results need the \
615 deterministic first-wins ordering of the sequential emit()",
616 event.kind,
617 );
618 let registrations = {
620 let handlers = self.handlers.read().await;
621 match handlers.get(&event.kind) {
622 Some(regs) if !regs.is_empty() => regs.clone(),
623 _ => return HookResult::Continue, }
625 };
626
627 let futures: Vec<_> =
629 registrations
630 .iter()
631 .filter(|reg| {
632 reg.tool_filter
634 .as_deref()
635 .is_none_or(|filter| tool_filter_matches(filter, event))
636 })
637 .filter(|reg| reg.matcher.as_ref().is_none_or(|m| m.matches(event)))
638 .map(|reg| {
639 let handler = reg.handler.clone();
640 let event_clone = event.clone();
641 async move {
642 (
643 reg,
644 tokio::time::timeout(HANDLER_TIMEOUT, handler.handle(&event_clone))
645 .await,
646 )
647 }
648 })
649 .collect();
650
651 let results = join_all(futures).await;
652
653 let mut injections: Vec<String> = Vec::new();
654 let mut context_phase: Option<String> = None;
657 for (reg, result) in results {
658 match result {
659 Ok(HookResult::Continue) => {}
660 Ok(HookResult::Block { reason }) => {
661 return HookResult::Block { reason };
662 }
663 Ok(HookResult::Inject { content }) => {
664 injections.push(content);
665 }
666 Ok(HookResult::Modify { input }) => {
667 return HookResult::Modify { input };
668 }
669 Ok(HookResult::Replace { output }) => {
670 return HookResult::Replace { output };
671 }
672 Ok(HookResult::Confirm { message, .. }) => {
673 return HookResult::Confirm {
674 message,
675 sources: vec![reg.handler.id().to_string()],
676 generations: vec![self.extension_generation(reg.handler.id())],
677 tool: event.tool_runtime_name.clone().or_else(|| event.tool_name.clone()),
678 modified_input: None,
679 modified_by: vec![],
680 };
681 }
682 Ok(result @ HookResult::ContextPhase { .. }) => {
683 if !event.kind.allows_result(&result) {
684 tracing::warn!(
685 hook = %event.kind.as_str(),
686 extension = %reg.handler.id(),
687 action = hook_result_action(&result),
688 "Extension returned action not allowed for hook — ignoring"
689 );
690 continue;
691 }
692 if let HookResult::ContextPhase { phase } = result {
693 if let Some(phase) = accept_context_phase(reg, event.kind, phase) {
694 context_phase.get_or_insert(phase);
695 }
696 }
697 }
698 Err(_timeout) => {
699 tracing::warn!(
700 hook = %event.kind.as_str(),
701 extension = %reg.handler.id(),
702 timeout_secs = HANDLER_TIMEOUT.as_secs(),
703 "Hook handler timed out in concurrent emit — skipping"
704 );
705 }
706 }
707 }
708
709 if !injections.is_empty() {
710 HookResult::Inject {
711 content: injections.join("\n\n"),
712 }
713 } else if let Some(phase) = context_phase {
714 HookResult::ContextPhase { phase }
715 } else {
716 HookResult::Continue
717 }
718 }
719
720 pub async fn unsubscribe_all(&self, extension_id: &str) {
722 *self
723 .generations
724 .lock()
725 .unwrap_or_else(|e| e.into_inner())
726 .entry(extension_id.to_string())
727 .or_insert(0) += 1;
728 let mut handlers = self.handlers.write().await;
729 for regs in handlers.values_mut() {
730 regs.retain(|r| r.handler.id() != extension_id);
731 }
732 }
733
734 pub async fn handler_count(&self) -> usize {
737 if let Some(host) = &self.transforms_of {
738 return host
739 .worker_transform_registrations(HookKind::AfterToolCall)
740 .await
741 .map_or(0, |regs| regs.len());
742 }
743 let handlers = self.handlers.read().await;
744 handlers.values().map(|v| v.len()).sum()
745 }
746
747 pub async fn is_empty(&self) -> bool {
749 if self.is_worker_view() {
750 return self.handler_count().await == 0;
751 }
752 let handlers = self.handlers.read().await;
753 handlers.values().all(|v| v.is_empty())
754 }
755
756 pub async fn subscriptions_for(&self, extension_id: &str) -> Vec<(HookKind, Option<String>)> {
759 let handlers = self.handlers.read().await;
760 let mut out: Vec<(HookKind, Option<String>)> = Vec::new();
761 for (kind, regs) in handlers.iter() {
762 for reg in regs {
763 if reg.handler.id() == extension_id {
764 out.push((*kind, reg.tool_filter.clone()));
765 }
766 }
767 }
768 out.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()).then_with(|| a.1.cmp(&b.1)));
769 out
770 }
771}
772
773impl Default for HookBus {
774 fn default() -> Self {
775 Self::new()
776 }
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782 use crate::extensions::hooks::events::HookEvent;
783 use crate::extensions::permissions::Permission;
784 use async_trait::async_trait;
785 use std::sync::atomic::{AtomicUsize, Ordering};
786
787 struct TestHandler {
789 id: String,
790 call_count: AtomicUsize,
791 result: HookResult,
792 }
793
794 impl TestHandler {
795 fn new(id: &str, result: HookResult) -> Arc<Self> {
796 Arc::new(Self {
797 id: id.to_string(),
798 call_count: AtomicUsize::new(0),
799 result,
800 })
801 }
802
803 fn calls(&self) -> usize {
804 self.call_count.load(Ordering::Relaxed)
805 }
806 }
807
808 #[async_trait]
809 impl crate::extensions::runtime::ExtensionHandler for TestHandler {
810 fn id(&self) -> &str {
811 &self.id
812 }
813
814 async fn handle(&self, _event: &HookEvent) -> HookResult {
815 self.call_count.fetch_add(1, Ordering::Relaxed);
816 self.result.clone()
817 }
818
819 async fn shutdown(&self) {}
820 }
821
822 fn perms_with(perms: &[Permission]) -> PermissionSet {
823 let mut set = PermissionSet::new();
824 for p in perms {
825 set.grant(*p);
826 }
827 set
828 }
829
830 #[tokio::test]
834 #[serial_test::serial(synaps_base_dir)]
835 async fn after_tool_call_replace_substitutes_recorded_output() {
836 let base = crate::test_env::BaseDirGuard::new();
838 let bus = std::sync::Arc::new(HookBus::new());
839 let handler = TestHandler::new(
840 "compressor",
841 HookResult::Replace {
842 output: "COMPRESSED".into(),
843 },
844 );
845 bus.subscribe(
846 HookKind::AfterToolCall,
847 handler,
848 None,
849 None,
850 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
851 )
852 .await
853 .unwrap();
854
855 let recorded = crate::runtime::emit_after_tool_call(
856 &bus,
857 "bash",
858 None,
859 serde_json::json!({"command": "cat huge.log"}),
860 "RAW 10k lines".to_string(),
861 30_000,
862 None,
863 )
864 .await;
865
866 assert_eq!(recorded, "COMPRESSED", "Replace output must reach history");
867 let audit = std::fs::read_to_string(
868 crate::extensions::audit::intercept_file_path_for(base.path()),
869 )
870 .expect("intercept audit must land in the isolated base dir");
871 assert!(audit.contains("\"plugin_id\":\"compressor\""));
872 }
873
874 #[tokio::test]
876 async fn after_tool_call_without_replace_keeps_original_output() {
877 let bus = std::sync::Arc::new(HookBus::new());
878 let recorded = crate::runtime::emit_after_tool_call(
879 &bus,
880 "bash",
881 None,
882 serde_json::json!({}),
883 "RAW".to_string(),
884 30_000,
885 None,
886 )
887 .await;
888
889 assert_eq!(recorded, "RAW", "no Replace → original output preserved");
890 }
891
892 #[tokio::test]
896 #[serial_test::serial(synaps_base_dir)]
897 async fn replace_stops_chain_for_after_tool_call() {
898 let _base = crate::test_env::BaseDirGuard::new();
900 let bus = HookBus::new();
901 let first = TestHandler::new(
902 "first",
903 HookResult::Replace {
904 output: "FIRST".into(),
905 },
906 );
907 let second = TestHandler::new(
908 "second",
909 HookResult::Replace {
910 output: "SECOND".into(),
911 },
912 );
913 let perms = perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]);
914
915 bus.subscribe(
916 HookKind::AfterToolCall,
917 first.clone(),
918 None,
919 None,
920 perms.clone(),
921 )
922 .await
923 .unwrap();
924 bus.subscribe(HookKind::AfterToolCall, second.clone(), None, None, perms)
925 .await
926 .unwrap();
927
928 let event = HookEvent::after_tool_call("bash", serde_json::json!({}), "RAW".to_string());
929 let result = bus.emit(&event).await;
930
931 assert_eq!(
932 result,
933 HookResult::Replace {
934 output: "FIRST".into()
935 }
936 );
937 assert_eq!(first.calls(), 1);
938 assert_eq!(second.calls(), 0); }
940
941 #[tokio::test]
945 async fn replace_without_transform_permission_is_ignored() {
946 let bus = std::sync::Arc::new(HookBus::new());
947 let handler = TestHandler::new(
948 "observer-only",
949 HookResult::Replace {
950 output: "SNEAKY".into(),
951 },
952 );
953 bus.subscribe(
955 HookKind::AfterToolCall,
956 handler.clone(),
957 None,
958 None,
959 perms_with(&[Permission::ToolsIntercept]),
960 )
961 .await
962 .unwrap();
963
964 let recorded = crate::runtime::emit_after_tool_call(
965 &bus,
966 "bash",
967 None,
968 serde_json::json!({}),
969 "ORIGINAL".to_string(),
970 30_000,
971 None,
972 )
973 .await;
974
975 assert_eq!(handler.calls(), 1, "handler still runs (it's subscribed)");
976 assert_eq!(
977 recorded, "ORIGINAL",
978 "Replace without tools.transform_output must be ignored — original preserved"
979 );
980 }
981
982 fn phase(p: &str) -> HookResult {
985 HookResult::ContextPhase { phase: p.into() }
986 }
987
988 fn message_complete_event() -> HookEvent {
989 HookEvent::on_message_complete(
990 "done",
991 serde_json::json!({"content_block_count": 1, "has_tool_use": false}),
992 )
993 }
994
995 #[tokio::test]
998 async fn context_phase_accepted_with_session_lifecycle() {
999 let bus = HookBus::new();
1000 let reporter = TestHandler::new("reporter", phase("new_task"));
1001 bus.subscribe(
1002 HookKind::OnMessageComplete,
1003 reporter.clone(),
1004 None,
1005 None,
1006 perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]),
1007 )
1008 .await
1009 .unwrap();
1010
1011 let result = bus.emit(&message_complete_event()).await;
1012 assert_eq!(result, phase("new_task"));
1013 assert_eq!(reporter.calls(), 1);
1014 }
1015
1016 #[tokio::test]
1019 async fn context_phase_ignored_without_session_lifecycle() {
1020 let bus = HookBus::new();
1021 let reporter = TestHandler::new("observer-only", phase("new_task"));
1022 bus.subscribe(
1023 HookKind::OnMessageComplete,
1024 reporter.clone(),
1025 None,
1026 None,
1027 perms_with(&[Permission::LlmContent]),
1028 )
1029 .await
1030 .unwrap();
1031
1032 let result = bus.emit(&message_complete_event()).await;
1033 assert_eq!(result, HookResult::Continue);
1034 assert_eq!(reporter.calls(), 1, "handler still runs (it's subscribed)");
1035 }
1036
1037 #[tokio::test]
1041 async fn context_phase_does_not_stop_chain_and_first_valid_wins() {
1042 let bus = HookBus::new();
1043 let unprivileged = TestHandler::new("unprivileged", phase("plan"));
1044 let first = TestHandler::new("first", phase("new_task"));
1045 let second = TestHandler::new("second", phase("wrap_up"));
1046 let observer = TestHandler::new("observer", HookResult::Continue);
1047 let both = || perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]);
1048
1049 bus.subscribe(
1050 HookKind::OnMessageComplete,
1051 unprivileged.clone(),
1052 None,
1053 None,
1054 perms_with(&[Permission::LlmContent]),
1055 )
1056 .await
1057 .unwrap();
1058 for handler in [first.clone(), second.clone()] {
1059 bus.subscribe(HookKind::OnMessageComplete, handler, None, None, both())
1060 .await
1061 .unwrap();
1062 }
1063 bus.subscribe(
1064 HookKind::OnMessageComplete,
1065 observer.clone(),
1066 None,
1067 None,
1068 perms_with(&[Permission::LlmContent]),
1069 )
1070 .await
1071 .unwrap();
1072
1073 let result = bus.emit(&message_complete_event()).await;
1074 assert_eq!(result, phase("new_task"), "first valid report wins");
1075 assert_eq!(unprivileged.calls(), 1);
1076 assert_eq!(first.calls(), 1);
1077 assert_eq!(second.calls(), 1, "later reporter still runs");
1078 assert_eq!(observer.calls(), 1, "later observer still runs");
1079 }
1080
1081 #[tokio::test]
1084 async fn context_phase_survives_ignored_block_on_message_complete() {
1085 let bus = HookBus::new();
1086 let blocker = TestHandler::new(
1087 "blocker",
1088 HookResult::Block {
1089 reason: "nope".into(),
1090 },
1091 );
1092 let reporter = TestHandler::new("reporter", phase("execute"));
1093 bus.subscribe(
1094 HookKind::OnMessageComplete,
1095 blocker.clone(),
1096 None,
1097 None,
1098 perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]),
1099 )
1100 .await
1101 .unwrap();
1102 bus.subscribe(
1103 HookKind::OnMessageComplete,
1104 reporter.clone(),
1105 None,
1106 None,
1107 perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]),
1108 )
1109 .await
1110 .unwrap();
1111
1112 let result = bus.emit(&message_complete_event()).await;
1113 assert_eq!(result, phase("execute"));
1114 assert_eq!(blocker.calls(), 1);
1115 assert_eq!(reporter.calls(), 1);
1116 }
1117
1118 #[tokio::test]
1121 async fn context_phase_not_allowed_on_other_hooks() {
1122 let bus = HookBus::new();
1123 let all_perms = perms_with(&[
1124 Permission::ToolsIntercept,
1125 Permission::LlmContent,
1126 Permission::SessionLifecycle,
1127 ]);
1128 let handlers: Vec<(HookKind, HookEvent, Arc<TestHandler>)> = vec![
1129 (
1130 HookKind::BeforeToolCall,
1131 HookEvent::before_tool_call("bash", serde_json::json!({})),
1132 TestHandler::new("btc", phase("new_task")),
1133 ),
1134 (
1135 HookKind::AfterToolCall,
1136 HookEvent::after_tool_call("bash", serde_json::json!({}), "o".into()),
1137 TestHandler::new("atc", phase("new_task")),
1138 ),
1139 (
1140 HookKind::BeforeMessage,
1141 HookEvent::before_message("hi"),
1142 TestHandler::new("bm", phase("new_task")),
1143 ),
1144 (
1145 HookKind::OnCompaction,
1146 HookEvent::on_compaction("a", "b", "s", 1, serde_json::json!({})),
1147 TestHandler::new("oc", phase("new_task")),
1148 ),
1149 (
1150 HookKind::OnSessionStart,
1151 HookEvent::on_session_start("s"),
1152 TestHandler::new("oss", phase("new_task")),
1153 ),
1154 (
1155 HookKind::OnSessionEnd,
1156 HookEvent::on_session_end("s", None),
1157 TestHandler::new("ose", phase("new_task")),
1158 ),
1159 ];
1160 for (kind, _, handler) in &handlers {
1161 bus.subscribe(*kind, handler.clone(), None, None, all_perms.clone())
1162 .await
1163 .unwrap();
1164 }
1165 for (kind, event, handler) in &handlers {
1166 let result = bus.emit(event).await;
1167 assert_eq!(result, HookResult::Continue, "{}", kind.as_str());
1168 assert_eq!(handler.calls(), 1, "{}", kind.as_str());
1169 }
1170 let end = HookEvent::on_session_end("s", None);
1172 assert_eq!(bus.emit_concurrent(&end).await, HookResult::Continue);
1173 }
1174
1175 #[tokio::test]
1178 async fn context_phase_concurrent_emit_two_key_gate() {
1179 let bus = HookBus::new();
1180 let unprivileged = TestHandler::new("unprivileged", phase("plan"));
1181 let reporter = TestHandler::new("reporter", phase("wrap_up"));
1182 bus.subscribe(
1183 HookKind::OnMessageComplete,
1184 unprivileged.clone(),
1185 None,
1186 None,
1187 perms_with(&[Permission::LlmContent]),
1188 )
1189 .await
1190 .unwrap();
1191 bus.subscribe(
1192 HookKind::OnMessageComplete,
1193 reporter.clone(),
1194 None,
1195 None,
1196 perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]),
1197 )
1198 .await
1199 .unwrap();
1200
1201 let result = bus.emit_concurrent(&message_complete_event()).await;
1202 assert_eq!(result, phase("wrap_up"));
1203 assert_eq!(unprivileged.calls(), 1);
1204 assert_eq!(reporter.calls(), 1);
1205 }
1206
1207 #[test]
1208 fn trace_env_value_parser_accepts_common_truthy_values() {
1209 for value in ["1", "true", "TRUE", "yes", "on"] {
1210 std::env::set_var("SYNAPS_EXTENSIONS_TRACE", value);
1211 assert!(
1212 extensions_trace_enabled(),
1213 "{value} should enable trace mode"
1214 );
1215 }
1216
1217 for value in ["", "0", "false", "off", "no"] {
1218 std::env::set_var("SYNAPS_EXTENSIONS_TRACE", value);
1219 assert!(
1220 !extensions_trace_enabled(),
1221 "{value:?} should not enable trace mode"
1222 );
1223 }
1224 std::env::remove_var("SYNAPS_EXTENSIONS_TRACE");
1225 }
1226
1227 #[tokio::test]
1228 async fn matcher_skips_handler_when_input_does_not_contain_value() {
1229 let bus = HookBus::new();
1230 let handler = TestHandler::new(
1231 "matcher",
1232 HookResult::Block {
1233 reason: "matched".into(),
1234 },
1235 );
1236 let mut perms = PermissionSet::new();
1237 perms.grant(Permission::ToolsIntercept);
1238 bus.subscribe(
1239 HookKind::BeforeToolCall,
1240 handler.clone(),
1241 None,
1242 Some(HookMatcher {
1243 input_contains: Some("danger".to_string()),
1244 input_equals: None,
1245 }),
1246 perms,
1247 )
1248 .await
1249 .unwrap();
1250
1251 let safe = HookEvent::before_tool_call("bash", serde_json::json!({"command": "echo safe"}));
1252 assert!(matches!(bus.emit(&safe).await, HookResult::Continue));
1253
1254 let danger =
1255 HookEvent::before_tool_call("bash", serde_json::json!({"command": "echo danger"}));
1256 assert!(matches!(bus.emit(&danger).await, HookResult::Block { .. }));
1257 }
1258
1259 #[test]
1260 fn hook_result_action_names_are_stable_for_trace_logs() {
1261 assert_eq!(hook_result_action(&HookResult::Continue), "continue");
1262 assert_eq!(
1263 hook_result_action(&HookResult::Block {
1264 reason: "stop".into(),
1265 }),
1266 "block"
1267 );
1268 assert_eq!(
1269 hook_result_action(&HookResult::Inject {
1270 content: "context".into(),
1271 }),
1272 "inject"
1273 );
1274 assert_eq!(
1275 hook_result_action(&HookResult::confirm("Proceed?")),
1276 "confirm"
1277 );
1278 assert_eq!(
1279 hook_result_action(&HookResult::Modify {
1280 input: serde_json::json!({"command": "echo safe"}),
1281 }),
1282 "modify"
1283 );
1284 }
1285
1286 #[tokio::test]
1287 async fn empty_bus_returns_continue() {
1288 let bus = HookBus::new();
1289 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1290 let result = bus.emit(&event).await;
1291 assert!(matches!(result, HookResult::Continue));
1292 }
1293
1294 #[tokio::test]
1295 async fn handler_receives_events() {
1296 let bus = HookBus::new();
1297 let handler = TestHandler::new("test-ext", HookResult::Continue);
1298 let perms = perms_with(&[Permission::ToolsIntercept]);
1299
1300 bus.subscribe(HookKind::BeforeToolCall, handler.clone(), None, None, perms)
1301 .await
1302 .unwrap();
1303
1304 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "ls"}));
1305 bus.emit(&event).await;
1306
1307 assert_eq!(handler.calls(), 1);
1308 }
1309
1310 async fn bus_with(handlers: Vec<Arc<TestHandler>>) -> HookBus {
1311 let bus = HookBus::new();
1312 let perms = perms_with(&[Permission::ToolsIntercept]);
1313 for h in handlers {
1314 bus.subscribe(HookKind::BeforeToolCall, h, None, None, perms.clone())
1315 .await
1316 .unwrap();
1317 }
1318 bus
1319 }
1320
1321 #[tokio::test]
1324 async fn confirm_does_not_stop_chain_for_before_tool_call() {
1325 let confirmer = TestHandler::new("confirmer", HookResult::confirm("Run this command?"));
1326 let after = TestHandler::new("after", HookResult::Continue);
1327 let bus = bus_with(vec![confirmer.clone(), after.clone()]).await;
1328
1329 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1330 let result = bus.emit(&event).await;
1331
1332 assert!(matches!(result, HookResult::Confirm { .. }));
1333 assert_eq!(confirmer.calls(), 1);
1334 assert_eq!(after.calls(), 1, "later handlers are still evaluated");
1335 }
1336
1337 #[tokio::test]
1339 async fn later_block_wins_over_earlier_confirm() {
1340 let confirmer = TestHandler::new("confirmer", HookResult::confirm("Run?"));
1341 let blocker = TestHandler::new(
1342 "policy",
1343 HookResult::Block {
1344 reason: "policy veto".into(),
1345 },
1346 );
1347 let bus = bus_with(vec![confirmer, blocker]).await;
1348 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1349 assert!(matches!(
1350 bus.emit(&event).await,
1351 HookResult::Block { reason } if reason == "policy veto"
1352 ));
1353 }
1354
1355 #[tokio::test]
1358 async fn confirms_from_several_extensions_merge_sources() {
1359 let a = TestHandler::new("ext-a", HookResult::confirm("A asks"));
1360 let b = TestHandler::new("ext-b", HookResult::confirm("B asks"));
1361 let bus = bus_with(vec![a, b]).await;
1362 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1363 match bus.emit(&event).await {
1364 HookResult::Confirm {
1365 message, sources, ..
1366 } => {
1367 assert_eq!(sources, vec!["ext-a".to_string(), "ext-b".to_string()]);
1368 assert!(message.contains("A asks") && message.contains("B asks"), "{message}");
1369 assert!(message.contains("[ext-a]") && message.contains("[ext-b]"), "{message}");
1370 }
1371 other => panic!("expected merged Confirm, got {other:?}"),
1372 }
1373 }
1374
1375 #[tokio::test]
1378 async fn armed_latch_never_skips_a_later_block() {
1379 let confirmer = TestHandler::new("jev", HookResult::confirm("Run?"));
1380 let blocker = TestHandler::new(
1381 "policy",
1382 HookResult::Block {
1383 reason: "policy veto".into(),
1384 },
1385 );
1386 let bus = bus_with(vec![confirmer, blocker.clone()]).await;
1387 let latch = crate::tools::SessionConfirmLatch::new();
1388 latch.allow(&[("jev".to_string(), 0)]);
1389 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1390 let decision = crate::runtime::resolve_before_tool_call_decision(
1391 serde_json::json!({}),
1392 bus.emit(&event).await,
1393 None,
1394 false,
1395 Some(&latch),
1396 )
1397 .await;
1398 assert_eq!(blocker.calls(), 1);
1399 assert!(matches!(
1400 decision,
1401 crate::runtime::BeforeToolCallDecision::Block { reason } if reason == "policy veto"
1402 ));
1403 }
1404
1405 struct InputSeen {
1407 id: String,
1408 seen: std::sync::Mutex<Option<serde_json::Value>>,
1409 result: HookResult,
1410 }
1411 #[async_trait]
1412 impl crate::extensions::runtime::ExtensionHandler for InputSeen {
1413 fn id(&self) -> &str {
1414 &self.id
1415 }
1416 async fn handle(&self, event: &HookEvent) -> HookResult {
1417 *self.seen.lock().unwrap() = event.tool_input.clone();
1418 self.result.clone()
1419 }
1420 async fn shutdown(&self) {}
1421 }
1422 fn input_seen(id: &str, result: HookResult) -> Arc<InputSeen> {
1423 Arc::new(InputSeen {
1424 id: id.into(),
1425 seen: std::sync::Mutex::new(None),
1426 result,
1427 })
1428 }
1429 fn modify(cmd: &str) -> HookResult {
1430 HookResult::Modify {
1431 input: serde_json::json!({ "command": cmd }),
1432 }
1433 }
1434 async fn bus_of(handlers: Vec<Arc<dyn crate::extensions::runtime::ExtensionHandler>>) -> HookBus {
1435 let bus = HookBus::new();
1436 let perms = perms_with(&[Permission::ToolsIntercept]);
1437 for h in handlers {
1438 bus.subscribe(HookKind::BeforeToolCall, h, None, None, perms.clone())
1439 .await
1440 .unwrap();
1441 }
1442 bus
1443 }
1444
1445 #[tokio::test]
1448 async fn modify_then_block_blocks_and_blocker_vets_modified_input() {
1449 let blocker = input_seen(
1450 "policy",
1451 HookResult::Block {
1452 reason: "veto".into(),
1453 },
1454 );
1455 let bus = bus_of(vec![input_seen("rewriter", modify("rm -rf /")), blocker.clone()]).await;
1456 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "ls"}));
1457 assert!(matches!(bus.emit(&event).await, HookResult::Block { reason } if reason == "veto"));
1458 assert_eq!(
1459 blocker.seen.lock().unwrap().clone(),
1460 Some(serde_json::json!({"command": "rm -rf /"})),
1461 "the blocker must vet the rewritten input, not the original"
1462 );
1463 }
1464
1465 #[tokio::test]
1468 async fn modify_then_confirm_prompts_with_modified_input() {
1469 let confirmer = input_seen("jev", HookResult::confirm("ok?"));
1470 let bus = bus_of(vec![input_seen("rewriter", modify("echo safe")), confirmer.clone()]).await;
1471 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "ls"}));
1472 let result = bus.emit(&event).await;
1473 assert_eq!(
1474 confirmer.seen.lock().unwrap().clone(),
1475 Some(serde_json::json!({"command": "echo safe"}))
1476 );
1477 match &result {
1478 HookResult::Confirm {
1479 sources,
1480 modified_input,
1481 ..
1482 } => {
1483 assert_eq!(sources, &vec!["jev".to_string()]);
1484 assert_eq!(
1485 modified_input,
1486 &Some(serde_json::json!({"command": "echo safe"}))
1487 );
1488 }
1489 other => panic!("expected Confirm, got {other:?}"),
1490 }
1491 let decision = crate::runtime::resolve_before_tool_call_decision(
1493 serde_json::json!({"command": "ls"}),
1494 result,
1495 None,
1496 true,
1497 None,
1498 )
1499 .await;
1500 assert!(matches!(
1501 decision,
1502 crate::runtime::BeforeToolCallDecision::Continue { input }
1503 if input == serde_json::json!({"command": "echo safe"})
1504 ));
1505 }
1506
1507 #[tokio::test]
1510 async fn modifiers_compose_and_final_input_is_returned() {
1511 let second = input_seen("second", modify("two"));
1512 let bus = bus_of(vec![input_seen("first", modify("one")), second.clone()]).await;
1513 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "zero"}));
1514 assert!(matches!(
1515 bus.emit(&event).await,
1516 HookResult::Modify { input } if input == serde_json::json!({"command": "two"})
1517 ));
1518 assert_eq!(
1519 second.seen.lock().unwrap().clone(),
1520 Some(serde_json::json!({"command": "one"}))
1521 );
1522 }
1523
1524 #[tokio::test]
1527 async fn reinstall_under_same_id_gets_a_new_confirm_generation() {
1528 let bus = bus_with(vec![TestHandler::new("jev", HookResult::confirm("ok?"))]).await;
1529 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1530 let gens = |r: HookResult| match r {
1531 HookResult::Confirm { generations, .. } => generations,
1532 other => panic!("{other:?}"),
1533 };
1534 assert_eq!(gens(bus.emit(&event).await), vec![0]);
1535 bus.unsubscribe_all("jev").await;
1536 bus.subscribe(
1537 HookKind::BeforeToolCall,
1538 TestHandler::new("jev", HookResult::confirm("ok?")),
1539 None,
1540 None,
1541 perms_with(&[Permission::ToolsIntercept]),
1542 )
1543 .await
1544 .unwrap();
1545 let latch = crate::tools::SessionConfirmLatch::new();
1546 latch.allow(&[("jev".to_string(), 0)]);
1547 let result = bus.emit(&event).await;
1548 let decision = crate::runtime::resolve_before_tool_call_decision(
1549 serde_json::json!({}),
1550 result.clone(),
1551 None,
1552 false,
1553 Some(&latch),
1554 )
1555 .await;
1556 assert_eq!(gens(result), vec![1]);
1557 assert!(
1558 matches!(decision, crate::runtime::BeforeToolCallDecision::Block { .. }),
1559 "old consent must not carry over to the reinstalled extension"
1560 );
1561 }
1562
1563 #[tokio::test]
1567 async fn modify_after_confirm_is_ignored() {
1568 let confirmer = TestHandler::new("confirmer", HookResult::confirm("Run?"));
1569 let modifier = TestHandler::new(
1570 "modifier",
1571 HookResult::Modify {
1572 input: serde_json::json!({"command": "echo other"}),
1573 },
1574 );
1575 let bus = bus_with(vec![confirmer, modifier]).await;
1576 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1577 assert!(matches!(
1578 bus.emit(&event).await,
1579 HookResult::Confirm { modified_input: None, .. }
1580 ), "Confirm→Modify: the modify is ignored (logged), the original input is confirmed");
1581 }
1582
1583 #[tokio::test]
1584 async fn confirm_is_stamped_with_source_extension_and_tool() {
1585 let bus = HookBus::new();
1586 let confirmer = TestHandler::new("jev", HookResult::confirm("Run ls?"));
1587 let perms = perms_with(&[Permission::ToolsIntercept]);
1588 bus.subscribe(HookKind::BeforeToolCall, confirmer, None, None, perms)
1589 .await
1590 .unwrap();
1591 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1592 match bus.emit(&event).await {
1593 HookResult::Confirm {
1594 message,
1595 sources,
1596 tool,
1597 ..
1598 } => {
1599 assert_eq!(message, "Run ls?");
1600 assert_eq!(sources, vec!["jev".to_string()]);
1601 assert_eq!(tool.as_deref(), Some("bash"));
1602 }
1603 other => panic!("expected Confirm, got {other:?}"),
1604 }
1605 }
1606
1607 #[tokio::test]
1608 async fn confirm_is_ignored_for_non_tool_hooks() {
1609 let bus = HookBus::new();
1610 let confirmer = TestHandler::new(
1611 "confirmer",
1612 HookResult::confirm("Not allowed here"),
1613 );
1614 let perms = perms_with(&[Permission::LlmContent]);
1615
1616 bus.subscribe(
1617 HookKind::BeforeMessage,
1618 confirmer.clone(),
1619 None,
1620 None,
1621 perms,
1622 )
1623 .await
1624 .unwrap();
1625
1626 let event = HookEvent::before_message("hello");
1627 let result = bus.emit(&event).await;
1628
1629 assert!(matches!(result, HookResult::Continue));
1630 assert_eq!(confirmer.calls(), 1);
1631 }
1632
1633 #[tokio::test]
1634 async fn block_stops_chain() {
1635 let bus = HookBus::new();
1636 let blocker = TestHandler::new(
1637 "blocker",
1638 HookResult::Block {
1639 reason: "dangerous".into(),
1640 },
1641 );
1642 let after = TestHandler::new("after", HookResult::Continue);
1643 let perms = perms_with(&[Permission::ToolsIntercept]);
1644
1645 bus.subscribe(
1646 HookKind::BeforeToolCall,
1647 blocker.clone(),
1648 None,
1649 None,
1650 perms.clone(),
1651 )
1652 .await
1653 .unwrap();
1654 bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
1655 .await
1656 .unwrap();
1657
1658 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1659 let result = bus.emit(&event).await;
1660
1661 assert!(matches!(result, HookResult::Block { .. }));
1662 assert_eq!(blocker.calls(), 1);
1663 assert_eq!(after.calls(), 0); }
1665
1666 #[tokio::test]
1669 async fn modify_does_not_stop_chain_later_block_wins() {
1670 let modifier = TestHandler::new(
1671 "modifier",
1672 HookResult::Modify {
1673 input: serde_json::json!({"command": "echo safe"}),
1674 },
1675 );
1676 let after = TestHandler::new(
1677 "after",
1678 HookResult::Block {
1679 reason: "vetoed".into(),
1680 },
1681 );
1682 let bus = bus_with(vec![modifier.clone(), after.clone()]).await;
1683 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "rm -rf /"}));
1684 let result = bus.emit(&event).await;
1685 assert!(matches!(result, HookResult::Block { reason } if reason == "vetoed"));
1686 assert_eq!(modifier.calls(), 1);
1687 assert_eq!(after.calls(), 1);
1688 }
1689
1690 #[tokio::test]
1691 async fn tool_filter_only_matches_specified_tool() {
1692 let bus = HookBus::new();
1693 let handler = TestHandler::new("bash-only", HookResult::Continue);
1694 let perms = perms_with(&[Permission::ToolsIntercept]);
1695
1696 bus.subscribe(
1697 HookKind::AfterToolCall,
1698 handler.clone(),
1699 Some("bash".into()),
1700 None,
1701 perms,
1702 )
1703 .await
1704 .unwrap();
1705
1706 let event = HookEvent::after_tool_call("read", serde_json::json!({}), "content".into());
1708 bus.emit(&event).await;
1709 assert_eq!(handler.calls(), 0);
1710
1711 let event = HookEvent::after_tool_call("bash", serde_json::json!({}), "output".into());
1713 bus.emit(&event).await;
1714 assert_eq!(handler.calls(), 1);
1715 }
1716
1717 #[tokio::test]
1721 async fn edit_and_write_filters_also_guard_multi_edit_before_the_call() {
1722 for filter in ["edit", "write"] {
1723 let bus = HookBus::new();
1724 let guard = TestHandler::new(
1725 "guard",
1726 HookResult::Block {
1727 reason: "guarded".into(),
1728 },
1729 );
1730 let after = TestHandler::new("after", HookResult::Continue);
1731 let perms = perms_with(&[Permission::ToolsIntercept]);
1732 bus.subscribe(HookKind::BeforeToolCall, guard.clone(), Some(filter.into()), None, perms.clone())
1733 .await
1734 .unwrap();
1735 bus.subscribe(HookKind::AfterToolCall, after.clone(), Some(filter.into()), None, perms)
1736 .await
1737 .unwrap();
1738 let input = serde_json::json!({"edits": [{"path": "/etc/hosts", "op": "append", "text": "x"}]});
1739 let blocked = bus.emit(&HookEvent::before_tool_call("multi_edit", input.clone())).await;
1740 assert!(matches!(blocked, HookResult::Block { reason } if reason == "guarded"), "{filter}");
1741 assert_eq!(guard.calls(), 1, "{filter}");
1742 let other = bus.emit(&HookEvent::before_tool_call("read", serde_json::json!({}))).await;
1743 assert!(matches!(other, HookResult::Continue), "{filter}: only the file-changing tool");
1744 bus.emit(&HookEvent::after_tool_call("multi_edit", input, "ok".into())).await;
1745 assert_eq!(after.calls(), 0, "{filter}: after_tool_call filters stay exact");
1746 }
1747 }
1748
1749 #[tokio::test]
1750 async fn permission_denied_rejects_subscribe() {
1751 let bus = HookBus::new();
1752 let handler = TestHandler::new("no-perms", HookResult::Continue);
1753 let perms = PermissionSet::new(); let result = bus
1756 .subscribe(HookKind::BeforeToolCall, handler, None, None, perms)
1757 .await;
1758
1759 assert!(result.is_err());
1760 assert!(result.unwrap_err().contains("lacks permission"));
1761 }
1762
1763 #[tokio::test]
1764 async fn unsubscribe_removes_handlers() {
1765 let bus = HookBus::new();
1766 let handler = TestHandler::new("removable", HookResult::Continue);
1767 let perms = perms_with(&[Permission::ToolsIntercept]);
1768
1769 bus.subscribe(HookKind::BeforeToolCall, handler.clone(), None, None, perms)
1770 .await
1771 .unwrap();
1772 assert_eq!(bus.handler_count().await, 1);
1773
1774 bus.unsubscribe_all("removable").await;
1775 assert_eq!(bus.handler_count().await, 0);
1776 }
1777
1778 #[tokio::test]
1779 async fn subscriptions_for_lists_only_matching_extension() {
1780 let bus = HookBus::new();
1781 let alpha = TestHandler::new("alpha", HookResult::Continue);
1782 let beta = TestHandler::new("beta", HookResult::Continue);
1783 let perms = perms_with(&[Permission::ToolsIntercept]);
1784
1785 bus.subscribe(
1786 HookKind::BeforeToolCall,
1787 alpha.clone(),
1788 Some("bash".into()),
1789 None,
1790 perms.clone(),
1791 )
1792 .await
1793 .unwrap();
1794 bus.subscribe(
1795 HookKind::AfterToolCall,
1796 alpha.clone(),
1797 None,
1798 None,
1799 perms.clone(),
1800 )
1801 .await
1802 .unwrap();
1803 bus.subscribe(HookKind::BeforeToolCall, beta.clone(), None, None, perms)
1804 .await
1805 .unwrap();
1806
1807 let alpha_subs = bus.subscriptions_for("alpha").await;
1808 assert_eq!(alpha_subs.len(), 2);
1809 assert_eq!(alpha_subs[0].0, HookKind::AfterToolCall);
1811 assert_eq!(alpha_subs[0].1, None);
1812 assert_eq!(alpha_subs[1].0, HookKind::BeforeToolCall);
1813 assert_eq!(alpha_subs[1].1, Some("bash".to_string()));
1814
1815 let beta_subs = bus.subscriptions_for("beta").await;
1816 assert_eq!(beta_subs, vec![(HookKind::BeforeToolCall, None)]);
1817
1818 let none_subs = bus.subscriptions_for("ghost").await;
1819 assert!(none_subs.is_empty());
1820 }
1821
1822 #[tokio::test]
1823 async fn is_empty_reflects_state() {
1824 let bus = HookBus::new();
1825 assert!(bus.is_empty().await);
1826
1827 let handler = TestHandler::new("ext", HookResult::Continue);
1828 let perms = perms_with(&[Permission::ToolsIntercept]);
1829 bus.subscribe(HookKind::BeforeToolCall, handler, None, None, perms)
1830 .await
1831 .unwrap();
1832 assert!(!bus.is_empty().await);
1833 }
1834
1835 #[cfg(feature = "command-exec")]
1840 #[derive(Debug)]
1841 struct RecordingHandler {
1842 id: String,
1843 recorded_len: Arc<AtomicUsize>,
1844 result: HookResult,
1845 }
1846 #[cfg(feature = "command-exec")]
1847 impl RecordingHandler {
1848 fn new(id: &str, result: HookResult) -> (Arc<Self>, Arc<AtomicUsize>) {
1849 let recorded_len = Arc::new(AtomicUsize::new(0));
1850 let h = Arc::new(Self {
1851 id: id.to_string(),
1852 recorded_len: recorded_len.clone(),
1853 result,
1854 });
1855 (h, recorded_len)
1856 }
1857 }
1858 #[cfg(feature = "command-exec")]
1859 #[async_trait]
1860 impl crate::extensions::runtime::ExtensionHandler for RecordingHandler {
1861 fn id(&self) -> &str {
1862 &self.id
1863 }
1864 async fn handle(&self, event: &HookEvent) -> HookResult {
1865 if let Some(out) = event.tool_output.as_ref() {
1866 self.recorded_len.store(out.len(), Ordering::Relaxed);
1867 }
1868 self.result.clone()
1869 }
1870 async fn shutdown(&self) {}
1871 }
1872
1873 #[cfg(feature = "command-exec")]
1875 #[tokio::test]
1876 async fn after_tool_call_transform_receives_full_output_not_pre_truncated() {
1877 use crate::tools::Tool;
1878 let bus = std::sync::Arc::new(HookBus::new());
1879 let (handler, recorded_len) = RecordingHandler::new("len-recorder", HookResult::Continue);
1880 bus.subscribe(
1881 HookKind::AfterToolCall,
1882 handler,
1883 None,
1884 None,
1885 perms_with(&[Permission::ToolsIntercept]),
1886 )
1887 .await
1888 .unwrap();
1889
1890 let ctx = crate::tools::ToolContext {
1891 channels: crate::tools::ToolChannels {
1892 tx_delta: None,
1893 tx_events: None,
1894 },
1895 capabilities: crate::tools::ToolCapabilities {
1896 session_allow_all: None,
1897 launch_cancel: None,
1898 memory_backend: None,
1899 telemetry: None,
1900 watcher_exit_path: None,
1901 tool_register_tx: None,
1902 session_manager: None,
1903 subagent_registry: None,
1904 event_queue: None,
1905 delegation_parent: None,
1906 codex_parent_plan: None,
1907 secret_prompt: None,
1908 orchestration: None,
1909 tool_activation: None,
1910 mcp_leases: None,
1911 extension_leases: None,
1912 memory_context: None,
1913 cwd: None,
1914 env: None,
1915 env_stripped: Vec::new(),
1916 env_warned: Default::default(),
1917 session_id: None,
1918 },
1919 limits: crate::tools::ToolLimits {
1920 max_tool_output: 30_000,
1921 max_tool_buffer: 256 * 1024,
1922 bash_timeout: 30,
1923 bash_max_timeout: 300,
1924 subagent_timeout: 300,
1925 },
1926 };
1927
1928 let bash = crate::tools::BashTool;
1929 let output = bash
1930 .execute(
1931 serde_json::json!({"command": "yes hello | head -c 150000"}),
1932 ctx,
1933 )
1934 .await
1935 .expect("bash should succeed");
1936
1937 let _ = crate::runtime::emit_after_tool_call(
1938 &bus,
1939 "bash",
1940 None,
1941 serde_json::json!({"command": "yes hello | head -c 150000"}),
1942 output,
1943 30_000,
1944 None,
1945 )
1946 .await;
1947
1948 let len = recorded_len.load(Ordering::Relaxed);
1949 assert!(
1950 len > 100_000,
1951 "after_tool_call handler was starved: received {len} bytes — \
1952 expected the full ~150KB buffered output (max_tool_buffer=256KB)"
1953 );
1954 }
1955
1956 #[tokio::test]
1960 #[serial_test::serial(synaps_base_dir)]
1961 async fn final_output_truncated_to_max_tool_output_after_hook() {
1962 let _base = crate::test_env::BaseDirGuard::new();
1964 let bus = std::sync::Arc::new(HookBus::new());
1965 let big: String = "x".repeat(100_000);
1966 let handler = TestHandler::new("bloater", HookResult::Replace { output: big });
1967 bus.subscribe(
1968 HookKind::AfterToolCall,
1969 handler,
1970 None,
1971 None,
1972 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
1973 )
1974 .await
1975 .unwrap();
1976
1977 let result = crate::runtime::emit_after_tool_call(
1978 &bus,
1979 "bash",
1980 None,
1981 serde_json::json!({}),
1982 "tiny original".to_string(),
1983 30_000,
1984 None,
1985 )
1986 .await;
1987
1988 assert!(
1989 result.len() <= 30_000 + 200,
1990 "post-hook output should be capped at max_tool_output (got {} bytes)",
1991 result.len()
1992 );
1993 }
1994
1995 #[tokio::test]
1998 async fn no_transform_extension_preserves_legacy_truncation() {
1999 let bus = std::sync::Arc::new(HookBus::new());
2000 let huge: String = "y".repeat(80_000);
2001
2002 let result = crate::runtime::emit_after_tool_call(
2003 &bus,
2004 "bash",
2005 None,
2006 serde_json::json!({}),
2007 huge.clone(),
2008 30_000,
2009 None,
2010 )
2011 .await;
2012
2013 let legacy = crate::runtime::helpers::HelperMethods::truncate_tool_result(&huge, 30_000);
2014 assert_eq!(
2015 result, legacy,
2016 "no-extension path must be byte-identical to legacy truncation"
2017 );
2018 }
2019
2020 #[tokio::test]
2023 #[serial_test::serial(synaps_base_dir)]
2024 async fn emit_after_tool_call_truncates_char_boundary_safe() {
2025 let _base = crate::test_env::BaseDirGuard::new();
2027 let bus = std::sync::Arc::new(HookBus::new());
2029 let multibyte: String = "héllo🌟".repeat(5_000);
2030 assert!(multibyte.len() > 30_000);
2031
2032 let result = crate::runtime::emit_after_tool_call(
2033 &bus,
2034 "bash",
2035 None,
2036 serde_json::json!({}),
2037 multibyte,
2038 30_000,
2039 None,
2040 )
2041 .await;
2042 assert!(result.is_char_boundary(result.len()));
2043
2044 let bus2 = std::sync::Arc::new(HookBus::new());
2046 let big_multi = "✨".repeat(20_000);
2047 assert!(big_multi.len() > 30_000);
2048 let handler = TestHandler::new("mb-replacer", HookResult::Replace { output: big_multi });
2049 bus2.subscribe(
2050 HookKind::AfterToolCall,
2051 handler,
2052 None,
2053 None,
2054 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
2055 )
2056 .await
2057 .unwrap();
2058
2059 let result2 = crate::runtime::emit_after_tool_call(
2060 &bus2,
2061 "bash",
2062 None,
2063 serde_json::json!({}),
2064 "orig".to_string(),
2065 30_000,
2066 None,
2067 )
2068 .await;
2069 assert!(result2.is_char_boundary(result2.len()));
2070 }
2071
2072 #[tokio::test]
2073 async fn keyed_injection_isolated_per_session() {
2074 let bus = HookBus::new();
2075 bus.set_session_injection_for("s1", "one".into()).await;
2076 bus.set_session_injection_for("s2", "two".into()).await;
2077 assert_eq!(bus.session_injection_for("s1").await.as_deref(), Some("one"));
2078 assert_eq!(bus.session_injection_for("s2").await.as_deref(), Some("two"));
2079 assert_eq!(bus.session_injection_for("s3").await, None);
2080 }
2081
2082 #[tokio::test]
2083 async fn clear_removes_only_that_session() {
2084 let bus = HookBus::new();
2085 bus.set_session_injection_for("s1", "one".into()).await;
2086 bus.set_session_injection_for("s2", "two".into()).await;
2087 bus.clear_session_injection("s1").await;
2088 assert_eq!(bus.session_injection_for("s1").await, None);
2089 assert_eq!(bus.session_injection_for("s2").await.as_deref(), Some("two"));
2090 }
2091
2092 #[tokio::test]
2093 #[allow(deprecated)]
2094 async fn deprecated_shims_use_empty_key() {
2095 let bus = HookBus::new();
2096 bus.set_session_injection("legacy".into()).await;
2097 assert_eq!(bus.session_injection().await.as_deref(), Some("legacy"));
2098 assert_eq!(bus.session_injection_for("").await.as_deref(), Some("legacy"));
2099 assert_eq!(bus.session_injection_for("s1").await, None);
2100 }
2101}
2102
2103#[cfg(test)]
2107mod worker_view_tests {
2108 use super::*;
2109 use crate::extensions::permissions::Permission;
2110 use async_trait::async_trait;
2111 use std::sync::atomic::{AtomicUsize, Ordering};
2112
2113 struct Probe {
2118 id: String,
2119 result: HookResult,
2120 tag_output: Option<String>,
2121 delay: Duration,
2122 handled: AtomicUsize,
2123 handled_no_spawn: AtomicUsize,
2124 }
2125
2126 impl Probe {
2127 fn build(id: &str, result: HookResult, tag: Option<&str>, delay: Duration) -> Arc<Self> {
2128 Arc::new(Self {
2129 id: id.into(),
2130 result,
2131 tag_output: tag.map(str::to_string),
2132 delay,
2133 handled: AtomicUsize::new(0),
2134 handled_no_spawn: AtomicUsize::new(0),
2135 })
2136 }
2137
2138 fn new(id: &str, result: HookResult) -> Arc<Self> {
2139 Self::build(id, result, None, Duration::ZERO)
2140 }
2141
2142 fn tagging(id: &str, tag: &str, delay: Duration) -> Arc<Self> {
2143 Self::build(id, HookResult::Continue, Some(tag), delay)
2144 }
2145
2146 fn calls(&self) -> (usize, usize) {
2147 (
2148 self.handled.load(Ordering::SeqCst),
2149 self.handled_no_spawn.load(Ordering::SeqCst),
2150 )
2151 }
2152
2153 async fn answer(&self, event: &HookEvent) -> HookResult {
2154 if !self.delay.is_zero() {
2155 tokio::time::sleep(self.delay).await;
2156 }
2157 match &self.tag_output {
2158 Some(tag) => HookResult::Replace {
2159 output: format!("{tag}:{}", event.tool_output.as_deref().unwrap_or("")),
2160 },
2161 None => self.result.clone(),
2162 }
2163 }
2164 }
2165
2166 #[async_trait]
2167 impl crate::extensions::runtime::ExtensionHandler for Probe {
2168 fn id(&self) -> &str {
2169 &self.id
2170 }
2171 async fn handle(&self, event: &HookEvent) -> HookResult {
2172 self.handled.fetch_add(1, Ordering::SeqCst);
2173 self.answer(event).await
2174 }
2175 async fn handle_no_spawn(&self, event: &HookEvent) -> HookResult {
2176 self.handled_no_spawn.fetch_add(1, Ordering::SeqCst);
2177 self.answer(event).await
2178 }
2179 async fn shutdown(&self) {}
2180 }
2181
2182 fn perms(perms: &[Permission]) -> PermissionSet {
2183 let mut set = PermissionSet::new();
2184 for p in perms {
2185 set.grant(*p);
2186 }
2187 set
2188 }
2189
2190 fn transform() -> PermissionSet {
2191 perms(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput])
2192 }
2193
2194 async fn after(bus: &Arc<HookBus>, output: &str) -> String {
2195 crate::runtime::emit_after_tool_call(
2196 bus,
2197 "bash",
2198 None,
2199 serde_json::json!({"command": "cat big.log"}),
2200 output.to_string(),
2201 30_000,
2202 None,
2203 )
2204 .await
2205 }
2206
2207 async fn host_with_every_kind() -> (Arc<HookBus>, Vec<Arc<Probe>>, Arc<Probe>) {
2211 let host = Arc::new(HookBus::new());
2212 let confirm = Probe::new(
2213 "confirmer",
2214 HookResult::Confirm {
2215 message: "really?".into(),
2216 sources: vec![],
2217 generations: vec![],
2218 tool: None,
2219 modified_input: None,
2220 modified_by: vec![],
2221 },
2222 );
2223 let block = Probe::new("blocker", HookResult::Block { reason: "no".into() });
2224 let inject = Probe::new("injector", HookResult::Inject { content: "ctx".into() });
2225 let observer = Probe::new("observer", HookResult::Replace { output: "SNEAKY".into() });
2226 let crush = Probe::new("crush", HookResult::Replace { output: "CRUSHED".into() });
2227 let intercept = perms(&[Permission::ToolsIntercept]);
2228 host.subscribe(HookKind::BeforeToolCall, confirm.clone(), None, None, intercept.clone())
2229 .await
2230 .unwrap();
2231 host.subscribe(HookKind::BeforeToolCall, block.clone(), None, None, transform())
2232 .await
2233 .unwrap();
2234 host.subscribe(
2235 HookKind::BeforeMessage,
2236 inject.clone(),
2237 None,
2238 None,
2239 perms(&[Permission::LlmContent, Permission::ToolsTransformOutput]),
2240 )
2241 .await
2242 .unwrap();
2243 host.subscribe(HookKind::AfterToolCall, observer.clone(), None, None, intercept)
2245 .await
2246 .unwrap();
2247 host.subscribe(HookKind::AfterToolCall, crush.clone(), None, None, transform())
2248 .await
2249 .unwrap();
2250 (host, vec![confirm, block, inject, observer], crush)
2251 }
2252
2253 #[tokio::test]
2254 #[serial_test::serial(synaps_base_dir)]
2255 async fn a_worker_runs_only_the_hosts_after_tool_call_transforms() {
2256 let _base = crate::test_env::BaseDirGuard::new();
2257 let (host, others, crush) = host_with_every_kind().await;
2258 let worker = HookBus::worker_transforms(&host);
2259 assert!(worker.is_worker_view() && !host.is_worker_view());
2260
2261 assert_eq!(after(&worker, "RAW 10k lines").await, "CRUSHED");
2263 assert_eq!(crush.calls(), (0, 1), "through handle_no_spawn");
2264 let before = crate::runtime::emit_before_tool_call(
2266 &worker,
2267 "bash",
2268 None,
2269 serde_json::json!({"command": "rm -rf /"}),
2270 None,
2271 )
2272 .await;
2273 assert_eq!(before, HookResult::Continue);
2274 let decision = crate::runtime::resolve_before_tool_call_decision(
2275 serde_json::json!({"command": "rm -rf /"}),
2276 before,
2277 None,
2278 false,
2279 None,
2280 )
2281 .await;
2282 assert!(matches!(decision, crate::runtime::BeforeToolCallDecision::Continue { .. }));
2283 for kind in [HookKind::BeforeMessage, HookKind::OnMessageComplete, HookKind::OnSessionStart] {
2284 let mut event = HookEvent::after_tool_call("bash", serde_json::json!({}), "x".into());
2285 event.kind = kind;
2286 assert_eq!(worker.emit(&event).await, HookResult::Continue, "{kind:?}");
2287 }
2288 for probe in &others {
2289 assert_eq!(probe.calls(), (0, 0), "{} never reached from a worker", probe.id);
2290 }
2291 assert_eq!(worker.handler_count().await, 1, "the one transform");
2292 assert!(!worker.is_empty().await);
2293
2294 assert_eq!(after(&host, "RAW").await, "CRUSHED");
2296 assert_eq!(others[3].calls(), (1, 0));
2297 assert_eq!(crush.calls(), (1, 1));
2298 }
2299
2300 #[tokio::test]
2304 async fn an_extension_without_transform_output_cannot_replace_a_workers_output() {
2305 let host = Arc::new(HookBus::new());
2306 let observer = Probe::new("observer", HookResult::Replace { output: "SNEAKY".into() });
2307 host.subscribe(
2308 HookKind::AfterToolCall,
2309 observer.clone(),
2310 None,
2311 None,
2312 perms(&[Permission::ToolsIntercept]),
2313 )
2314 .await
2315 .unwrap();
2316 let worker = HookBus::worker_transforms(&host);
2317 assert_eq!(after(&worker, "RAW").await, "RAW");
2318 assert_eq!(observer.calls(), (0, 0));
2319 assert!(worker.is_empty().await);
2320 assert_eq!(after(&host, "RAW").await, "RAW");
2322 assert_eq!(observer.calls(), (1, 0));
2323 }
2324
2325 #[tokio::test(start_paused = true)]
2329 #[serial_test::serial(synaps_base_dir)]
2330 async fn a_timed_out_or_crashed_transform_leaves_the_original_output() {
2331 let _base = crate::test_env::BaseDirGuard::new();
2332 let host = Arc::new(HookBus::new());
2333 let slow = Probe::tagging("slow", "SLOW", Duration::from_secs(60));
2334 host.subscribe(HookKind::AfterToolCall, slow.clone(), None, None, transform())
2335 .await
2336 .unwrap();
2337 let worker = HookBus::worker_transforms(&host);
2338 let started = tokio::time::Instant::now();
2339 assert_eq!(after(&worker, "RAW").await, "RAW", "timed out → original");
2340 let waited = started.elapsed();
2341 assert!(
2342 waited >= HANDLER_TIMEOUT && waited < HANDLER_TIMEOUT + Duration::from_secs(1),
2343 "bounded by the hook timeout: {waited:?}"
2344 );
2345
2346 let crashed = Probe::new("crashed", HookResult::Continue);
2347 let next = Probe::tagging("next", "NEXT", Duration::ZERO);
2348 let host = Arc::new(HookBus::new());
2349 host.subscribe(HookKind::AfterToolCall, crashed.clone(), None, None, transform())
2350 .await
2351 .unwrap();
2352 let worker = HookBus::worker_transforms(&host);
2353 assert_eq!(after(&worker, "RAW").await, "RAW", "crashed → original");
2354 host.subscribe(HookKind::AfterToolCall, next.clone(), None, None, transform())
2355 .await
2356 .unwrap();
2357 assert_eq!(after(&worker, "RAW").await, "NEXT:RAW", "the chain continues");
2358 }
2359
2360 #[tokio::test]
2364 #[serial_test::serial(synaps_base_dir)]
2365 async fn a_worker_sees_extension_loads_reloads_and_unloads_live() {
2366 let _base = crate::test_env::BaseDirGuard::new();
2367 let host = Arc::new(HookBus::new());
2368 let worker = HookBus::worker_transforms(&host);
2369 assert_eq!(after(&worker, "RAW").await, "RAW", "nothing loaded yet");
2370
2371 let v1 = Probe::tagging("crush", "V1", Duration::ZERO);
2372 host.subscribe(HookKind::AfterToolCall, v1.clone(), None, None, transform())
2373 .await
2374 .unwrap();
2375 assert_eq!(after(&worker, "RAW").await, "V1:RAW", "loaded after the worker started");
2376
2377 host.unsubscribe_all("crush").await;
2379 assert_eq!(after(&worker, "RAW").await, "RAW", "mid-reload: pass-through");
2380 let v2 = Probe::tagging("crush", "V2", Duration::ZERO);
2381 host.subscribe(HookKind::AfterToolCall, v2.clone(), None, None, transform())
2382 .await
2383 .unwrap();
2384 assert_eq!(after(&worker, "RAW").await, "V2:RAW");
2385 assert_eq!(v1.calls(), (0, 1), "the stopped instance is never called again");
2386
2387 host.unsubscribe_all("crush").await;
2388 assert_eq!(after(&worker, "RAW").await, "RAW", "unloaded");
2389 }
2390
2391 #[test]
2395 #[serial_test::serial(synaps_base_dir)]
2396 fn parallel_workers_each_get_their_own_replacement() {
2397 let _base = crate::test_env::BaseDirGuard::new();
2398 let crush = Probe::tagging("crush", "CRUSHED", Duration::from_millis(2));
2399 let host = Arc::new(HookBus::new());
2400 tokio::runtime::Builder::new_current_thread()
2401 .enable_all()
2402 .build()
2403 .unwrap()
2404 .block_on(host.subscribe(HookKind::AfterToolCall, crush.clone(), None, None, transform()))
2405 .unwrap();
2406 let threads: Vec<_> = (0..8)
2407 .map(|w| {
2408 let worker = HookBus::worker_transforms(&host);
2409 std::thread::spawn(move || {
2410 let rt = tokio::runtime::Builder::new_current_thread()
2411 .enable_all()
2412 .build()
2413 .unwrap();
2414 rt.block_on(async {
2415 for call in 0..10 {
2416 let output = format!("worker {w} call {call}");
2417 assert_eq!(after(&worker, &output).await, format!("CRUSHED:{output}"));
2418 }
2419 });
2420 })
2421 })
2422 .collect();
2423 for t in threads {
2424 t.join().expect("worker thread");
2425 }
2426 assert_eq!(crush.calls(), (0, 80));
2427 }
2428
2429 #[tokio::test]
2430 async fn a_view_is_read_only_and_a_view_of_a_view_reads_the_host() {
2431 let host = Arc::new(HookBus::new());
2432 let worker = HookBus::worker_transforms(&host);
2433 let probe = Probe::tagging("crush", "C", Duration::ZERO);
2434 let err = worker
2435 .subscribe(HookKind::AfterToolCall, probe.clone(), None, None, transform())
2436 .await
2437 .unwrap_err();
2438 assert!(err.contains("worker"), "{err}");
2439 let nested = HookBus::worker_transforms(&worker);
2440 assert!(nested.is_worker_view());
2441 assert!(Arc::ptr_eq(nested.transforms_of.as_ref().unwrap(), &host));
2442 assert_eq!(nested.handler_count().await, 0);
2443 }
2444}