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