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 }
42}
43
44#[derive(Clone)]
46pub struct HandlerRegistration {
47 pub handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
49 pub tool_filter: Option<String>,
51 pub matcher: Option<HookMatcher>,
53 pub permissions: PermissionSet,
55}
56
57pub struct HookBus {
62 handlers: RwLock<HashMap<HookKind, Vec<HandlerRegistration>>>,
63}
64
65impl HookBus {
66 pub fn new() -> Self {
68 Self {
69 handlers: RwLock::new(HashMap::new()),
70 }
71 }
72
73 pub async fn subscribe(
78 &self,
79 kind: HookKind,
80 handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
81 tool_filter: Option<String>,
82 matcher: Option<HookMatcher>,
83 permissions: PermissionSet,
84 ) -> Result<(), String> {
85 if !permissions.allows_hook(kind) {
87 return Err(format!(
88 "Extension '{}' lacks permission '{}' required for hook '{}'",
89 handler.id(),
90 kind.required_permission().as_str(),
91 kind.as_str(),
92 ));
93 }
94
95 let reg = HandlerRegistration {
96 handler,
97 tool_filter,
98 matcher,
99 permissions,
100 };
101
102 let mut handlers = self.handlers.write().await;
103 handlers.entry(kind).or_default().push(reg);
104 Ok(())
105 }
106
107 pub async fn emit(&self, event: &HookEvent) -> HookResult {
115 let registrations = {
120 let handlers = self.handlers.read().await;
121 match handlers.get(&event.kind) {
122 Some(regs) if !regs.is_empty() => regs.clone(),
123 _ => return HookResult::Continue, }
125 }; let mut injections: Vec<String> = Vec::new();
129
130 for reg in ®istrations {
131 if let Some(ref filter) = reg.tool_filter {
135 let matches = match (&event.tool_name, &event.tool_runtime_name) {
136 (Some(api), Some(runtime)) => filter == api || filter == runtime,
137 (Some(api), None) => filter == api,
138 (None, Some(runtime)) => filter == runtime,
139 (None, None) => false,
140 };
141 if !matches {
142 continue;
143 }
144 }
145
146 if let Some(ref matcher) = reg.matcher {
147 if !matcher.matches(event) {
148 continue;
149 }
150 }
151
152 let handler = reg.handler.clone();
154 let event_clone = event.clone();
155 let trace_enabled = extensions_trace_enabled();
156 let started_at = trace_enabled.then(Instant::now);
157 let result = tokio::time::timeout(HANDLER_TIMEOUT, handler.handle(&event_clone)).await;
158
159 if trace_enabled {
160 let health = reg.handler.health().await;
161 let health = health.as_str();
162 let restart_count = reg.handler.restart_count().await;
163 let duration_ms = started_at
164 .map(|start| start.elapsed().as_millis() as u64)
165 .unwrap_or(0);
166 match &result {
167 Ok(hook_result) => {
168 let action = hook_result_action(hook_result);
169 tracing::info!(
170 extension_trace = true,
171 hook = %event.kind.as_str(),
172 extension = %reg.handler.id(),
173 action = action,
174 duration_ms = duration_ms,
175 health = health,
176 restart_count = restart_count,
177 "Extension hook trace"
178 );
179 }
180 Err(_) => {
181 tracing::warn!(
182 extension_trace = true,
183 hook = %event.kind.as_str(),
184 extension = %reg.handler.id(),
185 action = "timeout",
186 duration_ms = duration_ms,
187 timeout_secs = HANDLER_TIMEOUT.as_secs(),
188 health = health,
189 restart_count = restart_count,
190 "Extension hook trace"
191 );
192 }
193 }
194 }
195
196 match result {
197 Ok(result) if !event.kind.allows_result(&result) => {
198 tracing::warn!(
199 hook = %event.kind.as_str(),
200 extension = %reg.handler.id(),
201 action = hook_result_action(&result),
202 "Extension returned action not allowed for hook — ignoring"
203 );
204 continue;
205 }
206 Ok(HookResult::Block { reason }) => {
207 tracing::info!(
208 hook = %event.kind.as_str(),
209 extension = %reg.handler.id(),
210 reason = %reason,
211 "Hook blocked by extension"
212 );
213 return HookResult::Block { reason };
214 }
215 Ok(HookResult::Continue) => {}
216 Ok(HookResult::Inject { content }) => {
217 tracing::debug!(
218 hook = %event.kind.as_str(),
219 extension = %reg.handler.id(),
220 len = content.len(),
221 "Extension injected context"
222 );
223 injections.push(content);
225 }
226 Ok(HookResult::Modify { input }) => {
227 tracing::info!(
228 hook = %event.kind.as_str(),
229 extension = %reg.handler.id(),
230 "Hook modified tool input by extension"
231 );
232 return HookResult::Modify { input };
233 }
234 Ok(HookResult::Replace { output }) => {
235 if !reg.permissions.has(Permission::ToolsTransformOutput) {
241 tracing::warn!(
242 hook = %event.kind.as_str(),
243 extension = %reg.handler.id(),
244 "Extension returned Replace without tools.transform_output permission — ignoring transform"
245 );
246 continue;
247 }
248 let observed_len = event.tool_output.as_ref().map_or(0, String::len);
249 tracing::info!(
250 hook = %event.kind.as_str(),
251 extension = %reg.handler.id(),
252 observed_len,
253 replacement_len = output.len(),
254 "Hook replaced tool output by extension"
255 );
256 let _ = crate::extensions::audit::record_tool_output_replace(
260 reg.handler.id(),
261 event.kind.as_str(),
262 event.tool_name.clone(),
263 observed_len,
264 output.len(),
265 );
266 return HookResult::Replace { output };
268 }
269 Ok(HookResult::Confirm { message }) => {
270 tracing::info!(
271 hook = %event.kind.as_str(),
272 extension = %reg.handler.id(),
273 "Hook requested confirmation by extension"
274 );
275 return HookResult::Confirm { message };
276 }
277 Err(_timeout) => {
278 tracing::warn!(
279 hook = %event.kind.as_str(),
280 extension = %reg.handler.id(),
281 timeout_secs = HANDLER_TIMEOUT.as_secs(),
282 "Hook handler timed out — skipping"
283 );
284 }
286 }
287 }
288
289 if !injections.is_empty() {
291 HookResult::Inject {
292 content: injections.join("\n\n"),
293 }
294 } else {
295 HookResult::Continue
296 }
297 }
298
299 pub async fn emit_concurrent(&self, event: &HookEvent) -> HookResult {
323 assert!(
326 !matches!(
327 event.kind,
328 HookKind::BeforeToolCall | HookKind::AfterToolCall | HookKind::BeforeMessage
329 ),
330 "emit_concurrent must not be used for transform-capable hooks \
331 ({:?}); their Block/Modify/Replace/Inject results need the \
332 deterministic first-wins ordering of the sequential emit()",
333 event.kind,
334 );
335 let registrations = {
337 let handlers = self.handlers.read().await;
338 match handlers.get(&event.kind) {
339 Some(regs) if !regs.is_empty() => regs.clone(),
340 _ => return HookResult::Continue, }
342 };
343
344 let futures: Vec<_> =
346 registrations
347 .iter()
348 .filter(|reg| {
349 if let Some(ref filter) = reg.tool_filter {
351 match (&event.tool_name, &event.tool_runtime_name) {
352 (Some(api), Some(runtime)) => filter == api || filter == runtime,
353 (Some(api), None) => filter == api,
354 (None, Some(runtime)) => filter == runtime,
355 (None, None) => false,
356 }
357 } else {
358 true
359 }
360 })
361 .filter(|reg| reg.matcher.as_ref().map_or(true, |m| m.matches(event)))
362 .map(|reg| {
363 let handler = reg.handler.clone();
364 let event_clone = event.clone();
365 async move {
366 tokio::time::timeout(HANDLER_TIMEOUT, handler.handle(&event_clone)).await
367 }
368 })
369 .collect();
370
371 let results = join_all(futures).await;
372
373 let mut injections: Vec<String> = Vec::new();
374 for result in results {
375 match result {
376 Ok(HookResult::Continue) => {}
377 Ok(HookResult::Block { reason }) => {
378 return HookResult::Block { reason };
379 }
380 Ok(HookResult::Inject { content }) => {
381 injections.push(content);
382 }
383 Ok(HookResult::Modify { input }) => {
384 return HookResult::Modify { input };
385 }
386 Ok(HookResult::Replace { output }) => {
387 return HookResult::Replace { output };
388 }
389 Ok(HookResult::Confirm { message }) => {
390 return HookResult::Confirm { message };
391 }
392 Err(_timeout) => {
393 tracing::warn!(
394 hook = %event.kind.as_str(),
395 timeout_secs = HANDLER_TIMEOUT.as_secs(),
396 "Hook handler timed out in concurrent emit — skipping"
397 );
398 }
399 }
400 }
401
402 if !injections.is_empty() {
403 HookResult::Inject {
404 content: injections.join("\n\n"),
405 }
406 } else {
407 HookResult::Continue
408 }
409 }
410
411 pub async fn unsubscribe_all(&self, extension_id: &str) {
413 let mut handlers = self.handlers.write().await;
414 for regs in handlers.values_mut() {
415 regs.retain(|r| r.handler.id() != extension_id);
416 }
417 }
418
419 pub async fn handler_count(&self) -> usize {
421 let handlers = self.handlers.read().await;
422 handlers.values().map(|v| v.len()).sum()
423 }
424
425 pub async fn is_empty(&self) -> bool {
427 let handlers = self.handlers.read().await;
428 handlers.values().all(|v| v.is_empty())
429 }
430
431 pub async fn subscriptions_for(&self, extension_id: &str) -> Vec<(HookKind, Option<String>)> {
434 let handlers = self.handlers.read().await;
435 let mut out: Vec<(HookKind, Option<String>)> = Vec::new();
436 for (kind, regs) in handlers.iter() {
437 for reg in regs {
438 if reg.handler.id() == extension_id {
439 out.push((*kind, reg.tool_filter.clone()));
440 }
441 }
442 }
443 out.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()).then_with(|| a.1.cmp(&b.1)));
444 out
445 }
446}
447
448impl Default for HookBus {
449 fn default() -> Self {
450 Self::new()
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use crate::extensions::hooks::events::HookEvent;
458 use crate::extensions::permissions::Permission;
459 use async_trait::async_trait;
460 use std::sync::atomic::{AtomicUsize, Ordering};
461
462 struct TestHandler {
464 id: String,
465 call_count: AtomicUsize,
466 result: HookResult,
467 }
468
469 impl TestHandler {
470 fn new(id: &str, result: HookResult) -> Arc<Self> {
471 Arc::new(Self {
472 id: id.to_string(),
473 call_count: AtomicUsize::new(0),
474 result,
475 })
476 }
477
478 fn calls(&self) -> usize {
479 self.call_count.load(Ordering::Relaxed)
480 }
481 }
482
483 #[async_trait]
484 impl crate::extensions::runtime::ExtensionHandler for TestHandler {
485 fn id(&self) -> &str {
486 &self.id
487 }
488
489 async fn handle(&self, _event: &HookEvent) -> HookResult {
490 self.call_count.fetch_add(1, Ordering::Relaxed);
491 self.result.clone()
492 }
493
494 async fn shutdown(&self) {}
495 }
496
497 fn perms_with(perms: &[Permission]) -> PermissionSet {
498 let mut set = PermissionSet::new();
499 for p in perms {
500 set.grant(*p);
501 }
502 set
503 }
504
505 #[tokio::test]
509 async fn after_tool_call_replace_substitutes_recorded_output() {
510 let bus = std::sync::Arc::new(HookBus::new());
511 let handler = TestHandler::new(
512 "compressor",
513 HookResult::Replace {
514 output: "COMPRESSED".into(),
515 },
516 );
517 bus.subscribe(
518 HookKind::AfterToolCall,
519 handler,
520 None,
521 None,
522 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
523 )
524 .await
525 .unwrap();
526
527 let recorded = crate::runtime::emit_after_tool_call(
528 &bus,
529 "bash",
530 None,
531 serde_json::json!({"command": "cat huge.log"}),
532 "RAW 10k lines".to_string(),
533 30_000,
534 )
535 .await;
536
537 assert_eq!(recorded, "COMPRESSED", "Replace output must reach history");
538 }
539
540 #[tokio::test]
542 async fn after_tool_call_without_replace_keeps_original_output() {
543 let bus = std::sync::Arc::new(HookBus::new());
544 let recorded = crate::runtime::emit_after_tool_call(
545 &bus,
546 "bash",
547 None,
548 serde_json::json!({}),
549 "RAW".to_string(),
550 30_000,
551 )
552 .await;
553
554 assert_eq!(recorded, "RAW", "no Replace → original output preserved");
555 }
556
557 #[tokio::test]
561 async fn replace_stops_chain_for_after_tool_call() {
562 let bus = HookBus::new();
563 let first = TestHandler::new(
564 "first",
565 HookResult::Replace {
566 output: "FIRST".into(),
567 },
568 );
569 let second = TestHandler::new(
570 "second",
571 HookResult::Replace {
572 output: "SECOND".into(),
573 },
574 );
575 let perms = perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]);
576
577 bus.subscribe(
578 HookKind::AfterToolCall,
579 first.clone(),
580 None,
581 None,
582 perms.clone(),
583 )
584 .await
585 .unwrap();
586 bus.subscribe(HookKind::AfterToolCall, second.clone(), None, None, perms)
587 .await
588 .unwrap();
589
590 let event = HookEvent::after_tool_call("bash", serde_json::json!({}), "RAW".to_string());
591 let result = bus.emit(&event).await;
592
593 assert_eq!(
594 result,
595 HookResult::Replace {
596 output: "FIRST".into()
597 }
598 );
599 assert_eq!(first.calls(), 1);
600 assert_eq!(second.calls(), 0); }
602
603 #[tokio::test]
607 async fn replace_without_transform_permission_is_ignored() {
608 let bus = std::sync::Arc::new(HookBus::new());
609 let handler = TestHandler::new(
610 "observer-only",
611 HookResult::Replace {
612 output: "SNEAKY".into(),
613 },
614 );
615 bus.subscribe(
617 HookKind::AfterToolCall,
618 handler.clone(),
619 None,
620 None,
621 perms_with(&[Permission::ToolsIntercept]),
622 )
623 .await
624 .unwrap();
625
626 let recorded = crate::runtime::emit_after_tool_call(
627 &bus,
628 "bash",
629 None,
630 serde_json::json!({}),
631 "ORIGINAL".to_string(),
632 30_000,
633 )
634 .await;
635
636 assert_eq!(handler.calls(), 1, "handler still runs (it's subscribed)");
637 assert_eq!(
638 recorded, "ORIGINAL",
639 "Replace without tools.transform_output must be ignored — original preserved"
640 );
641 }
642
643 #[test]
644 fn trace_env_value_parser_accepts_common_truthy_values() {
645 for value in ["1", "true", "TRUE", "yes", "on"] {
646 std::env::set_var("SYNAPS_EXTENSIONS_TRACE", value);
647 assert!(
648 extensions_trace_enabled(),
649 "{value} should enable trace mode"
650 );
651 }
652
653 for value in ["", "0", "false", "off", "no"] {
654 std::env::set_var("SYNAPS_EXTENSIONS_TRACE", value);
655 assert!(
656 !extensions_trace_enabled(),
657 "{value:?} should not enable trace mode"
658 );
659 }
660 std::env::remove_var("SYNAPS_EXTENSIONS_TRACE");
661 }
662
663 #[tokio::test]
664 async fn matcher_skips_handler_when_input_does_not_contain_value() {
665 let bus = HookBus::new();
666 let handler = TestHandler::new(
667 "matcher",
668 HookResult::Block {
669 reason: "matched".into(),
670 },
671 );
672 let mut perms = PermissionSet::new();
673 perms.grant(Permission::ToolsIntercept);
674 bus.subscribe(
675 HookKind::BeforeToolCall,
676 handler.clone(),
677 None,
678 Some(HookMatcher {
679 input_contains: Some("danger".to_string()),
680 input_equals: None,
681 }),
682 perms,
683 )
684 .await
685 .unwrap();
686
687 let safe = HookEvent::before_tool_call("bash", serde_json::json!({"command": "echo safe"}));
688 assert!(matches!(bus.emit(&safe).await, HookResult::Continue));
689
690 let danger =
691 HookEvent::before_tool_call("bash", serde_json::json!({"command": "echo danger"}));
692 assert!(matches!(bus.emit(&danger).await, HookResult::Block { .. }));
693 }
694
695 #[test]
696 fn hook_result_action_names_are_stable_for_trace_logs() {
697 assert_eq!(hook_result_action(&HookResult::Continue), "continue");
698 assert_eq!(
699 hook_result_action(&HookResult::Block {
700 reason: "stop".into(),
701 }),
702 "block"
703 );
704 assert_eq!(
705 hook_result_action(&HookResult::Inject {
706 content: "context".into(),
707 }),
708 "inject"
709 );
710 assert_eq!(
711 hook_result_action(&HookResult::Confirm {
712 message: "Proceed?".into(),
713 }),
714 "confirm"
715 );
716 assert_eq!(
717 hook_result_action(&HookResult::Modify {
718 input: serde_json::json!({"command": "echo safe"}),
719 }),
720 "modify"
721 );
722 }
723
724 #[tokio::test]
725 async fn empty_bus_returns_continue() {
726 let bus = HookBus::new();
727 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
728 let result = bus.emit(&event).await;
729 assert!(matches!(result, HookResult::Continue));
730 }
731
732 #[tokio::test]
733 async fn handler_receives_events() {
734 let bus = HookBus::new();
735 let handler = TestHandler::new("test-ext", HookResult::Continue);
736 let perms = perms_with(&[Permission::ToolsIntercept]);
737
738 bus.subscribe(HookKind::BeforeToolCall, handler.clone(), None, None, perms)
739 .await
740 .unwrap();
741
742 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "ls"}));
743 bus.emit(&event).await;
744
745 assert_eq!(handler.calls(), 1);
746 }
747
748 #[tokio::test]
749 async fn confirm_stops_chain_for_before_tool_call() {
750 let bus = HookBus::new();
751 let confirmer = TestHandler::new(
752 "confirmer",
753 HookResult::Confirm {
754 message: "Run this command?".into(),
755 },
756 );
757 let after = TestHandler::new("after", HookResult::Continue);
758 let perms = perms_with(&[Permission::ToolsIntercept]);
759
760 bus.subscribe(
761 HookKind::BeforeToolCall,
762 confirmer.clone(),
763 None,
764 None,
765 perms.clone(),
766 )
767 .await
768 .unwrap();
769 bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
770 .await
771 .unwrap();
772
773 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
774 let result = bus.emit(&event).await;
775
776 assert!(matches!(result, HookResult::Confirm { .. }));
777 assert_eq!(confirmer.calls(), 1);
778 assert_eq!(after.calls(), 0);
779 }
780
781 #[tokio::test]
782 async fn confirm_is_ignored_for_non_tool_hooks() {
783 let bus = HookBus::new();
784 let confirmer = TestHandler::new(
785 "confirmer",
786 HookResult::Confirm {
787 message: "Not allowed here".into(),
788 },
789 );
790 let perms = perms_with(&[Permission::LlmContent]);
791
792 bus.subscribe(
793 HookKind::BeforeMessage,
794 confirmer.clone(),
795 None,
796 None,
797 perms,
798 )
799 .await
800 .unwrap();
801
802 let event = HookEvent::before_message("hello");
803 let result = bus.emit(&event).await;
804
805 assert!(matches!(result, HookResult::Continue));
806 assert_eq!(confirmer.calls(), 1);
807 }
808
809 #[tokio::test]
810 async fn block_stops_chain() {
811 let bus = HookBus::new();
812 let blocker = TestHandler::new(
813 "blocker",
814 HookResult::Block {
815 reason: "dangerous".into(),
816 },
817 );
818 let after = TestHandler::new("after", HookResult::Continue);
819 let perms = perms_with(&[Permission::ToolsIntercept]);
820
821 bus.subscribe(
822 HookKind::BeforeToolCall,
823 blocker.clone(),
824 None,
825 None,
826 perms.clone(),
827 )
828 .await
829 .unwrap();
830 bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
831 .await
832 .unwrap();
833
834 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
835 let result = bus.emit(&event).await;
836
837 assert!(matches!(result, HookResult::Block { .. }));
838 assert_eq!(blocker.calls(), 1);
839 assert_eq!(after.calls(), 0); }
841
842 #[tokio::test]
843 async fn modify_stops_chain() {
844 let bus = HookBus::new();
845 let modifier = TestHandler::new(
846 "modifier",
847 HookResult::Modify {
848 input: serde_json::json!({"command": "echo safe"}),
849 },
850 );
851 let after = TestHandler::new(
852 "after",
853 HookResult::Block {
854 reason: "should not run".into(),
855 },
856 );
857 let perms = perms_with(&[Permission::ToolsIntercept]);
858
859 bus.subscribe(
860 HookKind::BeforeToolCall,
861 modifier.clone(),
862 None,
863 None,
864 perms.clone(),
865 )
866 .await
867 .unwrap();
868 bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
869 .await
870 .unwrap();
871
872 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "rm -rf /"}));
873 let result = bus.emit(&event).await;
874
875 assert!(
876 matches!(result, HookResult::Modify { input } if input == serde_json::json!({"command": "echo safe"}))
877 );
878 assert_eq!(modifier.calls(), 1);
879 assert_eq!(after.calls(), 0); }
881
882 #[tokio::test]
883 async fn tool_filter_only_matches_specified_tool() {
884 let bus = HookBus::new();
885 let handler = TestHandler::new("bash-only", HookResult::Continue);
886 let perms = perms_with(&[Permission::ToolsIntercept]);
887
888 bus.subscribe(
889 HookKind::AfterToolCall,
890 handler.clone(),
891 Some("bash".into()),
892 None,
893 perms,
894 )
895 .await
896 .unwrap();
897
898 let event = HookEvent::after_tool_call("read", serde_json::json!({}), "content".into());
900 bus.emit(&event).await;
901 assert_eq!(handler.calls(), 0);
902
903 let event = HookEvent::after_tool_call("bash", serde_json::json!({}), "output".into());
905 bus.emit(&event).await;
906 assert_eq!(handler.calls(), 1);
907 }
908
909 #[tokio::test]
910 async fn permission_denied_rejects_subscribe() {
911 let bus = HookBus::new();
912 let handler = TestHandler::new("no-perms", HookResult::Continue);
913 let perms = PermissionSet::new(); let result = bus
916 .subscribe(HookKind::BeforeToolCall, handler, None, None, perms)
917 .await;
918
919 assert!(result.is_err());
920 assert!(result.unwrap_err().contains("lacks permission"));
921 }
922
923 #[tokio::test]
924 async fn unsubscribe_removes_handlers() {
925 let bus = HookBus::new();
926 let handler = TestHandler::new("removable", HookResult::Continue);
927 let perms = perms_with(&[Permission::ToolsIntercept]);
928
929 bus.subscribe(HookKind::BeforeToolCall, handler.clone(), None, None, perms)
930 .await
931 .unwrap();
932 assert_eq!(bus.handler_count().await, 1);
933
934 bus.unsubscribe_all("removable").await;
935 assert_eq!(bus.handler_count().await, 0);
936 }
937
938 #[tokio::test]
939 async fn subscriptions_for_lists_only_matching_extension() {
940 let bus = HookBus::new();
941 let alpha = TestHandler::new("alpha", HookResult::Continue);
942 let beta = TestHandler::new("beta", HookResult::Continue);
943 let perms = perms_with(&[Permission::ToolsIntercept]);
944
945 bus.subscribe(
946 HookKind::BeforeToolCall,
947 alpha.clone(),
948 Some("bash".into()),
949 None,
950 perms.clone(),
951 )
952 .await
953 .unwrap();
954 bus.subscribe(
955 HookKind::AfterToolCall,
956 alpha.clone(),
957 None,
958 None,
959 perms.clone(),
960 )
961 .await
962 .unwrap();
963 bus.subscribe(HookKind::BeforeToolCall, beta.clone(), None, None, perms)
964 .await
965 .unwrap();
966
967 let alpha_subs = bus.subscriptions_for("alpha").await;
968 assert_eq!(alpha_subs.len(), 2);
969 assert_eq!(alpha_subs[0].0, HookKind::AfterToolCall);
971 assert_eq!(alpha_subs[0].1, None);
972 assert_eq!(alpha_subs[1].0, HookKind::BeforeToolCall);
973 assert_eq!(alpha_subs[1].1, Some("bash".to_string()));
974
975 let beta_subs = bus.subscriptions_for("beta").await;
976 assert_eq!(beta_subs, vec![(HookKind::BeforeToolCall, None)]);
977
978 let none_subs = bus.subscriptions_for("ghost").await;
979 assert!(none_subs.is_empty());
980 }
981
982 #[tokio::test]
983 async fn is_empty_reflects_state() {
984 let bus = HookBus::new();
985 assert!(bus.is_empty().await);
986
987 let handler = TestHandler::new("ext", HookResult::Continue);
988 let perms = perms_with(&[Permission::ToolsIntercept]);
989 bus.subscribe(HookKind::BeforeToolCall, handler, None, None, perms)
990 .await
991 .unwrap();
992 assert!(!bus.is_empty().await);
993 }
994
995 #[derive(Debug)]
1000 struct RecordingHandler {
1001 id: String,
1002 recorded_len: Arc<AtomicUsize>,
1003 result: HookResult,
1004 }
1005 impl RecordingHandler {
1006 fn new(id: &str, result: HookResult) -> (Arc<Self>, Arc<AtomicUsize>) {
1007 let recorded_len = Arc::new(AtomicUsize::new(0));
1008 let h = Arc::new(Self {
1009 id: id.to_string(),
1010 recorded_len: recorded_len.clone(),
1011 result,
1012 });
1013 (h, recorded_len)
1014 }
1015 }
1016 #[async_trait]
1017 impl crate::extensions::runtime::ExtensionHandler for RecordingHandler {
1018 fn id(&self) -> &str {
1019 &self.id
1020 }
1021 async fn handle(&self, event: &HookEvent) -> HookResult {
1022 if let Some(out) = event.tool_output.as_ref() {
1023 self.recorded_len.store(out.len(), Ordering::Relaxed);
1024 }
1025 self.result.clone()
1026 }
1027 async fn shutdown(&self) {}
1028 }
1029
1030 #[tokio::test]
1032 async fn after_tool_call_transform_receives_full_output_not_pre_truncated() {
1033 use crate::tools::Tool;
1034 let bus = std::sync::Arc::new(HookBus::new());
1035 let (handler, recorded_len) = RecordingHandler::new("len-recorder", HookResult::Continue);
1036 bus.subscribe(
1037 HookKind::AfterToolCall,
1038 handler,
1039 None,
1040 None,
1041 perms_with(&[Permission::ToolsIntercept]),
1042 )
1043 .await
1044 .unwrap();
1045
1046 let ctx = crate::tools::ToolContext {
1047 channels: crate::tools::ToolChannels {
1048 tx_delta: None,
1049 tx_events: None,
1050 },
1051 capabilities: crate::tools::ToolCapabilities {
1052 watcher_exit_path: None,
1053 tool_register_tx: None,
1054 session_manager: None,
1055 subagent_registry: None,
1056 event_queue: None,
1057 delegation_parent: None,
1058 secret_prompt: None,
1059 orchestration: None,
1060 tool_activation: None,
1061 mcp_leases: None,
1062 extension_leases: None,
1063 memory_context: None,
1064 },
1065 limits: crate::tools::ToolLimits {
1066 max_tool_output: 30_000,
1067 max_tool_buffer: 256 * 1024,
1068 bash_timeout: 30,
1069 bash_max_timeout: 300,
1070 subagent_timeout: 300,
1071 },
1072 };
1073
1074 let bash = crate::tools::BashTool;
1075 let output = bash
1076 .execute(
1077 serde_json::json!({"command": "yes hello | head -c 150000"}),
1078 ctx,
1079 )
1080 .await
1081 .expect("bash should succeed");
1082
1083 let _ = crate::runtime::emit_after_tool_call(
1084 &bus,
1085 "bash",
1086 None,
1087 serde_json::json!({"command": "yes hello | head -c 150000"}),
1088 output,
1089 30_000,
1090 )
1091 .await;
1092
1093 let len = recorded_len.load(Ordering::Relaxed);
1094 assert!(
1095 len > 100_000,
1096 "after_tool_call handler was starved: received {len} bytes — \
1097 expected the full ~150KB buffered output (max_tool_buffer=256KB)"
1098 );
1099 }
1100
1101 #[tokio::test]
1105 async fn final_output_truncated_to_max_tool_output_after_hook() {
1106 let bus = std::sync::Arc::new(HookBus::new());
1107 let big: String = "x".repeat(100_000);
1108 let handler = TestHandler::new("bloater", HookResult::Replace { output: big });
1109 bus.subscribe(
1110 HookKind::AfterToolCall,
1111 handler,
1112 None,
1113 None,
1114 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
1115 )
1116 .await
1117 .unwrap();
1118
1119 let result = crate::runtime::emit_after_tool_call(
1120 &bus,
1121 "bash",
1122 None,
1123 serde_json::json!({}),
1124 "tiny original".to_string(),
1125 30_000,
1126 )
1127 .await;
1128
1129 assert!(
1130 result.len() <= 30_000 + 200,
1131 "post-hook output should be capped at max_tool_output (got {} bytes)",
1132 result.len()
1133 );
1134 }
1135
1136 #[tokio::test]
1139 async fn no_transform_extension_preserves_legacy_truncation() {
1140 let bus = std::sync::Arc::new(HookBus::new());
1141 let huge: String = "y".repeat(80_000);
1142
1143 let result = crate::runtime::emit_after_tool_call(
1144 &bus,
1145 "bash",
1146 None,
1147 serde_json::json!({}),
1148 huge.clone(),
1149 30_000,
1150 )
1151 .await;
1152
1153 let legacy = crate::runtime::helpers::HelperMethods::truncate_tool_result(&huge, 30_000);
1154 assert_eq!(
1155 result, legacy,
1156 "no-extension path must be byte-identical to legacy truncation"
1157 );
1158 }
1159
1160 #[tokio::test]
1163 async fn emit_after_tool_call_truncates_char_boundary_safe() {
1164 let bus = std::sync::Arc::new(HookBus::new());
1166 let multibyte: String = "héllo🌟".repeat(5_000);
1167 assert!(multibyte.len() > 30_000);
1168
1169 let result = crate::runtime::emit_after_tool_call(
1170 &bus,
1171 "bash",
1172 None,
1173 serde_json::json!({}),
1174 multibyte,
1175 30_000,
1176 )
1177 .await;
1178 assert!(result.is_char_boundary(result.len()));
1179
1180 let bus2 = std::sync::Arc::new(HookBus::new());
1182 let big_multi = "✨".repeat(20_000);
1183 assert!(big_multi.len() > 30_000);
1184 let handler = TestHandler::new("mb-replacer", HookResult::Replace { output: big_multi });
1185 bus2.subscribe(
1186 HookKind::AfterToolCall,
1187 handler,
1188 None,
1189 None,
1190 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
1191 )
1192 .await
1193 .unwrap();
1194
1195 let result2 = crate::runtime::emit_after_tool_call(
1196 &bus2,
1197 "bash",
1198 None,
1199 serde_json::json!({}),
1200 "orig".to_string(),
1201 30_000,
1202 )
1203 .await;
1204 assert!(result2.is_char_boundary(result2.len()));
1205 }
1206}