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 debug_assert!(
324 !matches!(
325 event.kind,
326 HookKind::BeforeToolCall | HookKind::AfterToolCall | HookKind::BeforeMessage
327 ),
328 "emit_concurrent must not be used for transform-capable hooks \
329 ({:?}); their Block/Modify/Replace/Inject results need the \
330 deterministic first-wins ordering of the sequential emit()",
331 event.kind,
332 );
333 let registrations = {
335 let handlers = self.handlers.read().await;
336 match handlers.get(&event.kind) {
337 Some(regs) if !regs.is_empty() => regs.clone(),
338 _ => return HookResult::Continue, }
340 };
341
342 let futures: Vec<_> =
344 registrations
345 .iter()
346 .filter(|reg| {
347 if let Some(ref filter) = reg.tool_filter {
349 match (&event.tool_name, &event.tool_runtime_name) {
350 (Some(api), Some(runtime)) => filter == api || filter == runtime,
351 (Some(api), None) => filter == api,
352 (None, Some(runtime)) => filter == runtime,
353 (None, None) => false,
354 }
355 } else {
356 true
357 }
358 })
359 .filter(|reg| reg.matcher.as_ref().map_or(true, |m| m.matches(event)))
360 .map(|reg| {
361 let handler = reg.handler.clone();
362 let event_clone = event.clone();
363 async move {
364 tokio::time::timeout(HANDLER_TIMEOUT, handler.handle(&event_clone)).await
365 }
366 })
367 .collect();
368
369 let results = join_all(futures).await;
370
371 let mut injections: Vec<String> = Vec::new();
372 for result in results {
373 match result {
374 Ok(HookResult::Continue) => {}
375 Ok(HookResult::Block { reason }) => {
376 return HookResult::Block { reason };
377 }
378 Ok(HookResult::Inject { content }) => {
379 injections.push(content);
380 }
381 Ok(HookResult::Modify { input }) => {
382 return HookResult::Modify { input };
383 }
384 Ok(HookResult::Replace { output }) => {
385 return HookResult::Replace { output };
386 }
387 Ok(HookResult::Confirm { message }) => {
388 return HookResult::Confirm { message };
389 }
390 Err(_timeout) => {
391 tracing::warn!(
392 hook = %event.kind.as_str(),
393 timeout_secs = HANDLER_TIMEOUT.as_secs(),
394 "Hook handler timed out in concurrent emit — skipping"
395 );
396 }
397 }
398 }
399
400 if !injections.is_empty() {
401 HookResult::Inject {
402 content: injections.join("\n\n"),
403 }
404 } else {
405 HookResult::Continue
406 }
407 }
408
409 pub async fn unsubscribe_all(&self, extension_id: &str) {
411 let mut handlers = self.handlers.write().await;
412 for regs in handlers.values_mut() {
413 regs.retain(|r| r.handler.id() != extension_id);
414 }
415 }
416
417 pub async fn handler_count(&self) -> usize {
419 let handlers = self.handlers.read().await;
420 handlers.values().map(|v| v.len()).sum()
421 }
422
423 pub async fn is_empty(&self) -> bool {
425 let handlers = self.handlers.read().await;
426 handlers.values().all(|v| v.is_empty())
427 }
428
429 pub async fn subscriptions_for(&self, extension_id: &str) -> Vec<(HookKind, Option<String>)> {
432 let handlers = self.handlers.read().await;
433 let mut out: Vec<(HookKind, Option<String>)> = Vec::new();
434 for (kind, regs) in handlers.iter() {
435 for reg in regs {
436 if reg.handler.id() == extension_id {
437 out.push((*kind, reg.tool_filter.clone()));
438 }
439 }
440 }
441 out.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()).then_with(|| a.1.cmp(&b.1)));
442 out
443 }
444}
445
446impl Default for HookBus {
447 fn default() -> Self {
448 Self::new()
449 }
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455 use crate::extensions::hooks::events::HookEvent;
456 use crate::extensions::permissions::Permission;
457 use async_trait::async_trait;
458 use std::sync::atomic::{AtomicUsize, Ordering};
459
460 struct TestHandler {
462 id: String,
463 call_count: AtomicUsize,
464 result: HookResult,
465 }
466
467 impl TestHandler {
468 fn new(id: &str, result: HookResult) -> Arc<Self> {
469 Arc::new(Self {
470 id: id.to_string(),
471 call_count: AtomicUsize::new(0),
472 result,
473 })
474 }
475
476 fn calls(&self) -> usize {
477 self.call_count.load(Ordering::Relaxed)
478 }
479 }
480
481 #[async_trait]
482 impl crate::extensions::runtime::ExtensionHandler for TestHandler {
483 fn id(&self) -> &str {
484 &self.id
485 }
486
487 async fn handle(&self, _event: &HookEvent) -> HookResult {
488 self.call_count.fetch_add(1, Ordering::Relaxed);
489 self.result.clone()
490 }
491
492 async fn shutdown(&self) {}
493 }
494
495 fn perms_with(perms: &[Permission]) -> PermissionSet {
496 let mut set = PermissionSet::new();
497 for p in perms {
498 set.grant(*p);
499 }
500 set
501 }
502
503 #[tokio::test]
507 async fn after_tool_call_replace_substitutes_recorded_output() {
508 let bus = std::sync::Arc::new(HookBus::new());
509 let handler = TestHandler::new(
510 "compressor",
511 HookResult::Replace {
512 output: "COMPRESSED".into(),
513 },
514 );
515 bus.subscribe(
516 HookKind::AfterToolCall,
517 handler,
518 None,
519 None,
520 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
521 )
522 .await
523 .unwrap();
524
525 let recorded = crate::runtime::emit_after_tool_call(
526 &bus,
527 "bash",
528 None,
529 serde_json::json!({"command": "cat huge.log"}),
530 "RAW 10k lines".to_string(),
531 30_000,
532 )
533 .await;
534
535 assert_eq!(recorded, "COMPRESSED", "Replace output must reach history");
536 }
537
538 #[tokio::test]
540 async fn after_tool_call_without_replace_keeps_original_output() {
541 let bus = std::sync::Arc::new(HookBus::new());
542 let recorded = crate::runtime::emit_after_tool_call(
543 &bus,
544 "bash",
545 None,
546 serde_json::json!({}),
547 "RAW".to_string(),
548 30_000,
549 )
550 .await;
551
552 assert_eq!(recorded, "RAW", "no Replace → original output preserved");
553 }
554
555 #[tokio::test]
559 async fn replace_stops_chain_for_after_tool_call() {
560 let bus = HookBus::new();
561 let first = TestHandler::new(
562 "first",
563 HookResult::Replace {
564 output: "FIRST".into(),
565 },
566 );
567 let second = TestHandler::new(
568 "second",
569 HookResult::Replace {
570 output: "SECOND".into(),
571 },
572 );
573 let perms = perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]);
574
575 bus.subscribe(
576 HookKind::AfterToolCall,
577 first.clone(),
578 None,
579 None,
580 perms.clone(),
581 )
582 .await
583 .unwrap();
584 bus.subscribe(HookKind::AfterToolCall, second.clone(), None, None, perms)
585 .await
586 .unwrap();
587
588 let event = HookEvent::after_tool_call("bash", serde_json::json!({}), "RAW".to_string());
589 let result = bus.emit(&event).await;
590
591 assert_eq!(
592 result,
593 HookResult::Replace {
594 output: "FIRST".into()
595 }
596 );
597 assert_eq!(first.calls(), 1);
598 assert_eq!(second.calls(), 0); }
600
601 #[tokio::test]
605 async fn replace_without_transform_permission_is_ignored() {
606 let bus = std::sync::Arc::new(HookBus::new());
607 let handler = TestHandler::new(
608 "observer-only",
609 HookResult::Replace {
610 output: "SNEAKY".into(),
611 },
612 );
613 bus.subscribe(
615 HookKind::AfterToolCall,
616 handler.clone(),
617 None,
618 None,
619 perms_with(&[Permission::ToolsIntercept]),
620 )
621 .await
622 .unwrap();
623
624 let recorded = crate::runtime::emit_after_tool_call(
625 &bus,
626 "bash",
627 None,
628 serde_json::json!({}),
629 "ORIGINAL".to_string(),
630 30_000,
631 )
632 .await;
633
634 assert_eq!(handler.calls(), 1, "handler still runs (it's subscribed)");
635 assert_eq!(
636 recorded, "ORIGINAL",
637 "Replace without tools.transform_output must be ignored — original preserved"
638 );
639 }
640
641 #[test]
642 fn trace_env_value_parser_accepts_common_truthy_values() {
643 for value in ["1", "true", "TRUE", "yes", "on"] {
644 std::env::set_var("SYNAPS_EXTENSIONS_TRACE", value);
645 assert!(
646 extensions_trace_enabled(),
647 "{value} should enable trace mode"
648 );
649 }
650
651 for value in ["", "0", "false", "off", "no"] {
652 std::env::set_var("SYNAPS_EXTENSIONS_TRACE", value);
653 assert!(
654 !extensions_trace_enabled(),
655 "{value:?} should not enable trace mode"
656 );
657 }
658 std::env::remove_var("SYNAPS_EXTENSIONS_TRACE");
659 }
660
661 #[tokio::test]
662 async fn matcher_skips_handler_when_input_does_not_contain_value() {
663 let bus = HookBus::new();
664 let handler = TestHandler::new(
665 "matcher",
666 HookResult::Block {
667 reason: "matched".into(),
668 },
669 );
670 let mut perms = PermissionSet::new();
671 perms.grant(Permission::ToolsIntercept);
672 bus.subscribe(
673 HookKind::BeforeToolCall,
674 handler.clone(),
675 None,
676 Some(HookMatcher {
677 input_contains: Some("danger".to_string()),
678 input_equals: None,
679 }),
680 perms,
681 )
682 .await
683 .unwrap();
684
685 let safe = HookEvent::before_tool_call("bash", serde_json::json!({"command": "echo safe"}));
686 assert!(matches!(bus.emit(&safe).await, HookResult::Continue));
687
688 let danger =
689 HookEvent::before_tool_call("bash", serde_json::json!({"command": "echo danger"}));
690 assert!(matches!(bus.emit(&danger).await, HookResult::Block { .. }));
691 }
692
693 #[test]
694 fn hook_result_action_names_are_stable_for_trace_logs() {
695 assert_eq!(hook_result_action(&HookResult::Continue), "continue");
696 assert_eq!(
697 hook_result_action(&HookResult::Block {
698 reason: "stop".into(),
699 }),
700 "block"
701 );
702 assert_eq!(
703 hook_result_action(&HookResult::Inject {
704 content: "context".into(),
705 }),
706 "inject"
707 );
708 assert_eq!(
709 hook_result_action(&HookResult::Confirm {
710 message: "Proceed?".into(),
711 }),
712 "confirm"
713 );
714 assert_eq!(
715 hook_result_action(&HookResult::Modify {
716 input: serde_json::json!({"command": "echo safe"}),
717 }),
718 "modify"
719 );
720 }
721
722 #[tokio::test]
723 async fn empty_bus_returns_continue() {
724 let bus = HookBus::new();
725 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
726 let result = bus.emit(&event).await;
727 assert!(matches!(result, HookResult::Continue));
728 }
729
730 #[tokio::test]
731 async fn handler_receives_events() {
732 let bus = HookBus::new();
733 let handler = TestHandler::new("test-ext", HookResult::Continue);
734 let perms = perms_with(&[Permission::ToolsIntercept]);
735
736 bus.subscribe(HookKind::BeforeToolCall, handler.clone(), None, None, perms)
737 .await
738 .unwrap();
739
740 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "ls"}));
741 bus.emit(&event).await;
742
743 assert_eq!(handler.calls(), 1);
744 }
745
746 #[tokio::test]
747 async fn confirm_stops_chain_for_before_tool_call() {
748 let bus = HookBus::new();
749 let confirmer = TestHandler::new(
750 "confirmer",
751 HookResult::Confirm {
752 message: "Run this command?".into(),
753 },
754 );
755 let after = TestHandler::new("after", HookResult::Continue);
756 let perms = perms_with(&[Permission::ToolsIntercept]);
757
758 bus.subscribe(
759 HookKind::BeforeToolCall,
760 confirmer.clone(),
761 None,
762 None,
763 perms.clone(),
764 )
765 .await
766 .unwrap();
767 bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
768 .await
769 .unwrap();
770
771 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
772 let result = bus.emit(&event).await;
773
774 assert!(matches!(result, HookResult::Confirm { .. }));
775 assert_eq!(confirmer.calls(), 1);
776 assert_eq!(after.calls(), 0);
777 }
778
779 #[tokio::test]
780 async fn confirm_is_ignored_for_non_tool_hooks() {
781 let bus = HookBus::new();
782 let confirmer = TestHandler::new(
783 "confirmer",
784 HookResult::Confirm {
785 message: "Not allowed here".into(),
786 },
787 );
788 let perms = perms_with(&[Permission::LlmContent]);
789
790 bus.subscribe(
791 HookKind::BeforeMessage,
792 confirmer.clone(),
793 None,
794 None,
795 perms,
796 )
797 .await
798 .unwrap();
799
800 let event = HookEvent::before_message("hello");
801 let result = bus.emit(&event).await;
802
803 assert!(matches!(result, HookResult::Continue));
804 assert_eq!(confirmer.calls(), 1);
805 }
806
807 #[tokio::test]
808 async fn block_stops_chain() {
809 let bus = HookBus::new();
810 let blocker = TestHandler::new(
811 "blocker",
812 HookResult::Block {
813 reason: "dangerous".into(),
814 },
815 );
816 let after = TestHandler::new("after", HookResult::Continue);
817 let perms = perms_with(&[Permission::ToolsIntercept]);
818
819 bus.subscribe(
820 HookKind::BeforeToolCall,
821 blocker.clone(),
822 None,
823 None,
824 perms.clone(),
825 )
826 .await
827 .unwrap();
828 bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
829 .await
830 .unwrap();
831
832 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
833 let result = bus.emit(&event).await;
834
835 assert!(matches!(result, HookResult::Block { .. }));
836 assert_eq!(blocker.calls(), 1);
837 assert_eq!(after.calls(), 0); }
839
840 #[tokio::test]
841 async fn modify_stops_chain() {
842 let bus = HookBus::new();
843 let modifier = TestHandler::new(
844 "modifier",
845 HookResult::Modify {
846 input: serde_json::json!({"command": "echo safe"}),
847 },
848 );
849 let after = TestHandler::new(
850 "after",
851 HookResult::Block {
852 reason: "should not run".into(),
853 },
854 );
855 let perms = perms_with(&[Permission::ToolsIntercept]);
856
857 bus.subscribe(
858 HookKind::BeforeToolCall,
859 modifier.clone(),
860 None,
861 None,
862 perms.clone(),
863 )
864 .await
865 .unwrap();
866 bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
867 .await
868 .unwrap();
869
870 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "rm -rf /"}));
871 let result = bus.emit(&event).await;
872
873 assert!(
874 matches!(result, HookResult::Modify { input } if input == serde_json::json!({"command": "echo safe"}))
875 );
876 assert_eq!(modifier.calls(), 1);
877 assert_eq!(after.calls(), 0); }
879
880 #[tokio::test]
881 async fn tool_filter_only_matches_specified_tool() {
882 let bus = HookBus::new();
883 let handler = TestHandler::new("bash-only", HookResult::Continue);
884 let perms = perms_with(&[Permission::ToolsIntercept]);
885
886 bus.subscribe(
887 HookKind::AfterToolCall,
888 handler.clone(),
889 Some("bash".into()),
890 None,
891 perms,
892 )
893 .await
894 .unwrap();
895
896 let event = HookEvent::after_tool_call("read", serde_json::json!({}), "content".into());
898 bus.emit(&event).await;
899 assert_eq!(handler.calls(), 0);
900
901 let event = HookEvent::after_tool_call("bash", serde_json::json!({}), "output".into());
903 bus.emit(&event).await;
904 assert_eq!(handler.calls(), 1);
905 }
906
907 #[tokio::test]
908 async fn permission_denied_rejects_subscribe() {
909 let bus = HookBus::new();
910 let handler = TestHandler::new("no-perms", HookResult::Continue);
911 let perms = PermissionSet::new(); let result = bus
914 .subscribe(HookKind::BeforeToolCall, handler, None, None, perms)
915 .await;
916
917 assert!(result.is_err());
918 assert!(result.unwrap_err().contains("lacks permission"));
919 }
920
921 #[tokio::test]
922 async fn unsubscribe_removes_handlers() {
923 let bus = HookBus::new();
924 let handler = TestHandler::new("removable", HookResult::Continue);
925 let perms = perms_with(&[Permission::ToolsIntercept]);
926
927 bus.subscribe(HookKind::BeforeToolCall, handler.clone(), None, None, perms)
928 .await
929 .unwrap();
930 assert_eq!(bus.handler_count().await, 1);
931
932 bus.unsubscribe_all("removable").await;
933 assert_eq!(bus.handler_count().await, 0);
934 }
935
936 #[tokio::test]
937 async fn subscriptions_for_lists_only_matching_extension() {
938 let bus = HookBus::new();
939 let alpha = TestHandler::new("alpha", HookResult::Continue);
940 let beta = TestHandler::new("beta", HookResult::Continue);
941 let perms = perms_with(&[Permission::ToolsIntercept]);
942
943 bus.subscribe(
944 HookKind::BeforeToolCall,
945 alpha.clone(),
946 Some("bash".into()),
947 None,
948 perms.clone(),
949 )
950 .await
951 .unwrap();
952 bus.subscribe(
953 HookKind::AfterToolCall,
954 alpha.clone(),
955 None,
956 None,
957 perms.clone(),
958 )
959 .await
960 .unwrap();
961 bus.subscribe(HookKind::BeforeToolCall, beta.clone(), None, None, perms)
962 .await
963 .unwrap();
964
965 let alpha_subs = bus.subscriptions_for("alpha").await;
966 assert_eq!(alpha_subs.len(), 2);
967 assert_eq!(alpha_subs[0].0, HookKind::AfterToolCall);
969 assert_eq!(alpha_subs[0].1, None);
970 assert_eq!(alpha_subs[1].0, HookKind::BeforeToolCall);
971 assert_eq!(alpha_subs[1].1, Some("bash".to_string()));
972
973 let beta_subs = bus.subscriptions_for("beta").await;
974 assert_eq!(beta_subs, vec![(HookKind::BeforeToolCall, None)]);
975
976 let none_subs = bus.subscriptions_for("ghost").await;
977 assert!(none_subs.is_empty());
978 }
979
980 #[tokio::test]
981 async fn is_empty_reflects_state() {
982 let bus = HookBus::new();
983 assert!(bus.is_empty().await);
984
985 let handler = TestHandler::new("ext", HookResult::Continue);
986 let perms = perms_with(&[Permission::ToolsIntercept]);
987 bus.subscribe(HookKind::BeforeToolCall, handler, None, None, perms)
988 .await
989 .unwrap();
990 assert!(!bus.is_empty().await);
991 }
992
993 #[derive(Debug)]
998 struct RecordingHandler {
999 id: String,
1000 recorded_len: Arc<AtomicUsize>,
1001 result: HookResult,
1002 }
1003 impl RecordingHandler {
1004 fn new(id: &str, result: HookResult) -> (Arc<Self>, Arc<AtomicUsize>) {
1005 let recorded_len = Arc::new(AtomicUsize::new(0));
1006 let h = Arc::new(Self {
1007 id: id.to_string(),
1008 recorded_len: recorded_len.clone(),
1009 result,
1010 });
1011 (h, recorded_len)
1012 }
1013 }
1014 #[async_trait]
1015 impl crate::extensions::runtime::ExtensionHandler for RecordingHandler {
1016 fn id(&self) -> &str {
1017 &self.id
1018 }
1019 async fn handle(&self, event: &HookEvent) -> HookResult {
1020 if let Some(out) = event.tool_output.as_ref() {
1021 self.recorded_len.store(out.len(), Ordering::Relaxed);
1022 }
1023 self.result.clone()
1024 }
1025 async fn shutdown(&self) {}
1026 }
1027
1028 #[tokio::test]
1030 async fn after_tool_call_transform_receives_full_output_not_pre_truncated() {
1031 use crate::tools::Tool;
1032 let bus = std::sync::Arc::new(HookBus::new());
1033 let (handler, recorded_len) = RecordingHandler::new("len-recorder", HookResult::Continue);
1034 bus.subscribe(
1035 HookKind::AfterToolCall,
1036 handler,
1037 None,
1038 None,
1039 perms_with(&[Permission::ToolsIntercept]),
1040 )
1041 .await
1042 .unwrap();
1043
1044 let ctx = crate::tools::ToolContext {
1045 channels: crate::tools::ToolChannels {
1046 tx_delta: None,
1047 tx_events: None,
1048 },
1049 capabilities: crate::tools::ToolCapabilities {
1050 watcher_exit_path: None,
1051 tool_register_tx: None,
1052 session_manager: None,
1053 subagent_registry: None,
1054 event_queue: None,
1055 secret_prompt: None,
1056 },
1057 limits: crate::tools::ToolLimits {
1058 max_tool_output: 30_000,
1059 max_tool_buffer: 256 * 1024,
1060 bash_timeout: 30,
1061 bash_max_timeout: 300,
1062 subagent_timeout: 300,
1063 },
1064 };
1065
1066 let bash = crate::tools::BashTool;
1067 let output = bash
1068 .execute(
1069 serde_json::json!({"command": "yes hello | head -c 150000"}),
1070 ctx,
1071 )
1072 .await
1073 .expect("bash should succeed");
1074
1075 let _ = crate::runtime::emit_after_tool_call(
1076 &bus,
1077 "bash",
1078 None,
1079 serde_json::json!({"command": "yes hello | head -c 150000"}),
1080 output,
1081 30_000,
1082 )
1083 .await;
1084
1085 let len = recorded_len.load(Ordering::Relaxed);
1086 assert!(
1087 len > 100_000,
1088 "after_tool_call handler was starved: received {len} bytes — \
1089 expected the full ~150KB buffered output (max_tool_buffer=256KB)"
1090 );
1091 }
1092
1093 #[tokio::test]
1097 async fn final_output_truncated_to_max_tool_output_after_hook() {
1098 let bus = std::sync::Arc::new(HookBus::new());
1099 let big: String = "x".repeat(100_000);
1100 let handler = TestHandler::new("bloater", HookResult::Replace { output: big });
1101 bus.subscribe(
1102 HookKind::AfterToolCall,
1103 handler,
1104 None,
1105 None,
1106 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
1107 )
1108 .await
1109 .unwrap();
1110
1111 let result = crate::runtime::emit_after_tool_call(
1112 &bus,
1113 "bash",
1114 None,
1115 serde_json::json!({}),
1116 "tiny original".to_string(),
1117 30_000,
1118 )
1119 .await;
1120
1121 assert!(
1122 result.len() <= 30_000 + 200,
1123 "post-hook output should be capped at max_tool_output (got {} bytes)",
1124 result.len()
1125 );
1126 }
1127
1128 #[tokio::test]
1131 async fn no_transform_extension_preserves_legacy_truncation() {
1132 let bus = std::sync::Arc::new(HookBus::new());
1133 let huge: String = "y".repeat(80_000);
1134
1135 let result = crate::runtime::emit_after_tool_call(
1136 &bus,
1137 "bash",
1138 None,
1139 serde_json::json!({}),
1140 huge.clone(),
1141 30_000,
1142 )
1143 .await;
1144
1145 let legacy = crate::runtime::helpers::HelperMethods::truncate_tool_result(&huge, 30_000);
1146 assert_eq!(
1147 result, legacy,
1148 "no-extension path must be byte-identical to legacy truncation"
1149 );
1150 }
1151
1152 #[tokio::test]
1155 async fn emit_after_tool_call_truncates_char_boundary_safe() {
1156 let bus = std::sync::Arc::new(HookBus::new());
1158 let multibyte: String = "héllo🌟".repeat(5_000);
1159 assert!(multibyte.len() > 30_000);
1160
1161 let result = crate::runtime::emit_after_tool_call(
1162 &bus,
1163 "bash",
1164 None,
1165 serde_json::json!({}),
1166 multibyte,
1167 30_000,
1168 )
1169 .await;
1170 assert!(result.is_char_boundary(result.len()));
1171
1172 let bus2 = std::sync::Arc::new(HookBus::new());
1174 let big_multi = "✨".repeat(20_000);
1175 assert!(big_multi.len() > 30_000);
1176 let handler = TestHandler::new("mb-replacer", HookResult::Replace { output: big_multi });
1177 bus2.subscribe(
1178 HookKind::AfterToolCall,
1179 handler,
1180 None,
1181 None,
1182 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
1183 )
1184 .await
1185 .unwrap();
1186
1187 let result2 = crate::runtime::emit_after_tool_call(
1188 &bus2,
1189 "bash",
1190 None,
1191 serde_json::json!({}),
1192 "orig".to_string(),
1193 30_000,
1194 )
1195 .await;
1196 assert!(result2.is_char_boundary(result2.len()));
1197 }
1198}