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