Skip to main content

agent_engine/extensions/hooks/
mod.rs

1//! HookBus — the central dispatcher for extension hooks.
2//!
3//! The HookBus holds registered handlers and dispatches typed events to them.
4//! Without any handlers, `emit()` is a no-op fast path (<1µs).
5//!
6//! Tool-specific hooks filter by tool name before dispatching.
7
8pub 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
21/// Default timeout for a single hook handler call.
22const 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/// A registered hook handler with its metadata.
45#[derive(Clone)]
46pub struct HandlerRegistration {
47    /// The extension handler.
48    pub handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
49    /// Optional tool name filter (None = all tools).
50    pub tool_filter: Option<String>,
51    /// Optional matcher for event payloads.
52    pub matcher: Option<HookMatcher>,
53    /// Permissions granted to this handler's extension.
54    pub permissions: PermissionSet,
55}
56
57/// The central hook dispatcher.
58///
59/// Thread-safe: uses `RwLock` so multiple concurrent emitters can read
60/// the handler list, and registration takes a write lock only briefly.
61pub struct HookBus {
62    handlers: RwLock<HashMap<HookKind, Vec<HandlerRegistration>>>,
63}
64
65impl HookBus {
66    /// Create an empty HookBus with no handlers.
67    pub fn new() -> Self {
68        Self {
69            handlers: RwLock::new(HashMap::new()),
70        }
71    }
72
73    /// Register a handler for a specific hook kind.
74    ///
75    /// Returns an error if the handler's permissions don't allow
76    /// subscribing to this hook kind.
77    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        // Permission check
86        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    /// Emit a hook event to all registered handlers.
108    ///
109    /// Returns the first `Block` result if any handler blocks, otherwise
110    /// returns `Continue`. Handlers are called in registration order.
111    ///
112    /// If no handlers are registered for this hook, returns immediately
113    /// (the no-extensions fast path).
114    pub async fn emit(&self, event: &HookEvent) -> HookResult {
115        // Snapshot the handler list and drop the lock immediately.
116        // This prevents holding the RwLock across async handler calls
117        // (which could block subscribe/unsubscribe for the entire
118        // duration of IPC round-trips to extension processes).
119        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, // fast path: no handlers
124            }
125        }; // lock dropped here
126
127        // Collect injections from all handlers rather than returning on first
128        let mut injections: Vec<String> = Vec::new();
129
130        for reg in &registrations {
131            // Tool-specific filter: skip handlers that don't match.
132            // Check both API name and runtime name so MCP tools with
133            // sanitized names (slashes→underscores) still match.
134            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            // Call handler with timeout
153            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                    // Accumulate — don't early-return. Multiple extensions can inject.
224                    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                    // Two-key gate: subscribing to after_tool_call needs
236                    // `tools.intercept` (observe); rewriting the output
237                    // additionally needs `tools.transform_output`. Without it,
238                    // ignore this handler's Replace (fail-safe: original output
239                    // preserved) and continue the chain.
240                    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                    // Persistent audit trail — who rewrote which tool's output
257                    // and when, plus sizes (never content). Best-effort: a
258                    // failed audit write must not break the tool flow.
259                    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                    // First transform wins (mirrors Modify). Chaining is a future enhancement.
267                    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                    // Fail-open: timeout = continue
285                }
286            }
287        }
288
289        // Merge accumulated injections from all handlers
290        if !injections.is_empty() {
291            HookResult::Inject {
292                content: injections.join("\n\n"),
293            }
294        } else {
295            HookResult::Continue
296        }
297    }
298
299    /// Emit a hook event to all registered handlers **concurrently**.
300    ///
301    /// All handlers race under a single shared timeout (`per_handler_timeout`).
302    /// Results are collected and the first `Block` wins; injections are merged.
303    ///
304    /// **When to use this over `emit()`:**
305    ///
306    /// Only safe for hook kinds whose handlers are order-independent — i.e.
307    /// where no handler's result depends on another's execution.  Currently
308    /// that applies to:
309    ///   - `on_session_end`: only `Continue` is a valid result; handlers are
310    ///     fire-and-forget notification calls (deck, d20, jawz-widget,
311    ///     synaps-tasks all write to their own stores independently).
312    ///
313    /// **Do NOT use for** `before_tool_call` / `after_tool_call` / `before_message`
314    /// hooks where `Block` / `Modify` / `Replace` / `Inject` semantics require a
315    /// defined winner when two handlers disagree — `join_all` completion order is
316    /// nondeterministic, so the "winner" would be unstable. Those hooks must use
317    /// the sequential [`emit`](Self::emit), which enforces first-wins ordering.
318    ///
319    /// With N extensions and a 5 s per-handler timeout, serial emit takes up
320    /// to N×5 s; concurrent emit collapses that to a single 5 s window
321    /// regardless of N — critical for teardown budgets.
322    pub async fn emit_concurrent(&self, event: &HookEvent) -> HookResult {
323        // H4: real assert, not debug_assert — this invariant protects against
324        // nondeterministic Block/Modify/Replace results in release builds.
325        assert!(
326            !matches!(
327                event.kind,
328                HookKind::BeforeToolCall | HookKind::AfterToolCall | HookKind::BeforeMessage
329            ),
330            "emit_concurrent must not be used for transform-capable hooks \
331             ({:?}); their Block/Modify/Replace/Inject results need the \
332             deterministic first-wins ordering of the sequential emit()",
333            event.kind,
334        );
335        // Snapshot handler list (same as emit()).
336        let registrations = {
337            let handlers = self.handlers.read().await;
338            match handlers.get(&event.kind) {
339                Some(regs) if !regs.is_empty() => regs.clone(),
340                _ => return HookResult::Continue, // fast path: no handlers
341            }
342        };
343
344        // Dispatch all handlers simultaneously.
345        let futures: Vec<_> =
346            registrations
347                .iter()
348                .filter(|reg| {
349                    // Apply tool filter before spawning.
350                    if let Some(ref filter) = reg.tool_filter {
351                        match (&event.tool_name, &event.tool_runtime_name) {
352                            (Some(api), Some(runtime)) => filter == api || filter == runtime,
353                            (Some(api), None) => filter == api,
354                            (None, Some(runtime)) => filter == runtime,
355                            (None, None) => false,
356                        }
357                    } else {
358                        true
359                    }
360                })
361                .filter(|reg| reg.matcher.as_ref().map_or(true, |m| m.matches(event)))
362                .map(|reg| {
363                    let handler = reg.handler.clone();
364                    let event_clone = event.clone();
365                    async move {
366                        tokio::time::timeout(HANDLER_TIMEOUT, handler.handle(&event_clone)).await
367                    }
368                })
369                .collect();
370
371        let results = join_all(futures).await;
372
373        let mut injections: Vec<String> = Vec::new();
374        for result in results {
375            match result {
376                Ok(HookResult::Continue) => {}
377                Ok(HookResult::Block { reason }) => {
378                    return HookResult::Block { reason };
379                }
380                Ok(HookResult::Inject { content }) => {
381                    injections.push(content);
382                }
383                Ok(HookResult::Modify { input }) => {
384                    return HookResult::Modify { input };
385                }
386                Ok(HookResult::Replace { output }) => {
387                    return HookResult::Replace { output };
388                }
389                Ok(HookResult::Confirm { message }) => {
390                    return HookResult::Confirm { message };
391                }
392                Err(_timeout) => {
393                    tracing::warn!(
394                        hook = %event.kind.as_str(),
395                        timeout_secs = HANDLER_TIMEOUT.as_secs(),
396                        "Hook handler timed out in concurrent emit — skipping"
397                    );
398                }
399            }
400        }
401
402        if !injections.is_empty() {
403            HookResult::Inject {
404                content: injections.join("\n\n"),
405            }
406        } else {
407            HookResult::Continue
408        }
409    }
410
411    /// Remove all handlers for a given extension ID.
412    pub async fn unsubscribe_all(&self, extension_id: &str) {
413        let mut handlers = self.handlers.write().await;
414        for regs in handlers.values_mut() {
415            regs.retain(|r| r.handler.id() != extension_id);
416        }
417    }
418
419    /// Number of registered handlers across all hooks.
420    pub async fn handler_count(&self) -> usize {
421        let handlers = self.handlers.read().await;
422        handlers.values().map(|v| v.len()).sum()
423    }
424
425    /// Check if any handlers are registered (for fast-path decisions).
426    pub async fn is_empty(&self) -> bool {
427        let handlers = self.handlers.read().await;
428        handlers.values().all(|v| v.is_empty())
429    }
430
431    /// Return all (kind, tool_filter) pairs subscribed by the given extension id.
432    /// Sorted by kind name, then by tool_filter (None first), for stable output.
433    pub async fn subscriptions_for(&self, extension_id: &str) -> Vec<(HookKind, Option<String>)> {
434        let handlers = self.handlers.read().await;
435        let mut out: Vec<(HookKind, Option<String>)> = Vec::new();
436        for (kind, regs) in handlers.iter() {
437            for reg in regs {
438                if reg.handler.id() == extension_id {
439                    out.push((*kind, reg.tool_filter.clone()));
440                }
441            }
442        }
443        out.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()).then_with(|| a.1.cmp(&b.1)));
444        out
445    }
446}
447
448impl Default for HookBus {
449    fn default() -> Self {
450        Self::new()
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use crate::extensions::hooks::events::HookEvent;
458    use crate::extensions::permissions::Permission;
459    use async_trait::async_trait;
460    use std::sync::atomic::{AtomicUsize, Ordering};
461
462    /// Test handler that counts calls and returns a configurable result.
463    struct TestHandler {
464        id: String,
465        call_count: AtomicUsize,
466        result: HookResult,
467    }
468
469    impl TestHandler {
470        fn new(id: &str, result: HookResult) -> Arc<Self> {
471            Arc::new(Self {
472                id: id.to_string(),
473                call_count: AtomicUsize::new(0),
474                result,
475            })
476        }
477
478        fn calls(&self) -> usize {
479            self.call_count.load(Ordering::Relaxed)
480        }
481    }
482
483    #[async_trait]
484    impl crate::extensions::runtime::ExtensionHandler for TestHandler {
485        fn id(&self) -> &str {
486            &self.id
487        }
488
489        async fn handle(&self, _event: &HookEvent) -> HookResult {
490            self.call_count.fetch_add(1, Ordering::Relaxed);
491            self.result.clone()
492        }
493
494        async fn shutdown(&self) {}
495    }
496
497    fn perms_with(perms: &[Permission]) -> PermissionSet {
498        let mut set = PermissionSet::new();
499        for p in perms {
500            set.grant(*p);
501        }
502        set
503    }
504
505    /// emit_after_tool_call returns the substituted output when an
506    /// after_tool_call handler returns Replace — this is the seam that lets an
507    /// extension compress/redact tool output before it enters history.
508    #[tokio::test]
509    async fn after_tool_call_replace_substitutes_recorded_output() {
510        let bus = std::sync::Arc::new(HookBus::new());
511        let handler = TestHandler::new(
512            "compressor",
513            HookResult::Replace {
514                output: "COMPRESSED".into(),
515            },
516        );
517        bus.subscribe(
518            HookKind::AfterToolCall,
519            handler,
520            None,
521            None,
522            perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
523        )
524        .await
525        .unwrap();
526
527        let recorded = crate::runtime::emit_after_tool_call(
528            &bus,
529            "bash",
530            None,
531            serde_json::json!({"command": "cat huge.log"}),
532            "RAW 10k lines".to_string(),
533            30_000,
534        )
535        .await;
536
537        assert_eq!(recorded, "COMPRESSED", "Replace output must reach history");
538    }
539
540    /// With no transform handler, the original output is preserved unchanged.
541    #[tokio::test]
542    async fn after_tool_call_without_replace_keeps_original_output() {
543        let bus = std::sync::Arc::new(HookBus::new());
544        let recorded = crate::runtime::emit_after_tool_call(
545            &bus,
546            "bash",
547            None,
548            serde_json::json!({}),
549            "RAW".to_string(),
550            30_000,
551        )
552        .await;
553
554        assert_eq!(recorded, "RAW", "no Replace → original output preserved");
555    }
556
557    /// First Replace wins: once an earlier after_tool_call handler returns
558    /// Replace, later handlers are never reached (mirrors block/modify
559    /// chain-stop). Proves the "first transform wins" comment in emit().
560    #[tokio::test]
561    async fn replace_stops_chain_for_after_tool_call() {
562        let bus = HookBus::new();
563        let first = TestHandler::new(
564            "first",
565            HookResult::Replace {
566                output: "FIRST".into(),
567            },
568        );
569        let second = TestHandler::new(
570            "second",
571            HookResult::Replace {
572                output: "SECOND".into(),
573            },
574        );
575        let perms = perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]);
576
577        bus.subscribe(
578            HookKind::AfterToolCall,
579            first.clone(),
580            None,
581            None,
582            perms.clone(),
583        )
584        .await
585        .unwrap();
586        bus.subscribe(HookKind::AfterToolCall, second.clone(), None, None, perms)
587            .await
588            .unwrap();
589
590        let event = HookEvent::after_tool_call("bash", serde_json::json!({}), "RAW".to_string());
591        let result = bus.emit(&event).await;
592
593        assert_eq!(
594            result,
595            HookResult::Replace {
596                output: "FIRST".into()
597            }
598        );
599        assert_eq!(first.calls(), 1);
600        assert_eq!(second.calls(), 0); // never reached — first transform wins
601    }
602
603    /// Two-key gate: an extension may subscribe to after_tool_call with only
604    /// `tools.intercept` (observe), but its Replace is IGNORED without the
605    /// additional `tools.transform_output` — the original output is preserved.
606    #[tokio::test]
607    async fn replace_without_transform_permission_is_ignored() {
608        let bus = std::sync::Arc::new(HookBus::new());
609        let handler = TestHandler::new(
610            "observer-only",
611            HookResult::Replace {
612                output: "SNEAKY".into(),
613            },
614        );
615        // Only the observe key — NOT tools.transform_output.
616        bus.subscribe(
617            HookKind::AfterToolCall,
618            handler.clone(),
619            None,
620            None,
621            perms_with(&[Permission::ToolsIntercept]),
622        )
623        .await
624        .unwrap();
625
626        let recorded = crate::runtime::emit_after_tool_call(
627            &bus,
628            "bash",
629            None,
630            serde_json::json!({}),
631            "ORIGINAL".to_string(),
632            30_000,
633        )
634        .await;
635
636        assert_eq!(handler.calls(), 1, "handler still runs (it's subscribed)");
637        assert_eq!(
638            recorded, "ORIGINAL",
639            "Replace without tools.transform_output must be ignored — original preserved"
640        );
641    }
642
643    #[test]
644    fn trace_env_value_parser_accepts_common_truthy_values() {
645        for value in ["1", "true", "TRUE", "yes", "on"] {
646            std::env::set_var("SYNAPS_EXTENSIONS_TRACE", value);
647            assert!(
648                extensions_trace_enabled(),
649                "{value} should enable trace mode"
650            );
651        }
652
653        for value in ["", "0", "false", "off", "no"] {
654            std::env::set_var("SYNAPS_EXTENSIONS_TRACE", value);
655            assert!(
656                !extensions_trace_enabled(),
657                "{value:?} should not enable trace mode"
658            );
659        }
660        std::env::remove_var("SYNAPS_EXTENSIONS_TRACE");
661    }
662
663    #[tokio::test]
664    async fn matcher_skips_handler_when_input_does_not_contain_value() {
665        let bus = HookBus::new();
666        let handler = TestHandler::new(
667            "matcher",
668            HookResult::Block {
669                reason: "matched".into(),
670            },
671        );
672        let mut perms = PermissionSet::new();
673        perms.grant(Permission::ToolsIntercept);
674        bus.subscribe(
675            HookKind::BeforeToolCall,
676            handler.clone(),
677            None,
678            Some(HookMatcher {
679                input_contains: Some("danger".to_string()),
680                input_equals: None,
681            }),
682            perms,
683        )
684        .await
685        .unwrap();
686
687        let safe = HookEvent::before_tool_call("bash", serde_json::json!({"command": "echo safe"}));
688        assert!(matches!(bus.emit(&safe).await, HookResult::Continue));
689
690        let danger =
691            HookEvent::before_tool_call("bash", serde_json::json!({"command": "echo danger"}));
692        assert!(matches!(bus.emit(&danger).await, HookResult::Block { .. }));
693    }
694
695    #[test]
696    fn hook_result_action_names_are_stable_for_trace_logs() {
697        assert_eq!(hook_result_action(&HookResult::Continue), "continue");
698        assert_eq!(
699            hook_result_action(&HookResult::Block {
700                reason: "stop".into(),
701            }),
702            "block"
703        );
704        assert_eq!(
705            hook_result_action(&HookResult::Inject {
706                content: "context".into(),
707            }),
708            "inject"
709        );
710        assert_eq!(
711            hook_result_action(&HookResult::Confirm {
712                message: "Proceed?".into(),
713            }),
714            "confirm"
715        );
716        assert_eq!(
717            hook_result_action(&HookResult::Modify {
718                input: serde_json::json!({"command": "echo safe"}),
719            }),
720            "modify"
721        );
722    }
723
724    #[tokio::test]
725    async fn empty_bus_returns_continue() {
726        let bus = HookBus::new();
727        let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
728        let result = bus.emit(&event).await;
729        assert!(matches!(result, HookResult::Continue));
730    }
731
732    #[tokio::test]
733    async fn handler_receives_events() {
734        let bus = HookBus::new();
735        let handler = TestHandler::new("test-ext", HookResult::Continue);
736        let perms = perms_with(&[Permission::ToolsIntercept]);
737
738        bus.subscribe(HookKind::BeforeToolCall, handler.clone(), None, None, perms)
739            .await
740            .unwrap();
741
742        let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "ls"}));
743        bus.emit(&event).await;
744
745        assert_eq!(handler.calls(), 1);
746    }
747
748    #[tokio::test]
749    async fn confirm_stops_chain_for_before_tool_call() {
750        let bus = HookBus::new();
751        let confirmer = TestHandler::new(
752            "confirmer",
753            HookResult::Confirm {
754                message: "Run this command?".into(),
755            },
756        );
757        let after = TestHandler::new("after", HookResult::Continue);
758        let perms = perms_with(&[Permission::ToolsIntercept]);
759
760        bus.subscribe(
761            HookKind::BeforeToolCall,
762            confirmer.clone(),
763            None,
764            None,
765            perms.clone(),
766        )
767        .await
768        .unwrap();
769        bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
770            .await
771            .unwrap();
772
773        let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
774        let result = bus.emit(&event).await;
775
776        assert!(matches!(result, HookResult::Confirm { .. }));
777        assert_eq!(confirmer.calls(), 1);
778        assert_eq!(after.calls(), 0);
779    }
780
781    #[tokio::test]
782    async fn confirm_is_ignored_for_non_tool_hooks() {
783        let bus = HookBus::new();
784        let confirmer = TestHandler::new(
785            "confirmer",
786            HookResult::Confirm {
787                message: "Not allowed here".into(),
788            },
789        );
790        let perms = perms_with(&[Permission::LlmContent]);
791
792        bus.subscribe(
793            HookKind::BeforeMessage,
794            confirmer.clone(),
795            None,
796            None,
797            perms,
798        )
799        .await
800        .unwrap();
801
802        let event = HookEvent::before_message("hello");
803        let result = bus.emit(&event).await;
804
805        assert!(matches!(result, HookResult::Continue));
806        assert_eq!(confirmer.calls(), 1);
807    }
808
809    #[tokio::test]
810    async fn block_stops_chain() {
811        let bus = HookBus::new();
812        let blocker = TestHandler::new(
813            "blocker",
814            HookResult::Block {
815                reason: "dangerous".into(),
816            },
817        );
818        let after = TestHandler::new("after", HookResult::Continue);
819        let perms = perms_with(&[Permission::ToolsIntercept]);
820
821        bus.subscribe(
822            HookKind::BeforeToolCall,
823            blocker.clone(),
824            None,
825            None,
826            perms.clone(),
827        )
828        .await
829        .unwrap();
830        bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
831            .await
832            .unwrap();
833
834        let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
835        let result = bus.emit(&event).await;
836
837        assert!(matches!(result, HookResult::Block { .. }));
838        assert_eq!(blocker.calls(), 1);
839        assert_eq!(after.calls(), 0); // never reached
840    }
841
842    #[tokio::test]
843    async fn modify_stops_chain() {
844        let bus = HookBus::new();
845        let modifier = TestHandler::new(
846            "modifier",
847            HookResult::Modify {
848                input: serde_json::json!({"command": "echo safe"}),
849            },
850        );
851        let after = TestHandler::new(
852            "after",
853            HookResult::Block {
854                reason: "should not run".into(),
855            },
856        );
857        let perms = perms_with(&[Permission::ToolsIntercept]);
858
859        bus.subscribe(
860            HookKind::BeforeToolCall,
861            modifier.clone(),
862            None,
863            None,
864            perms.clone(),
865        )
866        .await
867        .unwrap();
868        bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
869            .await
870            .unwrap();
871
872        let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "rm -rf /"}));
873        let result = bus.emit(&event).await;
874
875        assert!(
876            matches!(result, HookResult::Modify { input } if input == serde_json::json!({"command": "echo safe"}))
877        );
878        assert_eq!(modifier.calls(), 1);
879        assert_eq!(after.calls(), 0); // never reached
880    }
881
882    #[tokio::test]
883    async fn tool_filter_only_matches_specified_tool() {
884        let bus = HookBus::new();
885        let handler = TestHandler::new("bash-only", HookResult::Continue);
886        let perms = perms_with(&[Permission::ToolsIntercept]);
887
888        bus.subscribe(
889            HookKind::AfterToolCall,
890            handler.clone(),
891            Some("bash".into()),
892            None,
893            perms,
894        )
895        .await
896        .unwrap();
897
898        // Should NOT fire for 'read' tool
899        let event = HookEvent::after_tool_call("read", serde_json::json!({}), "content".into());
900        bus.emit(&event).await;
901        assert_eq!(handler.calls(), 0);
902
903        // SHOULD fire for 'bash' tool
904        let event = HookEvent::after_tool_call("bash", serde_json::json!({}), "output".into());
905        bus.emit(&event).await;
906        assert_eq!(handler.calls(), 1);
907    }
908
909    #[tokio::test]
910    async fn permission_denied_rejects_subscribe() {
911        let bus = HookBus::new();
912        let handler = TestHandler::new("no-perms", HookResult::Continue);
913        let perms = PermissionSet::new(); // empty — no permissions
914
915        let result = bus
916            .subscribe(HookKind::BeforeToolCall, handler, None, None, perms)
917            .await;
918
919        assert!(result.is_err());
920        assert!(result.unwrap_err().contains("lacks permission"));
921    }
922
923    #[tokio::test]
924    async fn unsubscribe_removes_handlers() {
925        let bus = HookBus::new();
926        let handler = TestHandler::new("removable", HookResult::Continue);
927        let perms = perms_with(&[Permission::ToolsIntercept]);
928
929        bus.subscribe(HookKind::BeforeToolCall, handler.clone(), None, None, perms)
930            .await
931            .unwrap();
932        assert_eq!(bus.handler_count().await, 1);
933
934        bus.unsubscribe_all("removable").await;
935        assert_eq!(bus.handler_count().await, 0);
936    }
937
938    #[tokio::test]
939    async fn subscriptions_for_lists_only_matching_extension() {
940        let bus = HookBus::new();
941        let alpha = TestHandler::new("alpha", HookResult::Continue);
942        let beta = TestHandler::new("beta", HookResult::Continue);
943        let perms = perms_with(&[Permission::ToolsIntercept]);
944
945        bus.subscribe(
946            HookKind::BeforeToolCall,
947            alpha.clone(),
948            Some("bash".into()),
949            None,
950            perms.clone(),
951        )
952        .await
953        .unwrap();
954        bus.subscribe(
955            HookKind::AfterToolCall,
956            alpha.clone(),
957            None,
958            None,
959            perms.clone(),
960        )
961        .await
962        .unwrap();
963        bus.subscribe(HookKind::BeforeToolCall, beta.clone(), None, None, perms)
964            .await
965            .unwrap();
966
967        let alpha_subs = bus.subscriptions_for("alpha").await;
968        assert_eq!(alpha_subs.len(), 2);
969        // sorted by kind name then by tool_filter (None first)
970        assert_eq!(alpha_subs[0].0, HookKind::AfterToolCall);
971        assert_eq!(alpha_subs[0].1, None);
972        assert_eq!(alpha_subs[1].0, HookKind::BeforeToolCall);
973        assert_eq!(alpha_subs[1].1, Some("bash".to_string()));
974
975        let beta_subs = bus.subscriptions_for("beta").await;
976        assert_eq!(beta_subs, vec![(HookKind::BeforeToolCall, None)]);
977
978        let none_subs = bus.subscriptions_for("ghost").await;
979        assert!(none_subs.is_empty());
980    }
981
982    #[tokio::test]
983    async fn is_empty_reflects_state() {
984        let bus = HookBus::new();
985        assert!(bus.is_empty().await);
986
987        let handler = TestHandler::new("ext", HookResult::Continue);
988        let perms = perms_with(&[Permission::ToolsIntercept]);
989        bus.subscribe(HookKind::BeforeToolCall, handler, None, None, perms)
990            .await
991            .unwrap();
992        assert!(!bus.is_empty().await);
993    }
994
995    // ────────────────────────────────────────────────────────────────────────
996    // compress-then-truncate ordering (Synaps Fork 1) — RED stub
997    // ────────────────────────────────────────────────────────────────────────
998
999    #[derive(Debug)]
1000    struct RecordingHandler {
1001        id: String,
1002        recorded_len: Arc<AtomicUsize>,
1003        result: HookResult,
1004    }
1005    impl RecordingHandler {
1006        fn new(id: &str, result: HookResult) -> (Arc<Self>, Arc<AtomicUsize>) {
1007            let recorded_len = Arc::new(AtomicUsize::new(0));
1008            let h = Arc::new(Self {
1009                id: id.to_string(),
1010                recorded_len: recorded_len.clone(),
1011                result,
1012            });
1013            (h, recorded_len)
1014        }
1015    }
1016    #[async_trait]
1017    impl crate::extensions::runtime::ExtensionHandler for RecordingHandler {
1018        fn id(&self) -> &str {
1019            &self.id
1020        }
1021        async fn handle(&self, event: &HookEvent) -> HookResult {
1022            if let Some(out) = event.tool_output.as_ref() {
1023                self.recorded_len.store(out.len(), Ordering::Relaxed);
1024            }
1025            self.result.clone()
1026        }
1027        async fn shutdown(&self) {}
1028    }
1029
1030    /// Test A (RED stub, current signature) — proves the bug.
1031    #[tokio::test]
1032    async fn after_tool_call_transform_receives_full_output_not_pre_truncated() {
1033        use crate::tools::Tool;
1034        let bus = std::sync::Arc::new(HookBus::new());
1035        let (handler, recorded_len) = RecordingHandler::new("len-recorder", HookResult::Continue);
1036        bus.subscribe(
1037            HookKind::AfterToolCall,
1038            handler,
1039            None,
1040            None,
1041            perms_with(&[Permission::ToolsIntercept]),
1042        )
1043        .await
1044        .unwrap();
1045
1046        let ctx = crate::tools::ToolContext {
1047            channels: crate::tools::ToolChannels {
1048                tx_delta: None,
1049                tx_events: None,
1050            },
1051            capabilities: crate::tools::ToolCapabilities {
1052                watcher_exit_path: None,
1053                tool_register_tx: None,
1054                session_manager: None,
1055                subagent_registry: None,
1056                event_queue: None,
1057                delegation_parent: None,
1058                secret_prompt: None,
1059                orchestration: None,
1060                tool_activation: None,
1061                mcp_leases: None,
1062                extension_leases: None,
1063                memory_context: None,
1064            },
1065            limits: crate::tools::ToolLimits {
1066                max_tool_output: 30_000,
1067                max_tool_buffer: 256 * 1024,
1068                bash_timeout: 30,
1069                bash_max_timeout: 300,
1070                subagent_timeout: 300,
1071            },
1072        };
1073
1074        let bash = crate::tools::BashTool;
1075        let output = bash
1076            .execute(
1077                serde_json::json!({"command": "yes hello | head -c 150000"}),
1078                ctx,
1079            )
1080            .await
1081            .expect("bash should succeed");
1082
1083        let _ = crate::runtime::emit_after_tool_call(
1084            &bus,
1085            "bash",
1086            None,
1087            serde_json::json!({"command": "yes hello | head -c 150000"}),
1088            output,
1089            30_000,
1090        )
1091        .await;
1092
1093        let len = recorded_len.load(Ordering::Relaxed);
1094        assert!(
1095            len > 100_000,
1096            "after_tool_call handler was starved: received {len} bytes — \
1097             expected the full ~150KB buffered output (max_tool_buffer=256KB)"
1098        );
1099    }
1100
1101    /// Test B: when a transform Replaces with a >max_tool_output string, the
1102    /// FINAL emit_after_tool_call return is ≤ max_tool_output (+marker overhead).
1103    /// Proves compress-then-truncate ordering at the new site.
1104    #[tokio::test]
1105    async fn final_output_truncated_to_max_tool_output_after_hook() {
1106        let bus = std::sync::Arc::new(HookBus::new());
1107        let big: String = "x".repeat(100_000);
1108        let handler = TestHandler::new("bloater", HookResult::Replace { output: big });
1109        bus.subscribe(
1110            HookKind::AfterToolCall,
1111            handler,
1112            None,
1113            None,
1114            perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
1115        )
1116        .await
1117        .unwrap();
1118
1119        let result = crate::runtime::emit_after_tool_call(
1120            &bus,
1121            "bash",
1122            None,
1123            serde_json::json!({}),
1124            "tiny original".to_string(),
1125            30_000,
1126        )
1127        .await;
1128
1129        assert!(
1130            result.len() <= 30_000 + 200,
1131            "post-hook output should be capped at max_tool_output (got {} bytes)",
1132            result.len()
1133        );
1134    }
1135
1136    /// Test C: with NO transform, the final output is byte-identical to the
1137    /// legacy truncate_tool_result(...) result — behavior preservation invariant.
1138    #[tokio::test]
1139    async fn no_transform_extension_preserves_legacy_truncation() {
1140        let bus = std::sync::Arc::new(HookBus::new());
1141        let huge: String = "y".repeat(80_000);
1142
1143        let result = crate::runtime::emit_after_tool_call(
1144            &bus,
1145            "bash",
1146            None,
1147            serde_json::json!({}),
1148            huge.clone(),
1149            30_000,
1150        )
1151        .await;
1152
1153        let legacy = crate::runtime::helpers::HelperMethods::truncate_tool_result(&huge, 30_000);
1154        assert_eq!(
1155            result, legacy,
1156            "no-extension path must be byte-identical to legacy truncation"
1157        );
1158    }
1159
1160    /// Test D: emit_after_tool_call unit-level — Continue and Replace branches
1161    /// both truncate to max_tool_output, and truncation is char-boundary safe.
1162    #[tokio::test]
1163    async fn emit_after_tool_call_truncates_char_boundary_safe() {
1164        // Continue path with multibyte content.
1165        let bus = std::sync::Arc::new(HookBus::new());
1166        let multibyte: String = "héllo🌟".repeat(5_000);
1167        assert!(multibyte.len() > 30_000);
1168
1169        let result = crate::runtime::emit_after_tool_call(
1170            &bus,
1171            "bash",
1172            None,
1173            serde_json::json!({}),
1174            multibyte,
1175            30_000,
1176        )
1177        .await;
1178        assert!(result.is_char_boundary(result.len()));
1179
1180        // Replace path with multibyte content.
1181        let bus2 = std::sync::Arc::new(HookBus::new());
1182        let big_multi = "✨".repeat(20_000);
1183        assert!(big_multi.len() > 30_000);
1184        let handler = TestHandler::new("mb-replacer", HookResult::Replace { output: big_multi });
1185        bus2.subscribe(
1186            HookKind::AfterToolCall,
1187            handler,
1188            None,
1189            None,
1190            perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
1191        )
1192        .await
1193        .unwrap();
1194
1195        let result2 = crate::runtime::emit_after_tool_call(
1196            &bus2,
1197            "bash",
1198            None,
1199            serde_json::json!({}),
1200            "orig".to_string(),
1201            30_000,
1202        )
1203        .await;
1204        assert!(result2.is_char_boundary(result2.len()));
1205    }
1206}