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