1pub mod events;
13
14use std::collections::HashMap;
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17
18use futures::future::join_all;
19use futures::FutureExt;
20use tokio::sync::RwLock;
21
22use self::events::{HookEvent, HookKind, HookResult};
23use crate::extensions::manifest::HookMatcher;
24use crate::extensions::permissions::{Permission, PermissionSet};
25
26const HANDLER_TIMEOUT: Duration = Duration::from_secs(5);
28
29const NOTIFY_MAX_IN_FLIGHT: usize = 64;
34
35fn extensions_trace_enabled() -> bool {
36 std::env::var("SYNAPS_EXTENSIONS_TRACE")
37 .map(|value| {
38 let normalized = value.trim().to_ascii_lowercase();
39 matches!(normalized.as_str(), "1" | "true" | "yes" | "on")
40 })
41 .unwrap_or(false)
42}
43
44fn hook_result_action(result: &HookResult) -> &'static str {
45 match result {
46 HookResult::Continue => "continue",
47 HookResult::Block { .. } => "block",
48 HookResult::Inject { .. } => "inject",
49 HookResult::Confirm { .. } => "confirm",
50 HookResult::Modify { .. } => "modify",
51 HookResult::Replace { .. } => "replace",
52 HookResult::ContextPhase { .. } => "context_phase",
53 }
54}
55
56fn accept_context_phase(
62 reg: &HandlerRegistration,
63 kind: HookKind,
64 phase: String,
65) -> Option<String> {
66 if !reg.permissions.has(Permission::SessionLifecycle) {
67 tracing::warn!(
68 hook = %kind.as_str(),
69 extension = %reg.handler.id(),
70 "Extension returned context_phase without session.lifecycle permission — ignoring report"
71 );
72 return None;
73 }
74 tracing::info!(
75 hook = %kind.as_str(),
76 extension = %reg.handler.id(),
77 phase = %crate::runtime::continuation::phase_for_log(&phase),
78 "Extension reported advisory context phase"
79 );
80 Some(phase)
81}
82
83fn tool_filter_matches(filter: &str, ev: &HookEvent) -> bool {
90 let (api, runtime) = (ev.tool_name.as_deref(), ev.tool_runtime_name.as_deref());
91 if api == Some(filter) || runtime == Some(filter) {
92 return true;
93 }
94 ev.kind == HookKind::BeforeToolCall
95 && matches!(filter, "edit" | "write")
96 && runtime.or(api) == Some("multi_edit")
97}
98
99#[derive(Clone)]
101pub struct HandlerRegistration {
102 pub handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
104 pub tool_filter: Option<String>,
106 pub matcher: Option<HookMatcher>,
108 pub permissions: PermissionSet,
110}
111
112impl std::fmt::Debug for HookBus {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 f.debug_struct("HookBus").finish_non_exhaustive()
115 }
116}
117
118pub struct HookBus {
123 handlers: RwLock<HashMap<HookKind, Vec<HandlerRegistration>>>,
124 generations: std::sync::Mutex<HashMap<String, u64>>,
129 session_injection: RwLock<HashMap<String, String>>,
141 transforms_of: Option<Arc<HookBus>>,
144 notify_slots: Arc<tokio::sync::Semaphore>,
147}
148
149impl HookBus {
150 pub fn new() -> Self {
152 Self {
153 handlers: RwLock::new(HashMap::new()),
154 session_injection: RwLock::new(HashMap::new()),
155 generations: std::sync::Mutex::new(HashMap::new()),
156 transforms_of: None,
157 notify_slots: Arc::new(tokio::sync::Semaphore::new(NOTIFY_MAX_IN_FLIGHT)),
158 }
159 }
160
161 pub fn worker_transforms(host: &Arc<HookBus>) -> Arc<HookBus> {
180 let host = host.transforms_of.clone().unwrap_or_else(|| Arc::clone(host));
182 Arc::new(Self {
183 transforms_of: Some(host),
184 ..Self::new()
185 })
186 }
187
188 pub fn is_worker_view(&self) -> bool {
190 self.transforms_of.is_some()
191 }
192
193 async fn worker_transform_registrations(
197 &self,
198 kind: HookKind,
199 ) -> Option<Vec<HandlerRegistration>> {
200 if kind != HookKind::AfterToolCall {
201 return None;
202 }
203 let handlers = self.handlers.read().await;
204 let regs: Vec<HandlerRegistration> = handlers
205 .get(&kind)?
206 .iter()
207 .filter(|reg| reg.permissions.has(Permission::ToolsTransformOutput))
208 .cloned()
209 .collect();
210 (!regs.is_empty()).then_some(regs)
211 }
212
213 pub fn extension_generation(&self, extension_id: &str) -> u64 {
215 self.generations
216 .lock()
217 .unwrap_or_else(|e| e.into_inner())
218 .get(extension_id)
219 .copied()
220 .unwrap_or(0)
221 }
222
223 pub async fn set_session_injection_for(&self, session_id: &str, content: String) {
230 self.session_injection
231 .write()
232 .await
233 .insert(session_id.to_string(), content);
234 }
235
236 pub async fn session_injection_for(&self, session_id: &str) -> Option<String> {
239 self.session_injection.read().await.get(session_id).cloned()
240 }
241
242 pub async fn clear_session_injection(&self, session_id: &str) {
244 self.session_injection.write().await.remove(session_id);
245 }
246
247 #[deprecated(note = "use set_session_injection_for")]
249 pub async fn set_session_injection(&self, content: String) {
250 self.set_session_injection_for("", content).await;
251 }
252
253 #[deprecated(note = "use session_injection_for")]
255 pub async fn session_injection(&self) -> Option<String> {
256 self.session_injection_for("").await
257 }
258
259 pub async fn subscribe(
264 &self,
265 kind: HookKind,
266 handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
267 tool_filter: Option<String>,
268 matcher: Option<HookMatcher>,
269 permissions: PermissionSet,
270 ) -> Result<(), String> {
271 if self.is_worker_view() {
272 return Err(format!(
273 "Extension '{}' cannot subscribe to a worker's hook view; subscribe to the host bus",
274 handler.id(),
275 ));
276 }
277 if !permissions.allows_hook(kind) {
279 return Err(format!(
280 "Extension '{}' lacks permission '{}' required for hook '{}'",
281 handler.id(),
282 kind.required_permission().as_str(),
283 kind.as_str(),
284 ));
285 }
286
287 let reg = HandlerRegistration {
288 handler,
289 tool_filter,
290 matcher,
291 permissions,
292 };
293
294 let mut handlers = self.handlers.write().await;
295 handlers.entry(kind).or_default().push(reg);
296 Ok(())
297 }
298
299 pub async fn emit(&self, event: &HookEvent) -> HookResult {
322 let no_spawn = self.is_worker_view();
327 let registrations = match &self.transforms_of {
328 Some(host) => match host.worker_transform_registrations(event.kind).await {
329 Some(regs) => regs,
330 None => return HookResult::Continue,
331 },
332 None => {
333 let handlers = self.handlers.read().await;
334 match handlers.get(&event.kind) {
335 Some(regs) if !regs.is_empty() => regs.clone(),
336 _ => return HookResult::Continue, }
338 }
339 }; let mut injections: Vec<String> = Vec::new();
343 let mut context_phase: Option<String> = None;
346 let mut confirms: Vec<(String, String)> = Vec::new();
348 let mut source_keys: Vec<(String, u64)> = Vec::new();
350
351 let mut modified_event: Option<HookEvent> = None;
355 let mut modified_by: Vec<String> = Vec::new();
357
358 for reg in ®istrations {
359 let ev: &HookEvent = modified_event.as_ref().unwrap_or(event);
360 if let Some(ref filter) = reg.tool_filter {
364 if !tool_filter_matches(filter, ev) {
365 continue;
366 }
367 }
368
369 if let Some(ref matcher) = reg.matcher {
370 if !matcher.matches(ev) {
371 continue;
372 }
373 }
374
375 let handler = reg.handler.clone();
377 let event_clone = ev.clone();
378 let trace_enabled = extensions_trace_enabled();
379 let started_at = trace_enabled.then(Instant::now);
380 let call = async {
381 if no_spawn {
382 handler.handle_no_spawn(&event_clone).await
383 } else {
384 handler.handle(&event_clone).await
385 }
386 };
387 let result = tokio::time::timeout(HANDLER_TIMEOUT, call).await;
388
389 if trace_enabled {
390 let health = reg.handler.health().await;
391 let health = health.as_str();
392 let restart_count = reg.handler.restart_count().await;
393 let duration_ms = started_at
394 .map(|start| start.elapsed().as_millis() as u64)
395 .unwrap_or(0);
396 match &result {
397 Ok(hook_result) => {
398 let action = hook_result_action(hook_result);
399 tracing::info!(
400 extension_trace = true,
401 hook = %ev.kind.as_str(),
402 extension = %reg.handler.id(),
403 action = action,
404 duration_ms = duration_ms,
405 health = health,
406 restart_count = restart_count,
407 "Extension hook trace"
408 );
409 }
410 Err(_) => {
411 tracing::warn!(
412 extension_trace = true,
413 hook = %ev.kind.as_str(),
414 extension = %reg.handler.id(),
415 action = "timeout",
416 duration_ms = duration_ms,
417 timeout_secs = HANDLER_TIMEOUT.as_secs(),
418 health = health,
419 restart_count = restart_count,
420 "Extension hook trace"
421 );
422 }
423 }
424 }
425
426 match result {
427 Ok(result) if !ev.kind.allows_result(&result) => {
428 tracing::warn!(
429 hook = %ev.kind.as_str(),
430 extension = %reg.handler.id(),
431 action = hook_result_action(&result),
432 "Extension returned action not allowed for hook — ignoring"
433 );
434 continue;
435 }
436 Ok(HookResult::Block { reason }) => {
437 tracing::info!(
438 hook = %ev.kind.as_str(),
439 extension = %reg.handler.id(),
440 reason = %reason,
441 "Hook blocked by extension"
442 );
443 return HookResult::Block { reason };
444 }
445 Ok(HookResult::Continue) => {}
446 Ok(HookResult::Inject { content }) => {
447 tracing::debug!(
448 hook = %ev.kind.as_str(),
449 extension = %reg.handler.id(),
450 len = content.len(),
451 "Extension injected context"
452 );
453 injections.push(content);
455 }
456 Ok(HookResult::Modify { input }) => {
457 if !confirms.is_empty() {
458 tracing::warn!(
462 hook = %ev.kind.as_str(),
463 extension = %reg.handler.id(),
464 confirming = ?confirms.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
465 "Extension Modify after a pending Confirm IGNORED: the user confirms the input as it was when the first Confirm was raised"
466 );
467 continue;
468 }
469 tracing::info!(
470 hook = %ev.kind.as_str(),
471 extension = %reg.handler.id(),
472 "Hook modified tool input by extension; later handlers vet the modified input"
473 );
474 let mut next = ev.clone();
475 next.tool_input = Some(input);
476 modified_event = Some(next);
477 let id = reg.handler.id().to_string();
478 if !modified_by.contains(&id) {
479 modified_by.push(id);
480 }
481 }
482 Ok(HookResult::Replace { output }) => {
483 if !reg.permissions.has(Permission::ToolsTransformOutput) {
489 tracing::warn!(
490 hook = %ev.kind.as_str(),
491 extension = %reg.handler.id(),
492 "Extension returned Replace without tools.transform_output permission — ignoring transform"
493 );
494 continue;
495 }
496 let observed_len = ev.tool_output.as_ref().map_or(0, String::len);
497 tracing::info!(
498 hook = %ev.kind.as_str(),
499 extension = %reg.handler.id(),
500 observed_len,
501 replacement_len = output.len(),
502 "Hook replaced tool output by extension"
503 );
504 let _ = crate::extensions::audit::record_tool_output_replace(
508 reg.handler.id(),
509 ev.kind.as_str(),
510 ev.tool_name.clone(),
511 observed_len,
512 output.len(),
513 );
514 return HookResult::Replace { output };
516 }
517 Ok(HookResult::Confirm { message, .. }) => {
518 tracing::info!(
519 hook = %ev.kind.as_str(),
520 extension = %reg.handler.id(),
521 "Hook requested confirmation by extension"
522 );
523 confirms.push((reg.handler.id().to_string(), message));
527 let id = reg.handler.id();
528 if !source_keys.iter().any(|(k, _)| k == id) {
529 source_keys.push((id.to_string(), self.extension_generation(id)));
530 }
531 }
532 Ok(HookResult::ContextPhase { phase }) => {
533 if let Some(phase) = accept_context_phase(reg, ev.kind, phase) {
536 if context_phase.is_none() {
537 context_phase = Some(phase);
538 } else {
539 tracing::debug!(
540 hook = %ev.kind.as_str(),
541 extension = %reg.handler.id(),
542 "Extension context_phase superseded by an earlier report — ignoring"
543 );
544 }
545 }
546 }
547 Err(_timeout) => {
548 tracing::warn!(
549 hook = %ev.kind.as_str(),
550 extension = %reg.handler.id(),
551 timeout_secs = HANDLER_TIMEOUT.as_secs(),
552 "Hook handler timed out — skipping"
553 );
554 }
556 }
557 }
558
559 if !confirms.is_empty() {
561 let tool = event
562 .tool_runtime_name
563 .clone()
564 .or_else(|| event.tool_name.clone());
565 let message = if confirms.len() == 1 {
566 confirms[0].1.clone()
567 } else {
568 confirms
569 .iter()
570 .map(|(id, m)| format!("[{id}] {m}"))
571 .collect::<Vec<_>>()
572 .join("\n\n")
573 };
574 let sources = source_keys.iter().map(|(id, _)| id.clone()).collect();
575 let generations = source_keys.iter().map(|(_, g)| *g).collect();
576 return HookResult::Confirm {
577 message,
578 sources,
579 generations,
580 tool,
581 modified_input: modified_event.and_then(|e| e.tool_input),
582 modified_by,
583 };
584 }
585 if let Some(input) = modified_event.and_then(|e| e.tool_input) {
586 return HookResult::Modify { input };
587 }
588
589 if !injections.is_empty() {
593 HookResult::Inject {
594 content: injections.join("\n\n"),
595 }
596 } else if let Some(phase) = context_phase {
597 HookResult::ContextPhase { phase }
598 } else {
599 HookResult::Continue
600 }
601 }
602
603 pub async fn emit_concurrent(&self, event: &HookEvent) -> HookResult {
630 assert!(
633 !matches!(
634 event.kind,
635 HookKind::BeforeToolCall | HookKind::AfterToolCall | HookKind::BeforeMessage
636 ),
637 "emit_concurrent must not be used for transform-capable hooks \
638 ({:?}); their Block/Modify/Replace/Inject results need the \
639 deterministic first-wins ordering of the sequential emit()",
640 event.kind,
641 );
642 let registrations = {
644 let handlers = self.handlers.read().await;
645 match handlers.get(&event.kind) {
646 Some(regs) if !regs.is_empty() => regs.clone(),
647 _ => return HookResult::Continue, }
649 };
650
651 let futures: Vec<_> =
653 registrations
654 .iter()
655 .filter(|reg| {
656 reg.tool_filter
658 .as_deref()
659 .is_none_or(|filter| tool_filter_matches(filter, event))
660 })
661 .filter(|reg| reg.matcher.as_ref().is_none_or(|m| m.matches(event)))
662 .map(|reg| {
663 let handler = reg.handler.clone();
664 let event_clone = event.clone();
665 async move {
666 (
667 reg,
668 tokio::time::timeout(HANDLER_TIMEOUT, handler.handle(&event_clone))
669 .await,
670 )
671 }
672 })
673 .collect();
674
675 let results = join_all(futures).await;
676
677 let mut injections: Vec<String> = Vec::new();
678 let mut context_phase: Option<String> = None;
681 for (reg, result) in results {
682 match result {
683 Ok(HookResult::Continue) => {}
684 Ok(HookResult::Block { reason }) => {
685 return HookResult::Block { reason };
686 }
687 Ok(HookResult::Inject { content }) => {
688 injections.push(content);
689 }
690 Ok(HookResult::Modify { input }) => {
691 return HookResult::Modify { input };
692 }
693 Ok(HookResult::Replace { output }) => {
694 return HookResult::Replace { output };
695 }
696 Ok(HookResult::Confirm { message, .. }) => {
697 return HookResult::Confirm {
698 message,
699 sources: vec![reg.handler.id().to_string()],
700 generations: vec![self.extension_generation(reg.handler.id())],
701 tool: event.tool_runtime_name.clone().or_else(|| event.tool_name.clone()),
702 modified_input: None,
703 modified_by: vec![],
704 };
705 }
706 Ok(result @ HookResult::ContextPhase { .. }) => {
707 if !event.kind.allows_result(&result) {
708 tracing::warn!(
709 hook = %event.kind.as_str(),
710 extension = %reg.handler.id(),
711 action = hook_result_action(&result),
712 "Extension returned action not allowed for hook — ignoring"
713 );
714 continue;
715 }
716 if let HookResult::ContextPhase { phase } = result {
717 if let Some(phase) = accept_context_phase(reg, event.kind, phase) {
718 context_phase.get_or_insert(phase);
719 }
720 }
721 }
722 Err(_timeout) => {
723 tracing::warn!(
724 hook = %event.kind.as_str(),
725 extension = %reg.handler.id(),
726 timeout_secs = HANDLER_TIMEOUT.as_secs(),
727 "Hook handler timed out in concurrent emit — skipping"
728 );
729 }
730 }
731 }
732
733 if !injections.is_empty() {
734 HookResult::Inject {
735 content: injections.join("\n\n"),
736 }
737 } else if let Some(phase) = context_phase {
738 HookResult::ContextPhase { phase }
739 } else {
740 HookResult::Continue
741 }
742 }
743
744 pub fn notify(self: &Arc<Self>, event: HookEvent) {
763 self.notify_with(event.kind, async move { Some(event) });
764 }
765
766 pub fn notify_with<F, I>(self: &Arc<Self>, kind: HookKind, events: F)
789 where
790 F: std::future::Future<Output = I> + Send + 'static,
791 I: IntoIterator<Item = HookEvent> + Send,
792 I::IntoIter: Send,
793 {
794 if !kind.is_notification() {
795 tracing::error!(hook = %kind.as_str(), "notify used for a hook that is not notify-only; nothing sent");
797 return;
798 }
799 if self.is_worker_view() || !self.may_have_subscriber(kind) {
800 return;
801 }
802 let Ok(runtime) = tokio::runtime::Handle::try_current() else {
803 return;
804 };
805 let Ok(slot) = Arc::clone(&self.notify_slots).try_acquire_owned() else {
806 tracing::warn!(
807 hook = %kind.as_str(),
808 in_flight = NOTIFY_MAX_IN_FLIGHT,
809 "Notify-only hook event dropped: earlier deliveries are still in flight"
810 );
811 return;
812 };
813 let bus = Arc::clone(self);
814 runtime.spawn(async move {
815 let _slot = slot;
816 let events: Vec<HookEvent> =
817 events.await.into_iter().filter(|event| event.kind == kind).collect();
818 bus.deliver_notifications(kind, &events).await;
819 });
820 }
821
822 async fn deliver_notifications(&self, kind: HookKind, events: &[HookEvent]) {
825 let registrations = {
826 let handlers = self.handlers.read().await;
827 handlers.get(&kind).cloned().unwrap_or_default()
828 }; let sequences = registrations.iter().map(|reg| async move {
830 let wanted = |event: &HookEvent| {
831 reg.tool_filter
832 .as_deref()
833 .is_none_or(|filter| tool_filter_matches(filter, event))
834 && reg.matcher.as_ref().is_none_or(|m| m.matches(event))
835 };
836 for (sent, event) in events.iter().enumerate() {
837 if !wanted(event) {
838 continue;
839 }
840 let call = tokio::time::timeout(HANDLER_TIMEOUT, reg.handler.handle(event));
843 match std::panic::AssertUnwindSafe(call).catch_unwind().await {
844 Ok(Ok(_answer)) => {}
845 Ok(Err(_timeout)) => {
846 let not_sent =
851 events[sent + 1..].iter().filter(|event| wanted(event)).count();
852 tracing::warn!(
853 hook = %kind.as_str(),
854 extension = %reg.handler.id(),
855 timeout_secs = HANDLER_TIMEOUT.as_secs(),
856 not_sent,
857 "Notify-only hook handler timed out — the later events of this delivery are not sent to it"
858 );
859 break;
860 }
861 Err(_panic) => tracing::error!(
862 hook = %kind.as_str(),
863 extension = %reg.handler.id(),
864 "Notify-only hook handler panicked — event skipped for it"
865 ),
866 }
867 }
868 });
869 join_all(sequences).await;
870 }
871
872 fn may_have_subscriber(&self, kind: HookKind) -> bool {
875 match self.handlers.try_read() {
876 Ok(handlers) => handlers.get(&kind).is_some_and(|regs| !regs.is_empty()),
877 Err(_) => true,
878 }
879 }
880
881 #[cfg(test)]
883 pub(crate) fn notifies_in_flight(&self) -> usize {
884 NOTIFY_MAX_IN_FLIGHT - self.notify_slots.available_permits()
885 }
886
887 pub async fn unsubscribe_all(&self, extension_id: &str) {
889 *self
890 .generations
891 .lock()
892 .unwrap_or_else(|e| e.into_inner())
893 .entry(extension_id.to_string())
894 .or_insert(0) += 1;
895 let mut handlers = self.handlers.write().await;
896 for regs in handlers.values_mut() {
897 regs.retain(|r| r.handler.id() != extension_id);
898 }
899 }
900
901 pub async fn handler_count(&self) -> usize {
904 if let Some(host) = &self.transforms_of {
905 return host
906 .worker_transform_registrations(HookKind::AfterToolCall)
907 .await
908 .map_or(0, |regs| regs.len());
909 }
910 let handlers = self.handlers.read().await;
911 handlers.values().map(|v| v.len()).sum()
912 }
913
914 pub async fn is_empty(&self) -> bool {
916 if self.is_worker_view() {
917 return self.handler_count().await == 0;
918 }
919 let handlers = self.handlers.read().await;
920 handlers.values().all(|v| v.is_empty())
921 }
922
923 pub async fn subscriptions_for(&self, extension_id: &str) -> Vec<(HookKind, Option<String>)> {
926 let handlers = self.handlers.read().await;
927 let mut out: Vec<(HookKind, Option<String>)> = Vec::new();
928 for (kind, regs) in handlers.iter() {
929 for reg in regs {
930 if reg.handler.id() == extension_id {
931 out.push((*kind, reg.tool_filter.clone()));
932 }
933 }
934 }
935 out.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()).then_with(|| a.1.cmp(&b.1)));
936 out
937 }
938}
939
940impl Default for HookBus {
941 fn default() -> Self {
942 Self::new()
943 }
944}
945
946#[cfg(test)]
947mod tests {
948 use super::*;
949 use crate::extensions::hooks::events::HookEvent;
950 use crate::extensions::permissions::Permission;
951 use async_trait::async_trait;
952 use std::sync::atomic::{AtomicUsize, Ordering};
953
954 struct TestHandler {
956 id: String,
957 call_count: AtomicUsize,
958 result: HookResult,
959 }
960
961 impl TestHandler {
962 fn new(id: &str, result: HookResult) -> Arc<Self> {
963 Arc::new(Self {
964 id: id.to_string(),
965 call_count: AtomicUsize::new(0),
966 result,
967 })
968 }
969
970 fn calls(&self) -> usize {
971 self.call_count.load(Ordering::Relaxed)
972 }
973 }
974
975 #[async_trait]
976 impl crate::extensions::runtime::ExtensionHandler for TestHandler {
977 fn id(&self) -> &str {
978 &self.id
979 }
980
981 async fn handle(&self, _event: &HookEvent) -> HookResult {
982 self.call_count.fetch_add(1, Ordering::Relaxed);
983 self.result.clone()
984 }
985
986 async fn shutdown(&self) {}
987 }
988
989 fn perms_with(perms: &[Permission]) -> PermissionSet {
990 let mut set = PermissionSet::new();
991 for p in perms {
992 set.grant(*p);
993 }
994 set
995 }
996
997 #[tokio::test]
1001 #[serial_test::serial(synaps_base_dir)]
1002 async fn after_tool_call_replace_substitutes_recorded_output() {
1003 let base = crate::test_env::BaseDirGuard::new();
1005 let bus = std::sync::Arc::new(HookBus::new());
1006 let handler = TestHandler::new(
1007 "compressor",
1008 HookResult::Replace {
1009 output: "COMPRESSED".into(),
1010 },
1011 );
1012 bus.subscribe(
1013 HookKind::AfterToolCall,
1014 handler,
1015 None,
1016 None,
1017 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
1018 )
1019 .await
1020 .unwrap();
1021
1022 let recorded = crate::runtime::emit_after_tool_call(
1023 &bus,
1024 "bash",
1025 None,
1026 serde_json::json!({"command": "cat huge.log"}),
1027 "RAW 10k lines".to_string(),
1028 30_000,
1029 None,
1030 None,
1031 )
1032 .await;
1033
1034 assert_eq!(recorded, "COMPRESSED", "Replace output must reach history");
1035 let audit = std::fs::read_to_string(
1036 crate::extensions::audit::intercept_file_path_for(base.path()),
1037 )
1038 .expect("intercept audit must land in the isolated base dir");
1039 assert!(audit.contains("\"plugin_id\":\"compressor\""));
1040 }
1041
1042 #[tokio::test]
1044 async fn after_tool_call_without_replace_keeps_original_output() {
1045 let bus = std::sync::Arc::new(HookBus::new());
1046 let recorded = crate::runtime::emit_after_tool_call(
1047 &bus,
1048 "bash",
1049 None,
1050 serde_json::json!({}),
1051 "RAW".to_string(),
1052 30_000,
1053 None,
1054 None,
1055 )
1056 .await;
1057
1058 assert_eq!(recorded, "RAW", "no Replace → original output preserved");
1059 }
1060
1061 #[tokio::test]
1065 #[serial_test::serial(synaps_base_dir)]
1066 async fn replace_stops_chain_for_after_tool_call() {
1067 let _base = crate::test_env::BaseDirGuard::new();
1069 let bus = HookBus::new();
1070 let first = TestHandler::new(
1071 "first",
1072 HookResult::Replace {
1073 output: "FIRST".into(),
1074 },
1075 );
1076 let second = TestHandler::new(
1077 "second",
1078 HookResult::Replace {
1079 output: "SECOND".into(),
1080 },
1081 );
1082 let perms = perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]);
1083
1084 bus.subscribe(
1085 HookKind::AfterToolCall,
1086 first.clone(),
1087 None,
1088 None,
1089 perms.clone(),
1090 )
1091 .await
1092 .unwrap();
1093 bus.subscribe(HookKind::AfterToolCall, second.clone(), None, None, perms)
1094 .await
1095 .unwrap();
1096
1097 let event = HookEvent::after_tool_call("bash", serde_json::json!({}), "RAW".to_string());
1098 let result = bus.emit(&event).await;
1099
1100 assert_eq!(
1101 result,
1102 HookResult::Replace {
1103 output: "FIRST".into()
1104 }
1105 );
1106 assert_eq!(first.calls(), 1);
1107 assert_eq!(second.calls(), 0); }
1109
1110 #[tokio::test]
1114 async fn replace_without_transform_permission_is_ignored() {
1115 let bus = std::sync::Arc::new(HookBus::new());
1116 let handler = TestHandler::new(
1117 "observer-only",
1118 HookResult::Replace {
1119 output: "SNEAKY".into(),
1120 },
1121 );
1122 bus.subscribe(
1124 HookKind::AfterToolCall,
1125 handler.clone(),
1126 None,
1127 None,
1128 perms_with(&[Permission::ToolsIntercept]),
1129 )
1130 .await
1131 .unwrap();
1132
1133 let recorded = crate::runtime::emit_after_tool_call(
1134 &bus,
1135 "bash",
1136 None,
1137 serde_json::json!({}),
1138 "ORIGINAL".to_string(),
1139 30_000,
1140 None,
1141 None,
1142 )
1143 .await;
1144
1145 assert_eq!(handler.calls(), 1, "handler still runs (it's subscribed)");
1146 assert_eq!(
1147 recorded, "ORIGINAL",
1148 "Replace without tools.transform_output must be ignored — original preserved"
1149 );
1150 }
1151
1152 fn phase(p: &str) -> HookResult {
1155 HookResult::ContextPhase { phase: p.into() }
1156 }
1157
1158 fn message_complete_event() -> HookEvent {
1159 HookEvent::on_message_complete(
1160 "done",
1161 serde_json::json!({"content_block_count": 1, "has_tool_use": false}),
1162 )
1163 }
1164
1165 #[tokio::test]
1168 async fn context_phase_accepted_with_session_lifecycle() {
1169 let bus = HookBus::new();
1170 let reporter = TestHandler::new("reporter", phase("new_task"));
1171 bus.subscribe(
1172 HookKind::OnMessageComplete,
1173 reporter.clone(),
1174 None,
1175 None,
1176 perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]),
1177 )
1178 .await
1179 .unwrap();
1180
1181 let result = bus.emit(&message_complete_event()).await;
1182 assert_eq!(result, phase("new_task"));
1183 assert_eq!(reporter.calls(), 1);
1184 }
1185
1186 #[tokio::test]
1189 async fn context_phase_ignored_without_session_lifecycle() {
1190 let bus = HookBus::new();
1191 let reporter = TestHandler::new("observer-only", phase("new_task"));
1192 bus.subscribe(
1193 HookKind::OnMessageComplete,
1194 reporter.clone(),
1195 None,
1196 None,
1197 perms_with(&[Permission::LlmContent]),
1198 )
1199 .await
1200 .unwrap();
1201
1202 let result = bus.emit(&message_complete_event()).await;
1203 assert_eq!(result, HookResult::Continue);
1204 assert_eq!(reporter.calls(), 1, "handler still runs (it's subscribed)");
1205 }
1206
1207 #[tokio::test]
1211 async fn context_phase_does_not_stop_chain_and_first_valid_wins() {
1212 let bus = HookBus::new();
1213 let unprivileged = TestHandler::new("unprivileged", phase("plan"));
1214 let first = TestHandler::new("first", phase("new_task"));
1215 let second = TestHandler::new("second", phase("wrap_up"));
1216 let observer = TestHandler::new("observer", HookResult::Continue);
1217 let both = || perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]);
1218
1219 bus.subscribe(
1220 HookKind::OnMessageComplete,
1221 unprivileged.clone(),
1222 None,
1223 None,
1224 perms_with(&[Permission::LlmContent]),
1225 )
1226 .await
1227 .unwrap();
1228 for handler in [first.clone(), second.clone()] {
1229 bus.subscribe(HookKind::OnMessageComplete, handler, None, None, both())
1230 .await
1231 .unwrap();
1232 }
1233 bus.subscribe(
1234 HookKind::OnMessageComplete,
1235 observer.clone(),
1236 None,
1237 None,
1238 perms_with(&[Permission::LlmContent]),
1239 )
1240 .await
1241 .unwrap();
1242
1243 let result = bus.emit(&message_complete_event()).await;
1244 assert_eq!(result, phase("new_task"), "first valid report wins");
1245 assert_eq!(unprivileged.calls(), 1);
1246 assert_eq!(first.calls(), 1);
1247 assert_eq!(second.calls(), 1, "later reporter still runs");
1248 assert_eq!(observer.calls(), 1, "later observer still runs");
1249 }
1250
1251 #[tokio::test]
1254 async fn context_phase_survives_ignored_block_on_message_complete() {
1255 let bus = HookBus::new();
1256 let blocker = TestHandler::new(
1257 "blocker",
1258 HookResult::Block {
1259 reason: "nope".into(),
1260 },
1261 );
1262 let reporter = TestHandler::new("reporter", phase("execute"));
1263 bus.subscribe(
1264 HookKind::OnMessageComplete,
1265 blocker.clone(),
1266 None,
1267 None,
1268 perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]),
1269 )
1270 .await
1271 .unwrap();
1272 bus.subscribe(
1273 HookKind::OnMessageComplete,
1274 reporter.clone(),
1275 None,
1276 None,
1277 perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]),
1278 )
1279 .await
1280 .unwrap();
1281
1282 let result = bus.emit(&message_complete_event()).await;
1283 assert_eq!(result, phase("execute"));
1284 assert_eq!(blocker.calls(), 1);
1285 assert_eq!(reporter.calls(), 1);
1286 }
1287
1288 #[tokio::test]
1291 async fn context_phase_not_allowed_on_other_hooks() {
1292 let bus = HookBus::new();
1293 let all_perms = perms_with(&[
1294 Permission::ToolsIntercept,
1295 Permission::LlmContent,
1296 Permission::SessionLifecycle,
1297 ]);
1298 let handlers: Vec<(HookKind, HookEvent, Arc<TestHandler>)> = vec![
1299 (
1300 HookKind::BeforeToolCall,
1301 HookEvent::before_tool_call("bash", serde_json::json!({})),
1302 TestHandler::new("btc", phase("new_task")),
1303 ),
1304 (
1305 HookKind::AfterToolCall,
1306 HookEvent::after_tool_call("bash", serde_json::json!({}), "o".into()),
1307 TestHandler::new("atc", phase("new_task")),
1308 ),
1309 (
1310 HookKind::BeforeMessage,
1311 HookEvent::before_message("hi"),
1312 TestHandler::new("bm", phase("new_task")),
1313 ),
1314 (
1315 HookKind::OnCompaction,
1316 HookEvent::on_compaction("a", "b", "s", 1, serde_json::json!({})),
1317 TestHandler::new("oc", phase("new_task")),
1318 ),
1319 (
1320 HookKind::OnSessionStart,
1321 HookEvent::on_session_start("s"),
1322 TestHandler::new("oss", phase("new_task")),
1323 ),
1324 (
1325 HookKind::OnSessionEnd,
1326 HookEvent::on_session_end("s", None),
1327 TestHandler::new("ose", phase("new_task")),
1328 ),
1329 ];
1330 for (kind, _, handler) in &handlers {
1331 bus.subscribe(*kind, handler.clone(), None, None, all_perms.clone())
1332 .await
1333 .unwrap();
1334 }
1335 for (kind, event, handler) in &handlers {
1336 let result = bus.emit(event).await;
1337 assert_eq!(result, HookResult::Continue, "{}", kind.as_str());
1338 assert_eq!(handler.calls(), 1, "{}", kind.as_str());
1339 }
1340 let end = HookEvent::on_session_end("s", None);
1342 assert_eq!(bus.emit_concurrent(&end).await, HookResult::Continue);
1343 }
1344
1345 #[tokio::test]
1348 async fn context_phase_concurrent_emit_two_key_gate() {
1349 let bus = HookBus::new();
1350 let unprivileged = TestHandler::new("unprivileged", phase("plan"));
1351 let reporter = TestHandler::new("reporter", phase("wrap_up"));
1352 bus.subscribe(
1353 HookKind::OnMessageComplete,
1354 unprivileged.clone(),
1355 None,
1356 None,
1357 perms_with(&[Permission::LlmContent]),
1358 )
1359 .await
1360 .unwrap();
1361 bus.subscribe(
1362 HookKind::OnMessageComplete,
1363 reporter.clone(),
1364 None,
1365 None,
1366 perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]),
1367 )
1368 .await
1369 .unwrap();
1370
1371 let result = bus.emit_concurrent(&message_complete_event()).await;
1372 assert_eq!(result, phase("wrap_up"));
1373 assert_eq!(unprivileged.calls(), 1);
1374 assert_eq!(reporter.calls(), 1);
1375 }
1376
1377 #[test]
1378 fn trace_env_value_parser_accepts_common_truthy_values() {
1379 for value in ["1", "true", "TRUE", "yes", "on"] {
1380 std::env::set_var("SYNAPS_EXTENSIONS_TRACE", value);
1381 assert!(
1382 extensions_trace_enabled(),
1383 "{value} should enable trace mode"
1384 );
1385 }
1386
1387 for value in ["", "0", "false", "off", "no"] {
1388 std::env::set_var("SYNAPS_EXTENSIONS_TRACE", value);
1389 assert!(
1390 !extensions_trace_enabled(),
1391 "{value:?} should not enable trace mode"
1392 );
1393 }
1394 std::env::remove_var("SYNAPS_EXTENSIONS_TRACE");
1395 }
1396
1397 #[tokio::test]
1398 async fn matcher_skips_handler_when_input_does_not_contain_value() {
1399 let bus = HookBus::new();
1400 let handler = TestHandler::new(
1401 "matcher",
1402 HookResult::Block {
1403 reason: "matched".into(),
1404 },
1405 );
1406 let mut perms = PermissionSet::new();
1407 perms.grant(Permission::ToolsIntercept);
1408 bus.subscribe(
1409 HookKind::BeforeToolCall,
1410 handler.clone(),
1411 None,
1412 Some(HookMatcher {
1413 input_contains: Some("danger".to_string()),
1414 input_equals: None,
1415 }),
1416 perms,
1417 )
1418 .await
1419 .unwrap();
1420
1421 let safe = HookEvent::before_tool_call("bash", serde_json::json!({"command": "echo safe"}));
1422 assert!(matches!(bus.emit(&safe).await, HookResult::Continue));
1423
1424 let danger =
1425 HookEvent::before_tool_call("bash", serde_json::json!({"command": "echo danger"}));
1426 assert!(matches!(bus.emit(&danger).await, HookResult::Block { .. }));
1427 }
1428
1429 #[test]
1430 fn hook_result_action_names_are_stable_for_trace_logs() {
1431 assert_eq!(hook_result_action(&HookResult::Continue), "continue");
1432 assert_eq!(
1433 hook_result_action(&HookResult::Block {
1434 reason: "stop".into(),
1435 }),
1436 "block"
1437 );
1438 assert_eq!(
1439 hook_result_action(&HookResult::Inject {
1440 content: "context".into(),
1441 }),
1442 "inject"
1443 );
1444 assert_eq!(
1445 hook_result_action(&HookResult::confirm("Proceed?")),
1446 "confirm"
1447 );
1448 assert_eq!(
1449 hook_result_action(&HookResult::Modify {
1450 input: serde_json::json!({"command": "echo safe"}),
1451 }),
1452 "modify"
1453 );
1454 }
1455
1456 #[tokio::test]
1457 async fn empty_bus_returns_continue() {
1458 let bus = HookBus::new();
1459 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1460 let result = bus.emit(&event).await;
1461 assert!(matches!(result, HookResult::Continue));
1462 }
1463
1464 #[tokio::test]
1465 async fn handler_receives_events() {
1466 let bus = HookBus::new();
1467 let handler = TestHandler::new("test-ext", HookResult::Continue);
1468 let perms = perms_with(&[Permission::ToolsIntercept]);
1469
1470 bus.subscribe(HookKind::BeforeToolCall, handler.clone(), None, None, perms)
1471 .await
1472 .unwrap();
1473
1474 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "ls"}));
1475 bus.emit(&event).await;
1476
1477 assert_eq!(handler.calls(), 1);
1478 }
1479
1480 async fn bus_with(handlers: Vec<Arc<TestHandler>>) -> HookBus {
1481 let bus = HookBus::new();
1482 let perms = perms_with(&[Permission::ToolsIntercept]);
1483 for h in handlers {
1484 bus.subscribe(HookKind::BeforeToolCall, h, None, None, perms.clone())
1485 .await
1486 .unwrap();
1487 }
1488 bus
1489 }
1490
1491 #[tokio::test]
1494 async fn confirm_does_not_stop_chain_for_before_tool_call() {
1495 let confirmer = TestHandler::new("confirmer", HookResult::confirm("Run this command?"));
1496 let after = TestHandler::new("after", HookResult::Continue);
1497 let bus = bus_with(vec![confirmer.clone(), after.clone()]).await;
1498
1499 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1500 let result = bus.emit(&event).await;
1501
1502 assert!(matches!(result, HookResult::Confirm { .. }));
1503 assert_eq!(confirmer.calls(), 1);
1504 assert_eq!(after.calls(), 1, "later handlers are still evaluated");
1505 }
1506
1507 #[tokio::test]
1509 async fn later_block_wins_over_earlier_confirm() {
1510 let confirmer = TestHandler::new("confirmer", HookResult::confirm("Run?"));
1511 let blocker = TestHandler::new(
1512 "policy",
1513 HookResult::Block {
1514 reason: "policy veto".into(),
1515 },
1516 );
1517 let bus = bus_with(vec![confirmer, blocker]).await;
1518 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1519 assert!(matches!(
1520 bus.emit(&event).await,
1521 HookResult::Block { reason } if reason == "policy veto"
1522 ));
1523 }
1524
1525 #[tokio::test]
1528 async fn confirms_from_several_extensions_merge_sources() {
1529 let a = TestHandler::new("ext-a", HookResult::confirm("A asks"));
1530 let b = TestHandler::new("ext-b", HookResult::confirm("B asks"));
1531 let bus = bus_with(vec![a, b]).await;
1532 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1533 match bus.emit(&event).await {
1534 HookResult::Confirm {
1535 message, sources, ..
1536 } => {
1537 assert_eq!(sources, vec!["ext-a".to_string(), "ext-b".to_string()]);
1538 assert!(message.contains("A asks") && message.contains("B asks"), "{message}");
1539 assert!(message.contains("[ext-a]") && message.contains("[ext-b]"), "{message}");
1540 }
1541 other => panic!("expected merged Confirm, got {other:?}"),
1542 }
1543 }
1544
1545 #[tokio::test]
1548 async fn armed_latch_never_skips_a_later_block() {
1549 let confirmer = TestHandler::new("jev", HookResult::confirm("Run?"));
1550 let blocker = TestHandler::new(
1551 "policy",
1552 HookResult::Block {
1553 reason: "policy veto".into(),
1554 },
1555 );
1556 let bus = bus_with(vec![confirmer, blocker.clone()]).await;
1557 let latch = crate::tools::SessionConfirmLatch::new();
1558 latch.allow(&[("jev".to_string(), 0)]);
1559 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1560 let decision = crate::runtime::resolve_before_tool_call_decision(
1561 serde_json::json!({}),
1562 bus.emit(&event).await,
1563 None,
1564 false,
1565 Some(&latch),
1566 )
1567 .await;
1568 assert_eq!(blocker.calls(), 1);
1569 assert!(matches!(
1570 decision,
1571 crate::runtime::BeforeToolCallDecision::Block { reason } if reason == "policy veto"
1572 ));
1573 }
1574
1575 struct InputSeen {
1577 id: String,
1578 seen: std::sync::Mutex<Option<serde_json::Value>>,
1579 result: HookResult,
1580 }
1581 #[async_trait]
1582 impl crate::extensions::runtime::ExtensionHandler for InputSeen {
1583 fn id(&self) -> &str {
1584 &self.id
1585 }
1586 async fn handle(&self, event: &HookEvent) -> HookResult {
1587 *self.seen.lock().unwrap() = event.tool_input.clone();
1588 self.result.clone()
1589 }
1590 async fn shutdown(&self) {}
1591 }
1592 fn input_seen(id: &str, result: HookResult) -> Arc<InputSeen> {
1593 Arc::new(InputSeen {
1594 id: id.into(),
1595 seen: std::sync::Mutex::new(None),
1596 result,
1597 })
1598 }
1599 fn modify(cmd: &str) -> HookResult {
1600 HookResult::Modify {
1601 input: serde_json::json!({ "command": cmd }),
1602 }
1603 }
1604 async fn bus_of(handlers: Vec<Arc<dyn crate::extensions::runtime::ExtensionHandler>>) -> HookBus {
1605 let bus = HookBus::new();
1606 let perms = perms_with(&[Permission::ToolsIntercept]);
1607 for h in handlers {
1608 bus.subscribe(HookKind::BeforeToolCall, h, None, None, perms.clone())
1609 .await
1610 .unwrap();
1611 }
1612 bus
1613 }
1614
1615 #[tokio::test]
1618 async fn modify_then_block_blocks_and_blocker_vets_modified_input() {
1619 let blocker = input_seen(
1620 "policy",
1621 HookResult::Block {
1622 reason: "veto".into(),
1623 },
1624 );
1625 let bus = bus_of(vec![input_seen("rewriter", modify("rm -rf /")), blocker.clone()]).await;
1626 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "ls"}));
1627 assert!(matches!(bus.emit(&event).await, HookResult::Block { reason } if reason == "veto"));
1628 assert_eq!(
1629 blocker.seen.lock().unwrap().clone(),
1630 Some(serde_json::json!({"command": "rm -rf /"})),
1631 "the blocker must vet the rewritten input, not the original"
1632 );
1633 }
1634
1635 #[tokio::test]
1638 async fn modify_then_confirm_prompts_with_modified_input() {
1639 let confirmer = input_seen("jev", HookResult::confirm("ok?"));
1640 let bus = bus_of(vec![input_seen("rewriter", modify("echo safe")), confirmer.clone()]).await;
1641 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "ls"}));
1642 let result = bus.emit(&event).await;
1643 assert_eq!(
1644 confirmer.seen.lock().unwrap().clone(),
1645 Some(serde_json::json!({"command": "echo safe"}))
1646 );
1647 match &result {
1648 HookResult::Confirm {
1649 sources,
1650 modified_input,
1651 ..
1652 } => {
1653 assert_eq!(sources, &vec!["jev".to_string()]);
1654 assert_eq!(
1655 modified_input,
1656 &Some(serde_json::json!({"command": "echo safe"}))
1657 );
1658 }
1659 other => panic!("expected Confirm, got {other:?}"),
1660 }
1661 let decision = crate::runtime::resolve_before_tool_call_decision(
1663 serde_json::json!({"command": "ls"}),
1664 result,
1665 None,
1666 true,
1667 None,
1668 )
1669 .await;
1670 assert!(matches!(
1671 decision,
1672 crate::runtime::BeforeToolCallDecision::Continue { input }
1673 if input == serde_json::json!({"command": "echo safe"})
1674 ));
1675 }
1676
1677 #[tokio::test]
1680 async fn modifiers_compose_and_final_input_is_returned() {
1681 let second = input_seen("second", modify("two"));
1682 let bus = bus_of(vec![input_seen("first", modify("one")), second.clone()]).await;
1683 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "zero"}));
1684 assert!(matches!(
1685 bus.emit(&event).await,
1686 HookResult::Modify { input } if input == serde_json::json!({"command": "two"})
1687 ));
1688 assert_eq!(
1689 second.seen.lock().unwrap().clone(),
1690 Some(serde_json::json!({"command": "one"}))
1691 );
1692 }
1693
1694 #[tokio::test]
1697 async fn reinstall_under_same_id_gets_a_new_confirm_generation() {
1698 let bus = bus_with(vec![TestHandler::new("jev", HookResult::confirm("ok?"))]).await;
1699 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1700 let gens = |r: HookResult| match r {
1701 HookResult::Confirm { generations, .. } => generations,
1702 other => panic!("{other:?}"),
1703 };
1704 assert_eq!(gens(bus.emit(&event).await), vec![0]);
1705 bus.unsubscribe_all("jev").await;
1706 bus.subscribe(
1707 HookKind::BeforeToolCall,
1708 TestHandler::new("jev", HookResult::confirm("ok?")),
1709 None,
1710 None,
1711 perms_with(&[Permission::ToolsIntercept]),
1712 )
1713 .await
1714 .unwrap();
1715 let latch = crate::tools::SessionConfirmLatch::new();
1716 latch.allow(&[("jev".to_string(), 0)]);
1717 let result = bus.emit(&event).await;
1718 let decision = crate::runtime::resolve_before_tool_call_decision(
1719 serde_json::json!({}),
1720 result.clone(),
1721 None,
1722 false,
1723 Some(&latch),
1724 )
1725 .await;
1726 assert_eq!(gens(result), vec![1]);
1727 assert!(
1728 matches!(decision, crate::runtime::BeforeToolCallDecision::Block { .. }),
1729 "old consent must not carry over to the reinstalled extension"
1730 );
1731 }
1732
1733 #[tokio::test]
1737 async fn modify_after_confirm_is_ignored() {
1738 let confirmer = TestHandler::new("confirmer", HookResult::confirm("Run?"));
1739 let modifier = TestHandler::new(
1740 "modifier",
1741 HookResult::Modify {
1742 input: serde_json::json!({"command": "echo other"}),
1743 },
1744 );
1745 let bus = bus_with(vec![confirmer, modifier]).await;
1746 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1747 assert!(matches!(
1748 bus.emit(&event).await,
1749 HookResult::Confirm { modified_input: None, .. }
1750 ), "Confirm→Modify: the modify is ignored (logged), the original input is confirmed");
1751 }
1752
1753 #[tokio::test]
1754 async fn confirm_is_stamped_with_source_extension_and_tool() {
1755 let bus = HookBus::new();
1756 let confirmer = TestHandler::new("jev", HookResult::confirm("Run ls?"));
1757 let perms = perms_with(&[Permission::ToolsIntercept]);
1758 bus.subscribe(HookKind::BeforeToolCall, confirmer, None, None, perms)
1759 .await
1760 .unwrap();
1761 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1762 match bus.emit(&event).await {
1763 HookResult::Confirm {
1764 message,
1765 sources,
1766 tool,
1767 ..
1768 } => {
1769 assert_eq!(message, "Run ls?");
1770 assert_eq!(sources, vec!["jev".to_string()]);
1771 assert_eq!(tool.as_deref(), Some("bash"));
1772 }
1773 other => panic!("expected Confirm, got {other:?}"),
1774 }
1775 }
1776
1777 #[tokio::test]
1778 async fn confirm_is_ignored_for_non_tool_hooks() {
1779 let bus = HookBus::new();
1780 let confirmer = TestHandler::new(
1781 "confirmer",
1782 HookResult::confirm("Not allowed here"),
1783 );
1784 let perms = perms_with(&[Permission::LlmContent]);
1785
1786 bus.subscribe(
1787 HookKind::BeforeMessage,
1788 confirmer.clone(),
1789 None,
1790 None,
1791 perms,
1792 )
1793 .await
1794 .unwrap();
1795
1796 let event = HookEvent::before_message("hello");
1797 let result = bus.emit(&event).await;
1798
1799 assert!(matches!(result, HookResult::Continue));
1800 assert_eq!(confirmer.calls(), 1);
1801 }
1802
1803 #[tokio::test]
1804 async fn block_stops_chain() {
1805 let bus = HookBus::new();
1806 let blocker = TestHandler::new(
1807 "blocker",
1808 HookResult::Block {
1809 reason: "dangerous".into(),
1810 },
1811 );
1812 let after = TestHandler::new("after", HookResult::Continue);
1813 let perms = perms_with(&[Permission::ToolsIntercept]);
1814
1815 bus.subscribe(
1816 HookKind::BeforeToolCall,
1817 blocker.clone(),
1818 None,
1819 None,
1820 perms.clone(),
1821 )
1822 .await
1823 .unwrap();
1824 bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
1825 .await
1826 .unwrap();
1827
1828 let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1829 let result = bus.emit(&event).await;
1830
1831 assert!(matches!(result, HookResult::Block { .. }));
1832 assert_eq!(blocker.calls(), 1);
1833 assert_eq!(after.calls(), 0); }
1835
1836 #[tokio::test]
1839 async fn modify_does_not_stop_chain_later_block_wins() {
1840 let modifier = TestHandler::new(
1841 "modifier",
1842 HookResult::Modify {
1843 input: serde_json::json!({"command": "echo safe"}),
1844 },
1845 );
1846 let after = TestHandler::new(
1847 "after",
1848 HookResult::Block {
1849 reason: "vetoed".into(),
1850 },
1851 );
1852 let bus = bus_with(vec![modifier.clone(), after.clone()]).await;
1853 let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "rm -rf /"}));
1854 let result = bus.emit(&event).await;
1855 assert!(matches!(result, HookResult::Block { reason } if reason == "vetoed"));
1856 assert_eq!(modifier.calls(), 1);
1857 assert_eq!(after.calls(), 1);
1858 }
1859
1860 #[tokio::test]
1861 async fn tool_filter_only_matches_specified_tool() {
1862 let bus = HookBus::new();
1863 let handler = TestHandler::new("bash-only", HookResult::Continue);
1864 let perms = perms_with(&[Permission::ToolsIntercept]);
1865
1866 bus.subscribe(
1867 HookKind::AfterToolCall,
1868 handler.clone(),
1869 Some("bash".into()),
1870 None,
1871 perms,
1872 )
1873 .await
1874 .unwrap();
1875
1876 let event = HookEvent::after_tool_call("read", serde_json::json!({}), "content".into());
1878 bus.emit(&event).await;
1879 assert_eq!(handler.calls(), 0);
1880
1881 let event = HookEvent::after_tool_call("bash", serde_json::json!({}), "output".into());
1883 bus.emit(&event).await;
1884 assert_eq!(handler.calls(), 1);
1885 }
1886
1887 #[tokio::test]
1891 async fn edit_and_write_filters_also_guard_multi_edit_before_the_call() {
1892 for filter in ["edit", "write"] {
1893 let bus = HookBus::new();
1894 let guard = TestHandler::new(
1895 "guard",
1896 HookResult::Block {
1897 reason: "guarded".into(),
1898 },
1899 );
1900 let after = TestHandler::new("after", HookResult::Continue);
1901 let perms = perms_with(&[Permission::ToolsIntercept]);
1902 bus.subscribe(HookKind::BeforeToolCall, guard.clone(), Some(filter.into()), None, perms.clone())
1903 .await
1904 .unwrap();
1905 bus.subscribe(HookKind::AfterToolCall, after.clone(), Some(filter.into()), None, perms)
1906 .await
1907 .unwrap();
1908 let input = serde_json::json!({"edits": [{"path": "/etc/hosts", "op": "append", "text": "x"}]});
1909 let blocked = bus.emit(&HookEvent::before_tool_call("multi_edit", input.clone())).await;
1910 assert!(matches!(blocked, HookResult::Block { reason } if reason == "guarded"), "{filter}");
1911 assert_eq!(guard.calls(), 1, "{filter}");
1912 let other = bus.emit(&HookEvent::before_tool_call("read", serde_json::json!({}))).await;
1913 assert!(matches!(other, HookResult::Continue), "{filter}: only the file-changing tool");
1914 bus.emit(&HookEvent::after_tool_call("multi_edit", input, "ok".into())).await;
1915 assert_eq!(after.calls(), 0, "{filter}: after_tool_call filters stay exact");
1916 }
1917 }
1918
1919 #[tokio::test]
1920 async fn permission_denied_rejects_subscribe() {
1921 let bus = HookBus::new();
1922 let handler = TestHandler::new("no-perms", HookResult::Continue);
1923 let perms = PermissionSet::new(); let result = bus
1926 .subscribe(HookKind::BeforeToolCall, handler, None, None, perms)
1927 .await;
1928
1929 assert!(result.is_err());
1930 assert!(result.unwrap_err().contains("lacks permission"));
1931 }
1932
1933 #[tokio::test]
1934 async fn unsubscribe_removes_handlers() {
1935 let bus = HookBus::new();
1936 let handler = TestHandler::new("removable", HookResult::Continue);
1937 let perms = perms_with(&[Permission::ToolsIntercept]);
1938
1939 bus.subscribe(HookKind::BeforeToolCall, handler.clone(), None, None, perms)
1940 .await
1941 .unwrap();
1942 assert_eq!(bus.handler_count().await, 1);
1943
1944 bus.unsubscribe_all("removable").await;
1945 assert_eq!(bus.handler_count().await, 0);
1946 }
1947
1948 #[tokio::test]
1949 async fn subscriptions_for_lists_only_matching_extension() {
1950 let bus = HookBus::new();
1951 let alpha = TestHandler::new("alpha", HookResult::Continue);
1952 let beta = TestHandler::new("beta", HookResult::Continue);
1953 let perms = perms_with(&[Permission::ToolsIntercept]);
1954
1955 bus.subscribe(
1956 HookKind::BeforeToolCall,
1957 alpha.clone(),
1958 Some("bash".into()),
1959 None,
1960 perms.clone(),
1961 )
1962 .await
1963 .unwrap();
1964 bus.subscribe(
1965 HookKind::AfterToolCall,
1966 alpha.clone(),
1967 None,
1968 None,
1969 perms.clone(),
1970 )
1971 .await
1972 .unwrap();
1973 bus.subscribe(HookKind::BeforeToolCall, beta.clone(), None, None, perms)
1974 .await
1975 .unwrap();
1976
1977 let alpha_subs = bus.subscriptions_for("alpha").await;
1978 assert_eq!(alpha_subs.len(), 2);
1979 assert_eq!(alpha_subs[0].0, HookKind::AfterToolCall);
1981 assert_eq!(alpha_subs[0].1, None);
1982 assert_eq!(alpha_subs[1].0, HookKind::BeforeToolCall);
1983 assert_eq!(alpha_subs[1].1, Some("bash".to_string()));
1984
1985 let beta_subs = bus.subscriptions_for("beta").await;
1986 assert_eq!(beta_subs, vec![(HookKind::BeforeToolCall, None)]);
1987
1988 let none_subs = bus.subscriptions_for("ghost").await;
1989 assert!(none_subs.is_empty());
1990 }
1991
1992 #[tokio::test]
1993 async fn is_empty_reflects_state() {
1994 let bus = HookBus::new();
1995 assert!(bus.is_empty().await);
1996
1997 let handler = TestHandler::new("ext", HookResult::Continue);
1998 let perms = perms_with(&[Permission::ToolsIntercept]);
1999 bus.subscribe(HookKind::BeforeToolCall, handler, None, None, perms)
2000 .await
2001 .unwrap();
2002 assert!(!bus.is_empty().await);
2003 }
2004
2005 #[cfg(feature = "command-exec")]
2010 #[derive(Debug)]
2011 struct RecordingHandler {
2012 id: String,
2013 recorded_len: Arc<AtomicUsize>,
2014 result: HookResult,
2015 }
2016 #[cfg(feature = "command-exec")]
2017 impl RecordingHandler {
2018 fn new(id: &str, result: HookResult) -> (Arc<Self>, Arc<AtomicUsize>) {
2019 let recorded_len = Arc::new(AtomicUsize::new(0));
2020 let h = Arc::new(Self {
2021 id: id.to_string(),
2022 recorded_len: recorded_len.clone(),
2023 result,
2024 });
2025 (h, recorded_len)
2026 }
2027 }
2028 #[cfg(feature = "command-exec")]
2029 #[async_trait]
2030 impl crate::extensions::runtime::ExtensionHandler for RecordingHandler {
2031 fn id(&self) -> &str {
2032 &self.id
2033 }
2034 async fn handle(&self, event: &HookEvent) -> HookResult {
2035 if let Some(out) = event.tool_output.as_ref() {
2036 self.recorded_len.store(out.len(), Ordering::Relaxed);
2037 }
2038 self.result.clone()
2039 }
2040 async fn shutdown(&self) {}
2041 }
2042
2043 #[cfg(feature = "command-exec")]
2045 #[tokio::test]
2046 async fn after_tool_call_transform_receives_full_output_not_pre_truncated() {
2047 use crate::tools::Tool;
2048 let bus = std::sync::Arc::new(HookBus::new());
2049 let (handler, recorded_len) = RecordingHandler::new("len-recorder", HookResult::Continue);
2050 bus.subscribe(
2051 HookKind::AfterToolCall,
2052 handler,
2053 None,
2054 None,
2055 perms_with(&[Permission::ToolsIntercept]),
2056 )
2057 .await
2058 .unwrap();
2059
2060 let ctx = crate::tools::ToolContext {
2061 channels: crate::tools::ToolChannels {
2062 tx_delta: None,
2063 tx_events: None,
2064 },
2065 capabilities: crate::tools::ToolCapabilities {
2066 session_allow_all: None,
2067 launch_cancel: None,
2068 memory_backend: None,
2069 telemetry: None,
2070 tool_register_tx: None,
2071 session_manager: None,
2072 subagent_registry: None,
2073 event_queue: None,
2074 delegation_parent: None,
2075 codex_parent_plan: None,
2076 secret_prompt: None,
2077 orchestration: None,
2078 tool_activation: None,
2079 mcp_leases: None,
2080 extension_leases: None,
2081 cwd: None,
2082 env: None,
2083 env_stripped: Vec::new(),
2084 env_warned: Default::default(),
2085 session_id: None,
2086 hook_bus: None,
2087 continuation: None,
2088 },
2089 limits: crate::tools::ToolLimits {
2090 max_tool_output: 30_000,
2091 max_tool_buffer: 256 * 1024,
2092 bash_timeout: 30,
2093 bash_max_timeout: 300,
2094 subagent_timeout: 300,
2095 },
2096 };
2097
2098 let bash = crate::tools::BashTool;
2099 let output = bash
2100 .execute(
2101 serde_json::json!({"command": "yes hello | head -c 150000"}),
2102 ctx,
2103 )
2104 .await
2105 .expect("bash should succeed");
2106
2107 let _ = crate::runtime::emit_after_tool_call(
2108 &bus,
2109 "bash",
2110 None,
2111 serde_json::json!({"command": "yes hello | head -c 150000"}),
2112 output,
2113 30_000,
2114 None,
2115 None,
2116 )
2117 .await;
2118
2119 let len = recorded_len.load(Ordering::Relaxed);
2120 assert!(
2121 len > 100_000,
2122 "after_tool_call handler was starved: received {len} bytes — \
2123 expected the full ~150KB buffered output (max_tool_buffer=256KB)"
2124 );
2125 }
2126
2127 #[tokio::test]
2131 #[serial_test::serial(synaps_base_dir)]
2132 async fn final_output_truncated_to_max_tool_output_after_hook() {
2133 let _base = crate::test_env::BaseDirGuard::new();
2135 let bus = std::sync::Arc::new(HookBus::new());
2136 let big: String = "x".repeat(100_000);
2137 let handler = TestHandler::new("bloater", HookResult::Replace { output: big });
2138 bus.subscribe(
2139 HookKind::AfterToolCall,
2140 handler,
2141 None,
2142 None,
2143 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
2144 )
2145 .await
2146 .unwrap();
2147
2148 let result = crate::runtime::emit_after_tool_call(
2149 &bus,
2150 "bash",
2151 None,
2152 serde_json::json!({}),
2153 "tiny original".to_string(),
2154 30_000,
2155 None,
2156 None,
2157 )
2158 .await;
2159
2160 assert!(
2161 result.len() <= 30_000 + 200,
2162 "post-hook output should be capped at max_tool_output (got {} bytes)",
2163 result.len()
2164 );
2165 }
2166
2167 #[tokio::test]
2170 async fn no_transform_extension_preserves_legacy_truncation() {
2171 let bus = std::sync::Arc::new(HookBus::new());
2172 let huge: String = "y".repeat(80_000);
2173
2174 let result = crate::runtime::emit_after_tool_call(
2175 &bus,
2176 "bash",
2177 None,
2178 serde_json::json!({}),
2179 huge.clone(),
2180 30_000,
2181 None,
2182 None,
2183 )
2184 .await;
2185
2186 let legacy = crate::runtime::helpers::HelperMethods::truncate_tool_result(&huge, 30_000);
2187 assert_eq!(
2188 result, legacy,
2189 "no-extension path must be byte-identical to legacy truncation"
2190 );
2191 }
2192
2193 #[tokio::test]
2196 #[serial_test::serial(synaps_base_dir)]
2197 async fn emit_after_tool_call_truncates_char_boundary_safe() {
2198 let _base = crate::test_env::BaseDirGuard::new();
2200 let bus = std::sync::Arc::new(HookBus::new());
2202 let multibyte: String = "héllo🌟".repeat(5_000);
2203 assert!(multibyte.len() > 30_000);
2204
2205 let result = crate::runtime::emit_after_tool_call(
2206 &bus,
2207 "bash",
2208 None,
2209 serde_json::json!({}),
2210 multibyte,
2211 30_000,
2212 None,
2213 None,
2214 )
2215 .await;
2216 assert!(result.is_char_boundary(result.len()));
2217
2218 let bus2 = std::sync::Arc::new(HookBus::new());
2220 let big_multi = "✨".repeat(20_000);
2221 assert!(big_multi.len() > 30_000);
2222 let handler = TestHandler::new("mb-replacer", HookResult::Replace { output: big_multi });
2223 bus2.subscribe(
2224 HookKind::AfterToolCall,
2225 handler,
2226 None,
2227 None,
2228 perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
2229 )
2230 .await
2231 .unwrap();
2232
2233 let result2 = crate::runtime::emit_after_tool_call(
2234 &bus2,
2235 "bash",
2236 None,
2237 serde_json::json!({}),
2238 "orig".to_string(),
2239 30_000,
2240 None,
2241 None,
2242 )
2243 .await;
2244 assert!(result2.is_char_boundary(result2.len()));
2245 }
2246
2247 #[tokio::test]
2248 async fn keyed_injection_isolated_per_session() {
2249 let bus = HookBus::new();
2250 bus.set_session_injection_for("s1", "one".into()).await;
2251 bus.set_session_injection_for("s2", "two".into()).await;
2252 assert_eq!(bus.session_injection_for("s1").await.as_deref(), Some("one"));
2253 assert_eq!(bus.session_injection_for("s2").await.as_deref(), Some("two"));
2254 assert_eq!(bus.session_injection_for("s3").await, None);
2255 }
2256
2257 #[tokio::test]
2258 async fn clear_removes_only_that_session() {
2259 let bus = HookBus::new();
2260 bus.set_session_injection_for("s1", "one".into()).await;
2261 bus.set_session_injection_for("s2", "two".into()).await;
2262 bus.clear_session_injection("s1").await;
2263 assert_eq!(bus.session_injection_for("s1").await, None);
2264 assert_eq!(bus.session_injection_for("s2").await.as_deref(), Some("two"));
2265 }
2266
2267 #[tokio::test]
2268 #[allow(deprecated)]
2269 async fn deprecated_shims_use_empty_key() {
2270 let bus = HookBus::new();
2271 bus.set_session_injection("legacy".into()).await;
2272 assert_eq!(bus.session_injection().await.as_deref(), Some("legacy"));
2273 assert_eq!(bus.session_injection_for("").await.as_deref(), Some("legacy"));
2274 assert_eq!(bus.session_injection_for("s1").await, None);
2275 }
2276}
2277
2278#[cfg(test)]
2282mod worker_view_tests {
2283 use super::*;
2284 use crate::extensions::permissions::Permission;
2285 use async_trait::async_trait;
2286 use std::sync::atomic::{AtomicUsize, Ordering};
2287
2288 struct Probe {
2293 id: String,
2294 result: HookResult,
2295 tag_output: Option<String>,
2296 delay: Duration,
2297 handled: AtomicUsize,
2298 handled_no_spawn: AtomicUsize,
2299 }
2300
2301 impl Probe {
2302 fn build(id: &str, result: HookResult, tag: Option<&str>, delay: Duration) -> Arc<Self> {
2303 Arc::new(Self {
2304 id: id.into(),
2305 result,
2306 tag_output: tag.map(str::to_string),
2307 delay,
2308 handled: AtomicUsize::new(0),
2309 handled_no_spawn: AtomicUsize::new(0),
2310 })
2311 }
2312
2313 fn new(id: &str, result: HookResult) -> Arc<Self> {
2314 Self::build(id, result, None, Duration::ZERO)
2315 }
2316
2317 fn tagging(id: &str, tag: &str, delay: Duration) -> Arc<Self> {
2318 Self::build(id, HookResult::Continue, Some(tag), delay)
2319 }
2320
2321 fn calls(&self) -> (usize, usize) {
2322 (
2323 self.handled.load(Ordering::SeqCst),
2324 self.handled_no_spawn.load(Ordering::SeqCst),
2325 )
2326 }
2327
2328 async fn answer(&self, event: &HookEvent) -> HookResult {
2329 if !self.delay.is_zero() {
2330 tokio::time::sleep(self.delay).await;
2331 }
2332 match &self.tag_output {
2333 Some(tag) => HookResult::Replace {
2334 output: format!("{tag}:{}", event.tool_output.as_deref().unwrap_or("")),
2335 },
2336 None => self.result.clone(),
2337 }
2338 }
2339 }
2340
2341 #[async_trait]
2342 impl crate::extensions::runtime::ExtensionHandler for Probe {
2343 fn id(&self) -> &str {
2344 &self.id
2345 }
2346 async fn handle(&self, event: &HookEvent) -> HookResult {
2347 self.handled.fetch_add(1, Ordering::SeqCst);
2348 self.answer(event).await
2349 }
2350 async fn handle_no_spawn(&self, event: &HookEvent) -> HookResult {
2351 self.handled_no_spawn.fetch_add(1, Ordering::SeqCst);
2352 self.answer(event).await
2353 }
2354 async fn shutdown(&self) {}
2355 }
2356
2357 fn perms(perms: &[Permission]) -> PermissionSet {
2358 let mut set = PermissionSet::new();
2359 for p in perms {
2360 set.grant(*p);
2361 }
2362 set
2363 }
2364
2365 fn transform() -> PermissionSet {
2366 perms(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput])
2367 }
2368
2369 async fn after(bus: &Arc<HookBus>, output: &str) -> String {
2370 crate::runtime::emit_after_tool_call(
2371 bus,
2372 "bash",
2373 None,
2374 serde_json::json!({"command": "cat big.log"}),
2375 output.to_string(),
2376 30_000,
2377 None,
2378 None,
2379 )
2380 .await
2381 }
2382
2383 async fn host_with_every_kind() -> (Arc<HookBus>, Vec<Arc<Probe>>, Arc<Probe>) {
2387 let host = Arc::new(HookBus::new());
2388 let confirm = Probe::new(
2389 "confirmer",
2390 HookResult::Confirm {
2391 message: "really?".into(),
2392 sources: vec![],
2393 generations: vec![],
2394 tool: None,
2395 modified_input: None,
2396 modified_by: vec![],
2397 },
2398 );
2399 let block = Probe::new("blocker", HookResult::Block { reason: "no".into() });
2400 let inject = Probe::new("injector", HookResult::Inject { content: "ctx".into() });
2401 let observer = Probe::new("observer", HookResult::Replace { output: "SNEAKY".into() });
2402 let crush = Probe::new("crush", HookResult::Replace { output: "CRUSHED".into() });
2403 let intercept = perms(&[Permission::ToolsIntercept]);
2404 host.subscribe(HookKind::BeforeToolCall, confirm.clone(), None, None, intercept.clone())
2405 .await
2406 .unwrap();
2407 host.subscribe(HookKind::BeforeToolCall, block.clone(), None, None, transform())
2408 .await
2409 .unwrap();
2410 host.subscribe(
2411 HookKind::BeforeMessage,
2412 inject.clone(),
2413 None,
2414 None,
2415 perms(&[Permission::LlmContent, Permission::ToolsTransformOutput]),
2416 )
2417 .await
2418 .unwrap();
2419 host.subscribe(HookKind::AfterToolCall, observer.clone(), None, None, intercept)
2421 .await
2422 .unwrap();
2423 host.subscribe(HookKind::AfterToolCall, crush.clone(), None, None, transform())
2424 .await
2425 .unwrap();
2426 (host, vec![confirm, block, inject, observer], crush)
2427 }
2428
2429 #[tokio::test]
2430 #[serial_test::serial(synaps_base_dir)]
2431 async fn a_worker_runs_only_the_hosts_after_tool_call_transforms() {
2432 let _base = crate::test_env::BaseDirGuard::new();
2433 let (host, others, crush) = host_with_every_kind().await;
2434 let worker = HookBus::worker_transforms(&host);
2435 assert!(worker.is_worker_view() && !host.is_worker_view());
2436
2437 assert_eq!(after(&worker, "RAW 10k lines").await, "CRUSHED");
2439 assert_eq!(crush.calls(), (0, 1), "through handle_no_spawn");
2440 let before = crate::runtime::emit_before_tool_call(
2442 &worker,
2443 "bash",
2444 None,
2445 serde_json::json!({"command": "rm -rf /"}),
2446 None,
2447 None,
2448 )
2449 .await;
2450 assert_eq!(before, HookResult::Continue);
2451 let decision = crate::runtime::resolve_before_tool_call_decision(
2452 serde_json::json!({"command": "rm -rf /"}),
2453 before,
2454 None,
2455 false,
2456 None,
2457 )
2458 .await;
2459 assert!(matches!(decision, crate::runtime::BeforeToolCallDecision::Continue { .. }));
2460 for kind in [HookKind::BeforeMessage, HookKind::OnMessageComplete, HookKind::OnSessionStart] {
2461 let mut event = HookEvent::after_tool_call("bash", serde_json::json!({}), "x".into());
2462 event.kind = kind;
2463 assert_eq!(worker.emit(&event).await, HookResult::Continue, "{kind:?}");
2464 }
2465 for probe in &others {
2466 assert_eq!(probe.calls(), (0, 0), "{} never reached from a worker", probe.id);
2467 }
2468 assert_eq!(worker.handler_count().await, 1, "the one transform");
2469 assert!(!worker.is_empty().await);
2470
2471 assert_eq!(after(&host, "RAW").await, "CRUSHED");
2473 assert_eq!(others[3].calls(), (1, 0));
2474 assert_eq!(crush.calls(), (1, 1));
2475 }
2476
2477 #[tokio::test]
2481 async fn an_extension_without_transform_output_cannot_replace_a_workers_output() {
2482 let host = Arc::new(HookBus::new());
2483 let observer = Probe::new("observer", HookResult::Replace { output: "SNEAKY".into() });
2484 host.subscribe(
2485 HookKind::AfterToolCall,
2486 observer.clone(),
2487 None,
2488 None,
2489 perms(&[Permission::ToolsIntercept]),
2490 )
2491 .await
2492 .unwrap();
2493 let worker = HookBus::worker_transforms(&host);
2494 assert_eq!(after(&worker, "RAW").await, "RAW");
2495 assert_eq!(observer.calls(), (0, 0));
2496 assert!(worker.is_empty().await);
2497 assert_eq!(after(&host, "RAW").await, "RAW");
2499 assert_eq!(observer.calls(), (1, 0));
2500 }
2501
2502 #[tokio::test(start_paused = true)]
2506 #[serial_test::serial(synaps_base_dir)]
2507 async fn a_timed_out_or_crashed_transform_leaves_the_original_output() {
2508 let _base = crate::test_env::BaseDirGuard::new();
2509 let host = Arc::new(HookBus::new());
2510 let slow = Probe::tagging("slow", "SLOW", Duration::from_secs(60));
2511 host.subscribe(HookKind::AfterToolCall, slow.clone(), None, None, transform())
2512 .await
2513 .unwrap();
2514 let worker = HookBus::worker_transforms(&host);
2515 let started = tokio::time::Instant::now();
2516 assert_eq!(after(&worker, "RAW").await, "RAW", "timed out → original");
2517 let waited = started.elapsed();
2518 assert!(
2519 waited >= HANDLER_TIMEOUT && waited < HANDLER_TIMEOUT + Duration::from_secs(1),
2520 "bounded by the hook timeout: {waited:?}"
2521 );
2522
2523 let crashed = Probe::new("crashed", HookResult::Continue);
2524 let next = Probe::tagging("next", "NEXT", Duration::ZERO);
2525 let host = Arc::new(HookBus::new());
2526 host.subscribe(HookKind::AfterToolCall, crashed.clone(), None, None, transform())
2527 .await
2528 .unwrap();
2529 let worker = HookBus::worker_transforms(&host);
2530 assert_eq!(after(&worker, "RAW").await, "RAW", "crashed → original");
2531 host.subscribe(HookKind::AfterToolCall, next.clone(), None, None, transform())
2532 .await
2533 .unwrap();
2534 assert_eq!(after(&worker, "RAW").await, "NEXT:RAW", "the chain continues");
2535 }
2536
2537 #[tokio::test]
2541 #[serial_test::serial(synaps_base_dir)]
2542 async fn a_worker_sees_extension_loads_reloads_and_unloads_live() {
2543 let _base = crate::test_env::BaseDirGuard::new();
2544 let host = Arc::new(HookBus::new());
2545 let worker = HookBus::worker_transforms(&host);
2546 assert_eq!(after(&worker, "RAW").await, "RAW", "nothing loaded yet");
2547
2548 let v1 = Probe::tagging("crush", "V1", Duration::ZERO);
2549 host.subscribe(HookKind::AfterToolCall, v1.clone(), None, None, transform())
2550 .await
2551 .unwrap();
2552 assert_eq!(after(&worker, "RAW").await, "V1:RAW", "loaded after the worker started");
2553
2554 host.unsubscribe_all("crush").await;
2556 assert_eq!(after(&worker, "RAW").await, "RAW", "mid-reload: pass-through");
2557 let v2 = Probe::tagging("crush", "V2", Duration::ZERO);
2558 host.subscribe(HookKind::AfterToolCall, v2.clone(), None, None, transform())
2559 .await
2560 .unwrap();
2561 assert_eq!(after(&worker, "RAW").await, "V2:RAW");
2562 assert_eq!(v1.calls(), (0, 1), "the stopped instance is never called again");
2563
2564 host.unsubscribe_all("crush").await;
2565 assert_eq!(after(&worker, "RAW").await, "RAW", "unloaded");
2566 }
2567
2568 #[test]
2572 #[serial_test::serial(synaps_base_dir)]
2573 fn parallel_workers_each_get_their_own_replacement() {
2574 let _base = crate::test_env::BaseDirGuard::new();
2575 let crush = Probe::tagging("crush", "CRUSHED", Duration::from_millis(2));
2576 let host = Arc::new(HookBus::new());
2577 tokio::runtime::Builder::new_current_thread()
2578 .enable_all()
2579 .build()
2580 .unwrap()
2581 .block_on(host.subscribe(HookKind::AfterToolCall, crush.clone(), None, None, transform()))
2582 .unwrap();
2583 let threads: Vec<_> = (0..8)
2584 .map(|w| {
2585 let worker = HookBus::worker_transforms(&host);
2586 std::thread::spawn(move || {
2587 let rt = tokio::runtime::Builder::new_current_thread()
2588 .enable_all()
2589 .build()
2590 .unwrap();
2591 rt.block_on(async {
2592 for call in 0..10 {
2593 let output = format!("worker {w} call {call}");
2594 assert_eq!(after(&worker, &output).await, format!("CRUSHED:{output}"));
2595 }
2596 });
2597 })
2598 })
2599 .collect();
2600 for t in threads {
2601 t.join().expect("worker thread");
2602 }
2603 assert_eq!(crush.calls(), (0, 80));
2604 }
2605
2606 #[tokio::test]
2607 async fn a_view_is_read_only_and_a_view_of_a_view_reads_the_host() {
2608 let host = Arc::new(HookBus::new());
2609 let worker = HookBus::worker_transforms(&host);
2610 let probe = Probe::tagging("crush", "C", Duration::ZERO);
2611 let err = worker
2612 .subscribe(HookKind::AfterToolCall, probe.clone(), None, None, transform())
2613 .await
2614 .unwrap_err();
2615 assert!(err.contains("worker"), "{err}");
2616 let nested = HookBus::worker_transforms(&worker);
2617 assert!(nested.is_worker_view());
2618 assert!(Arc::ptr_eq(nested.transforms_of.as_ref().unwrap(), &host));
2619 assert_eq!(nested.handler_count().await, 0);
2620 }
2621}
2622
2623#[cfg(test)]
2625pub(crate) mod test_support {
2626 use super::*;
2627 use async_trait::async_trait;
2628 use std::sync::atomic::{AtomicUsize, Ordering};
2629
2630 pub(crate) struct Listener {
2635 pub(crate) id: String,
2636 delay: Duration,
2637 panics: usize,
2638 result: HookResult,
2639 seen: std::sync::Mutex<Vec<HookEvent>>,
2640 answered: AtomicUsize,
2641 }
2642
2643 impl Listener {
2644 fn build(id: &str, delay: Duration, panics: usize, result: HookResult) -> Arc<Self> {
2645 Arc::new(Self {
2646 id: id.into(),
2647 delay,
2648 panics,
2649 result,
2650 seen: Default::default(),
2651 answered: AtomicUsize::new(0),
2652 })
2653 }
2654 pub(crate) fn answering(id: &str, result: HookResult) -> Arc<Self> {
2655 Self::build(id, Duration::ZERO, 0, result)
2656 }
2657 pub(crate) fn prompt(id: &str) -> Arc<Self> {
2658 Self::answering(id, HookResult::Continue)
2659 }
2660 pub(crate) fn slow(id: &str, delay: Duration) -> Arc<Self> {
2661 Self::build(id, delay, 0, HookResult::Continue)
2662 }
2663 pub(crate) fn hanging(id: &str) -> Arc<Self> {
2664 Self::slow(id, Duration::MAX)
2665 }
2666 pub(crate) fn panicking(id: &str) -> Arc<Self> {
2667 Self::build(id, Duration::ZERO, usize::MAX, HookResult::Continue)
2668 }
2669 pub(crate) fn panicking_once(id: &str) -> Arc<Self> {
2671 Self::build(id, Duration::ZERO, 1, HookResult::Continue)
2672 }
2673
2674 pub(crate) async fn subscribe(self: &Arc<Self>, bus: &HookBus, kind: HookKind) {
2677 let mut permissions = PermissionSet::new();
2678 permissions.grant(kind.required_permission());
2679 bus.subscribe(kind, self.clone(), None, None, permissions)
2680 .await
2681 .unwrap();
2682 }
2683
2684 pub(crate) fn events(&self) -> Vec<HookEvent> {
2686 self.seen.lock().unwrap().clone()
2687 }
2688
2689 pub(crate) fn calls(&self) -> (usize, usize) {
2691 (self.seen.lock().unwrap().len(), self.answered.load(Ordering::SeqCst))
2692 }
2693
2694 pub(crate) async fn wait_for(&self, count: usize) -> Vec<HookEvent> {
2698 let waited = tokio::time::timeout(Duration::from_secs(30), async {
2699 while self.seen.lock().unwrap().len() < count {
2700 tokio::time::sleep(Duration::from_millis(5)).await;
2701 }
2702 })
2703 .await;
2704 let events = self.events();
2705 assert!(waited.is_ok(), "{}: expected {count} events, got {events:?}", self.id);
2706 events
2707 }
2708 }
2709
2710 #[async_trait]
2711 impl crate::extensions::runtime::ExtensionHandler for Listener {
2712 fn id(&self) -> &str {
2713 &self.id
2714 }
2715 async fn handle(&self, event: &HookEvent) -> HookResult {
2716 let call = {
2717 let mut seen = self.seen.lock().unwrap();
2718 seen.push(event.clone());
2719 seen.len()
2720 };
2721 if self.delay == Duration::MAX {
2722 std::future::pending::<()>().await;
2723 }
2724 if !self.delay.is_zero() {
2725 tokio::time::sleep(self.delay).await;
2726 }
2727 if call <= self.panics {
2728 panic!("notify test: this handler panics on purpose");
2729 }
2730 self.answered.fetch_add(1, Ordering::SeqCst);
2731 self.result.clone()
2732 }
2733 async fn shutdown(&self) {}
2734 }
2735}
2736
2737#[cfg(test)]
2742mod notify_tests {
2743 use super::test_support::Listener;
2744 use super::*;
2745 use crate::extensions::permissions::Permission;
2746 use std::sync::atomic::{AtomicBool, Ordering};
2747
2748 const KIND: HookKind = HookKind::OnContextArchived;
2749
2750 fn event() -> HookEvent {
2751 HookEvent::on_context_archived("chat", &"a".repeat(32), None, "p0", 1, 1, 2)
2752 }
2753
2754 async fn bus_with(listeners: &[&Arc<Listener>]) -> Arc<HookBus> {
2755 let bus = Arc::new(HookBus::new());
2756 for listener in listeners {
2757 listener.subscribe(&bus, KIND).await;
2758 }
2759 bus
2760 }
2761
2762 async fn settle() {
2764 tokio::time::sleep(Duration::from_millis(1)).await;
2765 }
2766
2767 #[tokio::test(start_paused = true)]
2768 async fn notify_returns_before_a_hanging_handler() {
2769 let slow = Listener::slow("slow", Duration::from_secs(60));
2770 let bus = bus_with(&[&slow]).await;
2771 let (virtual_start, real_start) = (tokio::time::Instant::now(), Instant::now());
2772 bus.notify(event());
2773 assert_eq!(virtual_start.elapsed(), Duration::ZERO);
2776 assert!(real_start.elapsed() < Duration::from_secs(1));
2777 assert_eq!(slow.calls(), (0, 0), "the handler runs on the detached task");
2778 assert_eq!(bus.notifies_in_flight(), 1);
2779
2780 tokio::time::sleep(HANDLER_TIMEOUT - Duration::from_millis(1)).await;
2782 assert_eq!(slow.calls(), (1, 0));
2783 assert_eq!(bus.notifies_in_flight(), 1);
2784 tokio::time::sleep(Duration::from_millis(2)).await;
2786 assert_eq!(bus.notifies_in_flight(), 0);
2787 assert_eq!(slow.calls(), (1, 0));
2788 }
2789
2790 #[tokio::test(start_paused = true)]
2793 async fn notify_survives_a_panicking_handler() {
2794 let (bad, good) = (Listener::panicking("bad"), Listener::prompt("good"));
2795 let bus = bus_with(&[&bad, &good]).await;
2796 bus.notify(event());
2797 settle().await;
2798 assert_eq!(bad.calls(), (1, 0));
2799 assert_eq!(good.calls(), (1, 1), "the healthy listener got the event");
2800 assert_eq!(bus.notifies_in_flight(), 0, "the panic released its slot");
2801 bus.unsubscribe_all("bad").await;
2803 bus.notify(event());
2804 settle().await;
2805 assert!(good.calls().1 >= 1, "{:?}", good.calls());
2806 assert_eq!(bus.emit(&HookEvent::before_message("hi")).await, HookResult::Continue);
2807 assert_eq!(bus.notifies_in_flight(), 0);
2808 }
2809
2810 #[tokio::test(start_paused = true)]
2811 async fn notify_ignores_block_and_inject_results() {
2812 let listeners = [
2813 Listener::answering("block", HookResult::Block { reason: "no".into() }),
2814 Listener::answering("inject", HookResult::Inject { content: "ctx".into() }),
2815 Listener::answering("replace", HookResult::Replace { output: "x".into() }),
2816 Listener::answering("confirm", HookResult::confirm("sure?")),
2817 Listener::answering("phase", HookResult::ContextPhase { phase: "new_task".into() }),
2818 Listener::prompt("plain"),
2819 ];
2820 let bus = bus_with(&listeners.iter().collect::<Vec<_>>()).await;
2821 bus.notify(event());
2822 settle().await;
2823 for listener in &listeners {
2825 assert_eq!(listener.calls(), (1, 1), "{}", listener.id);
2826 }
2827 assert_eq!(bus.session_injection_for("chat").await, None);
2828 assert_eq!(bus.notifies_in_flight(), 0);
2829 }
2830
2831 #[tokio::test(start_paused = true)]
2832 async fn notify_with_no_subscribers_is_a_no_op() {
2833 let other = Listener::prompt("other");
2835 let bus = Arc::new(HookBus::new());
2836 other.subscribe(&bus, HookKind::BeforeMessage).await;
2837 bus.notify(event());
2838 assert_eq!(bus.notifies_in_flight(), 0, "no task was spawned");
2839 let built = Arc::new(AtomicBool::new(false));
2841 let flag = built.clone();
2842 bus.notify_with(KIND, async move {
2843 flag.store(true, Ordering::SeqCst);
2844 Some(event())
2845 });
2846 assert_eq!(bus.notifies_in_flight(), 0);
2847 settle().await;
2848 assert!(!built.load(Ordering::SeqCst));
2849 assert_eq!(other.calls(), (0, 0));
2850 }
2851
2852 #[tokio::test(start_paused = true)]
2853 async fn notify_on_worker_view_sends_nothing() {
2854 let listener = Listener::prompt("listener");
2855 let host = bus_with(&[&listener]).await;
2856 let worker = HookBus::worker_transforms(&host);
2857 worker.notify(event());
2858 settle().await;
2859 assert_eq!(listener.calls(), (0, 0));
2860 assert_eq!((worker.notifies_in_flight(), host.notifies_in_flight()), (0, 0));
2861 host.notify(event());
2863 settle().await;
2864 assert_eq!(listener.calls(), (1, 1));
2865 }
2866
2867 #[tokio::test(start_paused = true)]
2868 async fn notify_reaches_every_subscriber_concurrently() {
2869 let slow = Listener::slow("slow", Duration::from_secs(3));
2871 let fast = Listener::prompt("fast");
2872 let bus = bus_with(&[&slow, &fast]).await;
2873 bus.notify(event());
2874 settle().await;
2875 assert_eq!(fast.calls(), (1, 1), "answered without waiting for the slow one");
2876 assert_eq!(slow.calls(), (1, 0));
2877 tokio::time::sleep(Duration::from_secs(3)).await;
2878 assert_eq!(slow.calls(), (1, 1));
2879 assert_eq!(bus.notifies_in_flight(), 0);
2880 }
2881
2882 #[tokio::test(start_paused = true)]
2886 async fn notify_drops_events_beyond_the_in_flight_limit() {
2887 let stuck = Listener::hanging("stuck");
2888 let bus = bus_with(&[&stuck]).await;
2889 for _ in 0..NOTIFY_MAX_IN_FLIGHT + 10 {
2890 bus.notify(event());
2891 }
2892 assert_eq!(bus.notifies_in_flight(), NOTIFY_MAX_IN_FLIGHT);
2893 settle().await;
2894 assert_eq!(stuck.calls(), (NOTIFY_MAX_IN_FLIGHT, 0), "the extra ten were dropped");
2895 tokio::time::sleep(HANDLER_TIMEOUT).await;
2896 assert_eq!(bus.notifies_in_flight(), 0, "every delivery ended at the timeout");
2897 bus.notify(event());
2898 settle().await;
2899 assert_eq!(stuck.calls(), (NOTIFY_MAX_IN_FLIGHT + 1, 0));
2900 }
2901
2902 #[tokio::test(start_paused = true)]
2905 async fn notify_with_builds_the_event_off_the_callers_path() {
2906 let listener = Listener::prompt("listener");
2907 let bus = bus_with(&[&listener]).await;
2908 let started = tokio::time::Instant::now();
2909 bus.notify_with(KIND, async {
2910 tokio::time::sleep(Duration::from_secs(10)).await;
2911 Some(event())
2912 });
2913 assert_eq!(started.elapsed(), Duration::ZERO);
2914 settle().await;
2915 assert_eq!(listener.calls(), (0, 0), "still being built");
2916 tokio::time::sleep(Duration::from_secs(10)).await;
2917 assert_eq!(listener.calls(), (1, 1));
2918
2919 bus.notify_with(KIND, async { None });
2920 bus.notify_with(KIND, async { Some(HookEvent::on_session_start("chat")) });
2922 settle().await;
2923 assert_eq!(listener.calls(), (1, 1));
2924 assert_eq!(bus.notifies_in_flight(), 0);
2925 }
2926
2927 #[tokio::test(start_paused = true)]
2930 async fn notify_with_sends_several_events_in_order_as_one_delivery() {
2931 let numbered = |n: usize| part(n, NOTIFY_MAX_IN_FLIGHT + 6);
2932 let listener = Listener::prompt("listener");
2933 let bus = bus_with(&[&listener]).await;
2934 let count = NOTIFY_MAX_IN_FLIGHT + 6;
2935 bus.notify_with(KIND, async move {
2936 let mut events: Vec<_> = (1..=count).map(numbered).collect();
2937 events.insert(3, HookEvent::on_session_start("chat"));
2939 events
2940 });
2941 assert_eq!(bus.notifies_in_flight(), 1);
2942 settle().await;
2943 assert_eq!(parts_seen(&listener), (1..=count as u64).collect::<Vec<_>>());
2944 assert_eq!(bus.notifies_in_flight(), 0);
2945
2946 let hung = Listener::hanging("hung");
2949 let bus = bus_with(&[&hung]).await;
2950 bus.notify_with(KIND, async move { vec![numbered(1), numbered(2)] });
2951 settle().await;
2952 assert_eq!(parts_seen(&hung), [1]);
2953 assert_eq!(bus.notifies_in_flight(), 1);
2954 tokio::time::sleep(HANDLER_TIMEOUT).await;
2955 settle().await;
2956 assert_eq!(bus.notifies_in_flight(), 0);
2957 assert_eq!(hung.calls(), (1, 0));
2958 }
2959
2960 fn part(n: usize, of: usize) -> HookEvent {
2961 event().with_part(n, of, 0, 0)
2962 }
2963
2964 fn parts_seen(listener: &Listener) -> Vec<u64> {
2965 let events = listener.events();
2966 events.iter().map(|event| event.data["part"].as_u64().unwrap()).collect()
2967 }
2968
2969 #[tokio::test(start_paused = true)]
2981 async fn a_stuck_handler_does_not_delay_another_handlers_parts() {
2982 const PARTS: usize = 4;
2983 let (stuck, healthy) = (Listener::hanging("stuck"), Listener::prompt("healthy"));
2984 let bus = bus_with(&[&stuck, &healthy]).await;
2985 bus.notify_with(KIND, async { (1..=PARTS).map(|n| part(n, PARTS)).collect::<Vec<_>>() });
2986 settle().await;
2987 assert_eq!(parts_seen(&healthy), [1, 2, 3, 4], "none waited for the stuck handler");
2988 assert_eq!(healthy.calls(), (PARTS, PARTS));
2989 assert_eq!(parts_seen(&stuck), [1]);
2990 assert_eq!(bus.notifies_in_flight(), 1);
2991
2992 tokio::time::sleep(HANDLER_TIMEOUT).await;
2995 settle().await;
2996 assert_eq!(bus.notifies_in_flight(), 0, "one timeout, not one per part");
2997 assert_eq!(parts_seen(&stuck), [1]);
2998 tokio::time::sleep(HANDLER_TIMEOUT * PARTS as u32).await;
2999 assert_eq!((stuck.calls(), healthy.calls()), ((1, 0), (PARTS, PARTS)));
3000
3001 let slow = Listener::slow("slow", HANDLER_TIMEOUT - Duration::from_millis(1));
3003 let bus = bus_with(&[&slow]).await;
3004 bus.notify_with(KIND, async { (1..=PARTS).map(|n| part(n, PARTS)).collect::<Vec<_>>() });
3005 tokio::time::sleep(HANDLER_TIMEOUT * PARTS as u32).await;
3006 assert_eq!(slow.calls(), (PARTS, PARTS));
3007 assert_eq!(bus.notifies_in_flight(), 0);
3008 }
3009
3010 #[tokio::test(start_paused = true)]
3015 async fn a_panic_on_the_first_part_loses_only_that_part_for_that_handler() {
3016 let (bad, good) = (Listener::panicking_once("bad"), Listener::prompt("good"));
3017 let bus = bus_with(&[&bad, &good]).await;
3018 bus.notify_with(KIND, async { (1..=3).map(|n| part(n, 3)).collect::<Vec<_>>() });
3019 settle().await;
3020 assert_eq!(parts_seen(&good), [1, 2, 3]);
3021 assert_eq!(good.calls(), (3, 3));
3022 assert_eq!(parts_seen(&bad), [1, 2, 3], "it is sent the parts after the panic");
3023 assert_eq!(bad.calls(), (3, 2), "and answers them");
3024 assert_eq!(bus.notifies_in_flight(), 0);
3025
3026 let (bad, good) = (Listener::panicking("bad"), Listener::prompt("good"));
3028 let bus = bus_with(&[&bad, &good]).await;
3029 bus.notify_with(KIND, async { (1..=3).map(|n| part(n, 3)).collect::<Vec<_>>() });
3030 settle().await;
3031 assert_eq!((parts_seen(&good), good.calls()), (vec![1, 2, 3], (3, 3)));
3032 assert_eq!(bad.calls(), (3, 0));
3033 assert_eq!(bus.notifies_in_flight(), 0);
3034 }
3035
3036 #[tokio::test(start_paused = true)]
3038 async fn notify_refuses_a_hook_that_is_not_notify_only() {
3039 let listener = Listener::answering("guard", HookResult::Block { reason: "no".into() });
3040 let bus = Arc::new(HookBus::new());
3041 listener.subscribe(&bus, HookKind::BeforeToolCall).await;
3042 bus.notify(HookEvent::before_tool_call("bash", serde_json::json!({})));
3043 settle().await;
3044 assert_eq!(listener.calls(), (0, 0));
3045 assert_eq!(bus.notifies_in_flight(), 0);
3046 }
3047
3048 #[test]
3051 fn notify_outside_a_runtime_sends_nothing() {
3052 let listener = Listener::prompt("listener");
3053 let bus = tokio::runtime::Builder::new_current_thread()
3054 .build()
3055 .unwrap()
3056 .block_on(bus_with(&[&listener]));
3057 bus.notify(event());
3058 assert_eq!(bus.notifies_in_flight(), 0);
3059 assert_eq!(listener.calls(), (0, 0));
3060 }
3061
3062 #[tokio::test]
3064 async fn on_context_archived_needs_llm_content_to_subscribe() {
3065 let bus = HookBus::new();
3066 let mut perms = PermissionSet::new();
3067 perms.grant(Permission::SessionLifecycle);
3068 let err = bus
3069 .subscribe(KIND, Listener::prompt("nosy"), None, None, perms.clone())
3070 .await
3071 .unwrap_err();
3072 assert!(err.contains("privacy.llm_content"), "{err}");
3073 perms.grant(Permission::LlmContent);
3074 bus.subscribe(KIND, Listener::prompt("ok"), None, None, perms)
3075 .await
3076 .unwrap();
3077 }
3078
3079 #[tokio::test(start_paused = true)]
3081 async fn on_memory_forgotten_needs_session_lifecycle_and_is_delivered_by_notify() {
3082 let forgotten = HookKind::OnMemoryForgotten;
3083 let bus = Arc::new(HookBus::new());
3084 let mut perms = PermissionSet::new();
3085 perms.grant(Permission::LlmContent);
3086 let err = bus
3087 .subscribe(forgotten, Listener::prompt("nosy"), None, None, perms)
3088 .await
3089 .unwrap_err();
3090 assert!(err.contains("session.lifecycle"), "{err}");
3091
3092 let listener = Listener::prompt("indexer");
3093 listener.subscribe(&bus, forgotten).await;
3094 bus.notify(HookEvent::on_memory_forgotten(Some("chat"), "mem-1", "note", "p0", "repository"));
3095 settle().await;
3096 assert_eq!(listener.calls(), (1, 1));
3097 assert_eq!(listener.events()[0].data["id"], "mem-1");
3098 }
3099}