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//!
8//! Notify-only kinds ([`HookKind::is_notification`]) go out through
9//! [`HookBus::notify`]: the caller does not wait, and no handler can delay,
10//! fail or change what the caller is doing.
11
12pub mod events;
13
14use std::collections::HashMap;
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17
18use futures::future::join_all;
19use futures::FutureExt;
20use tokio::sync::RwLock;
21
22use self::events::{HookEvent, HookKind, HookResult};
23use crate::extensions::manifest::HookMatcher;
24use crate::extensions::permissions::{Permission, PermissionSet};
25
26/// Default timeout for a single hook handler call.
27const HANDLER_TIMEOUT: Duration = Duration::from_secs(5);
28
29/// Most [`HookBus::notify`] deliveries in flight on one bus. A notify-only
30/// event that arrives while this many are still being delivered is dropped,
31/// never queued, so handlers that are slow while events keep coming cost at
32/// most this many tasks, each ended by [`HANDLER_TIMEOUT`].
33const NOTIFY_MAX_IN_FLIGHT: usize = 64;
34
35fn extensions_trace_enabled() -> bool {
36    std::env::var("SYNAPS_EXTENSIONS_TRACE")
37        .map(|value| {
38            let normalized = value.trim().to_ascii_lowercase();
39            matches!(normalized.as_str(), "1" | "true" | "yes" | "on")
40        })
41        .unwrap_or(false)
42}
43
44fn hook_result_action(result: &HookResult) -> &'static str {
45    match result {
46        HookResult::Continue => "continue",
47        HookResult::Block { .. } => "block",
48        HookResult::Inject { .. } => "inject",
49        HookResult::Confirm { .. } => "confirm",
50        HookResult::Modify { .. } => "modify",
51        HookResult::Replace { .. } => "replace",
52        HookResult::ContextPhase { .. } => "context_phase",
53    }
54}
55
56/// Two-key gate for an advisory `context_phase` report: subscribing to
57/// `on_message_complete` needs `privacy.llm_content`; reporting a phase to
58/// the host's context management additionally needs `session.lifecycle`.
59/// Returns `Some(phase)` when the report is accepted, `None` (after a
60/// warning) when it must be ignored and treated as `continue`.
61fn accept_context_phase(
62    reg: &HandlerRegistration,
63    kind: HookKind,
64    phase: String,
65) -> Option<String> {
66    if !reg.permissions.has(Permission::SessionLifecycle) {
67        tracing::warn!(
68            hook = %kind.as_str(),
69            extension = %reg.handler.id(),
70            "Extension returned context_phase without session.lifecycle permission — ignoring report"
71        );
72        return None;
73    }
74    tracing::info!(
75        hook = %kind.as_str(),
76        extension = %reg.handler.id(),
77        phase = %crate::runtime::continuation::phase_for_log(&phase),
78        "Extension reported advisory context phase"
79    );
80    Some(phase)
81}
82
83/// Whether a handler's `tool` filter covers `ev`. A filter matches the
84/// tool's API name or runtime name (MCP tools with sanitized names still
85/// match). Before a call, a filter for `edit` or `write` also covers the
86/// builtin `multi_edit`, which changes files the same way: a guard written
87/// before `multi_edit` existed must still block or confirm it. Output
88/// transforms (`after_tool_call`) stay exact; their rewrite is per-format.
89fn tool_filter_matches(filter: &str, ev: &HookEvent) -> bool {
90    let (api, runtime) = (ev.tool_name.as_deref(), ev.tool_runtime_name.as_deref());
91    if api == Some(filter) || runtime == Some(filter) {
92        return true;
93    }
94    ev.kind == HookKind::BeforeToolCall
95        && matches!(filter, "edit" | "write")
96        && runtime.or(api) == Some("multi_edit")
97}
98
99/// A registered hook handler with its metadata.
100#[derive(Clone)]
101pub struct HandlerRegistration {
102    /// The extension handler.
103    pub handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
104    /// Optional tool name filter (None = all tools).
105    pub tool_filter: Option<String>,
106    /// Optional matcher for event payloads.
107    pub matcher: Option<HookMatcher>,
108    /// Permissions granted to this handler's extension.
109    pub permissions: PermissionSet,
110}
111
112impl std::fmt::Debug for HookBus {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        f.debug_struct("HookBus").finish_non_exhaustive()
115    }
116}
117
118/// The central hook dispatcher.
119///
120/// Thread-safe: uses `RwLock` so multiple concurrent emitters can read
121/// the handler list, and registration takes a write lock only briefly.
122pub struct HookBus {
123    handlers: RwLock<HashMap<HookKind, Vec<HandlerRegistration>>>,
124    /// Per-extension-id load generation, bumped by [`Self::unsubscribe_all`]
125    /// (unload / reload / reinstall). Stamped on every `Confirm` so the
126    /// "Allow all" latch keys on (id, generation): a different plugin later
127    /// installed under the same id does not inherit an earlier consent.
128    generations: std::sync::Mutex<HashMap<String, u64>>,
129    /// Context injected by `on_session_start` handlers, held for the lifetime
130    /// of the session.
131    ///
132    /// `before_message` injection is per-turn and re-applied on every request.
133    /// Session-start injection is the once-per-session equivalent: an
134    /// extension that wants to hand the model a session handoff should not
135    /// have to fake it by tracking "have I injected yet?" across every
136    /// message, and should not pay to re-send it each turn.
137    ///
138    /// Keyed by conversation session id: one process may host N sessions
139    /// (Phase 2 daemon), and each must read only its own injection.
140    session_injection: RwLock<HashMap<String, String>>,
141    /// `Some(host)`: this bus is a subagent worker's view of `host`
142    /// ([`Self::worker_transforms`]) and has no handlers of its own.
143    transforms_of: Option<Arc<HookBus>>,
144    /// One permit per [`Self::notify`] delivery in flight
145    /// ([`NOTIFY_MAX_IN_FLIGHT`]).
146    notify_slots: Arc<tokio::sync::Semaphore>,
147}
148
149impl HookBus {
150    /// Create an empty HookBus with no handlers.
151    pub fn new() -> Self {
152        Self {
153            handlers: RwLock::new(HashMap::new()),
154            session_injection: RwLock::new(HashMap::new()),
155            generations: std::sync::Mutex::new(HashMap::new()),
156            transforms_of: None,
157            notify_slots: Arc::new(tokio::sync::Semaphore::new(NOTIFY_MAX_IN_FLIGHT)),
158        }
159    }
160
161    /// The hook bus a subagent worker runs with: a read-only view of
162    /// `host`'s `after_tool_call` subscriptions from extensions that hold
163    /// `tools.transform_output`, so output transforms (compression,
164    /// redaction) see worker tool results too. Nothing else fires in a
165    /// worker: no `before_tool_call` (no confirm prompt, block or modify),
166    /// no message or session hooks, no observe-only `after_tool_call`.
167    ///
168    /// Live, not a snapshot: every [`Self::emit`] reads `host`'s current
169    /// handler list, so an extension loaded, reloaded or unloaded while a
170    /// worker runs applies from that worker's next tool call (a snapshot
171    /// would keep calling a reloaded extension's stopped instance).
172    /// Dispatch is otherwise [`Self::emit`]'s: chain order, the permission
173    /// matrix, the 5 s timeout, first `replace` wins. Handlers are called
174    /// through [`ExtensionHandler::handle_no_spawn`]: a worker never starts
175    /// or restarts an extension process, and one that is not running
176    /// passes the output through.
177    ///
178    /// [`ExtensionHandler::handle_no_spawn`]: crate::extensions::runtime::ExtensionHandler::handle_no_spawn
179    pub fn worker_transforms(host: &Arc<HookBus>) -> Arc<HookBus> {
180        // A view of a view reads the same host.
181        let host = host.transforms_of.clone().unwrap_or_else(|| Arc::clone(host));
182        Arc::new(Self {
183            transforms_of: Some(host),
184            ..Self::new()
185        })
186    }
187
188    /// Whether this is a worker's view ([`Self::worker_transforms`]).
189    pub fn is_worker_view(&self) -> bool {
190        self.transforms_of.is_some()
191    }
192
193    /// Snapshot of what a worker view dispatches for `kind`: `None` for any
194    /// kind but `after_tool_call`, or when no handler holds
195    /// `tools.transform_output`.
196    async fn worker_transform_registrations(
197        &self,
198        kind: HookKind,
199    ) -> Option<Vec<HandlerRegistration>> {
200        if kind != HookKind::AfterToolCall {
201            return None;
202        }
203        let handlers = self.handlers.read().await;
204        let regs: Vec<HandlerRegistration> = handlers
205            .get(&kind)?
206            .iter()
207            .filter(|reg| reg.permissions.has(Permission::ToolsTransformOutput))
208            .cloned()
209            .collect();
210        (!regs.is_empty()).then_some(regs)
211    }
212
213    /// Current load generation of `extension_id` (0 until first unloaded).
214    pub fn extension_generation(&self, extension_id: &str) -> u64 {
215        self.generations
216            .lock()
217            .unwrap_or_else(|e| e.into_inner())
218            .get(extension_id)
219            .copied()
220            .unwrap_or(0)
221    }
222
223    /// Record context injected by an `on_session_start` handler for
224    /// `session_id`.
225    ///
226    /// Called once per session, by whoever emits the session-start event,
227    /// after extensions have loaded. Later calls for the same id replace the
228    /// stored value.
229    pub async fn set_session_injection_for(&self, session_id: &str, content: String) {
230        self.session_injection
231            .write()
232            .await
233            .insert(session_id.to_string(), content);
234    }
235
236    /// Context injected at session start for `session_id`, if any. Read on
237    /// every turn while composing the system prompt.
238    pub async fn session_injection_for(&self, session_id: &str) -> Option<String> {
239        self.session_injection.read().await.get(session_id).cloned()
240    }
241
242    /// Drop the injection recorded for `session_id` (session teardown).
243    pub async fn clear_session_injection(&self, session_id: &str) {
244        self.session_injection.write().await.remove(session_id);
245    }
246
247    /// Compatibility shim: writes under the empty key.
248    #[deprecated(note = "use set_session_injection_for")]
249    pub async fn set_session_injection(&self, content: String) {
250        self.set_session_injection_for("", content).await;
251    }
252
253    /// Compatibility shim: reads the empty key.
254    #[deprecated(note = "use session_injection_for")]
255    pub async fn session_injection(&self) -> Option<String> {
256        self.session_injection_for("").await
257    }
258
259    /// Register a handler for a specific hook kind.
260    ///
261    /// Returns an error if the handler's permissions don't allow
262    /// subscribing to this hook kind.
263    pub async fn subscribe(
264        &self,
265        kind: HookKind,
266        handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
267        tool_filter: Option<String>,
268        matcher: Option<HookMatcher>,
269        permissions: PermissionSet,
270    ) -> Result<(), String> {
271        if self.is_worker_view() {
272            return Err(format!(
273                "Extension '{}' cannot subscribe to a worker's hook view; subscribe to the host bus",
274                handler.id(),
275            ));
276        }
277        // Permission check
278        if !permissions.allows_hook(kind) {
279            return Err(format!(
280                "Extension '{}' lacks permission '{}' required for hook '{}'",
281                handler.id(),
282                kind.required_permission().as_str(),
283                kind.as_str(),
284            ));
285        }
286
287        let reg = HandlerRegistration {
288            handler,
289            tool_filter,
290            matcher,
291            permissions,
292        };
293
294        let mut handlers = self.handlers.write().await;
295        handlers.entry(kind).or_default().push(reg);
296        Ok(())
297    }
298
299    /// Emit a hook event to all registered handlers.
300    ///
301    /// Returns the first `Block` result if any handler blocks, otherwise
302    /// returns `Continue`. Handlers are called in registration order.
303    ///
304    /// For `before_tool_call` only `Block` ends the chain (#148 I1):
305    /// - `Modify` rewrites the input and the chain continues; every later
306    ///   handler sees (and vets) the modified input, and the final input is
307    ///   returned as `Modify` unless something blocks or confirms.
308    /// - `Confirm` is recorded and the chain continues, so a later `Block`
309    ///   wins even when the confirming extension is already latched.
310    ///   Confirms are merged (all sources, all messages) and returned last,
311    ///   carrying any input modified BEFORE the first Confirm.
312    /// - A `Modify` after a pending `Confirm` is ignored (logged at warn):
313    ///   the user confirms the input as it stood when they were asked.
314    ///
315    /// If no handlers are registered for this hook, returns immediately
316    /// (the no-extensions fast path).
317    ///
318    /// On a worker view ([`Self::worker_transforms`]) only the host's
319    /// `after_tool_call` transform handlers run, each through
320    /// `handle_no_spawn`; every other kind returns `Continue` at once.
321    pub async fn emit(&self, event: &HookEvent) -> HookResult {
322        // Snapshot the handler list and drop the lock immediately.
323        // This prevents holding the RwLock across async handler calls
324        // (which could block subscribe/unsubscribe for the entire
325        // duration of IPC round-trips to extension processes).
326        let no_spawn = self.is_worker_view();
327        let registrations = match &self.transforms_of {
328            Some(host) => match host.worker_transform_registrations(event.kind).await {
329                Some(regs) => regs,
330                None => return HookResult::Continue,
331            },
332            None => {
333                let handlers = self.handlers.read().await;
334                match handlers.get(&event.kind) {
335                    Some(regs) if !regs.is_empty() => regs.clone(),
336                    _ => return HookResult::Continue, // fast path: no handlers
337                }
338            }
339        }; // lock dropped here
340
341        // Collect injections from all handlers rather than returning on first
342        let mut injections: Vec<String> = Vec::new();
343        // First valid advisory context-phase report wins; the chain continues
344        // so later observers still see the event.
345        let mut context_phase: Option<String> = None;
346        // (source extension id, message) for every Confirm, in chain order.
347        let mut confirms: Vec<(String, String)> = Vec::new();
348        // (id, load generation) per distinct confirming extension.
349        let mut source_keys: Vec<(String, u64)> = Vec::new();
350
351        // Input rewritten by an earlier Modify (#148 I1 / prA I1). Later
352        // handlers see — and vet — the MODIFIED input, so a Modify can no
353        // longer smuggle unchecked input past a later Block or Confirm.
354        let mut modified_event: Option<HookEvent> = None;
355        // Who rewrote it (chain order, de-duplicated).
356        let mut modified_by: Vec<String> = Vec::new();
357
358        for reg in &registrations {
359            let ev: &HookEvent = modified_event.as_ref().unwrap_or(event);
360            // Tool-specific filter: skip handlers that don't match.
361            // Check both API name and runtime name so MCP tools with
362            // sanitized names (slashes→underscores) still match.
363            if let Some(ref filter) = reg.tool_filter {
364                if !tool_filter_matches(filter, ev) {
365                    continue;
366                }
367            }
368
369            if let Some(ref matcher) = reg.matcher {
370                if !matcher.matches(ev) {
371                    continue;
372                }
373            }
374
375            // Call handler with timeout
376            let handler = reg.handler.clone();
377            let event_clone = ev.clone();
378            let trace_enabled = extensions_trace_enabled();
379            let started_at = trace_enabled.then(Instant::now);
380            let call = async {
381                if no_spawn {
382                    handler.handle_no_spawn(&event_clone).await
383                } else {
384                    handler.handle(&event_clone).await
385                }
386            };
387            let result = tokio::time::timeout(HANDLER_TIMEOUT, call).await;
388
389            if trace_enabled {
390                let health = reg.handler.health().await;
391                let health = health.as_str();
392                let restart_count = reg.handler.restart_count().await;
393                let duration_ms = started_at
394                    .map(|start| start.elapsed().as_millis() as u64)
395                    .unwrap_or(0);
396                match &result {
397                    Ok(hook_result) => {
398                        let action = hook_result_action(hook_result);
399                        tracing::info!(
400                            extension_trace = true,
401                            hook = %ev.kind.as_str(),
402                            extension = %reg.handler.id(),
403                            action = action,
404                            duration_ms = duration_ms,
405                            health = health,
406                            restart_count = restart_count,
407                            "Extension hook trace"
408                        );
409                    }
410                    Err(_) => {
411                        tracing::warn!(
412                            extension_trace = true,
413                            hook = %ev.kind.as_str(),
414                            extension = %reg.handler.id(),
415                            action = "timeout",
416                            duration_ms = duration_ms,
417                            timeout_secs = HANDLER_TIMEOUT.as_secs(),
418                            health = health,
419                            restart_count = restart_count,
420                            "Extension hook trace"
421                        );
422                    }
423                }
424            }
425
426            match result {
427                Ok(result) if !ev.kind.allows_result(&result) => {
428                    tracing::warn!(
429                        hook = %ev.kind.as_str(),
430                        extension = %reg.handler.id(),
431                        action = hook_result_action(&result),
432                        "Extension returned action not allowed for hook — ignoring"
433                    );
434                    continue;
435                }
436                Ok(HookResult::Block { reason }) => {
437                    tracing::info!(
438                        hook = %ev.kind.as_str(),
439                        extension = %reg.handler.id(),
440                        reason = %reason,
441                        "Hook blocked by extension"
442                    );
443                    return HookResult::Block { reason };
444                }
445                Ok(HookResult::Continue) => {}
446                Ok(HookResult::Inject { content }) => {
447                    tracing::debug!(
448                        hook = %ev.kind.as_str(),
449                        extension = %reg.handler.id(),
450                        len = content.len(),
451                        "Extension injected context"
452                    );
453                    // Accumulate — don't early-return. Multiple extensions can inject.
454                    injections.push(content);
455                }
456                Ok(HookResult::Modify { input }) => {
457                    if !confirms.is_empty() {
458                        // The user is asked about the input as it stood at
459                        // the first Confirm; a rewrite after that point
460                        // would run something they never saw.
461                        tracing::warn!(
462                            hook = %ev.kind.as_str(),
463                            extension = %reg.handler.id(),
464                            confirming = ?confirms.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
465                            "Extension Modify after a pending Confirm IGNORED: the user confirms the input as it was when the first Confirm was raised"
466                        );
467                        continue;
468                    }
469                    tracing::info!(
470                        hook = %ev.kind.as_str(),
471                        extension = %reg.handler.id(),
472                        "Hook modified tool input by extension; later handlers vet the modified input"
473                    );
474                    let mut next = ev.clone();
475                    next.tool_input = Some(input);
476                    modified_event = Some(next);
477                    let id = reg.handler.id().to_string();
478                    if !modified_by.contains(&id) {
479                        modified_by.push(id);
480                    }
481                }
482                Ok(HookResult::Replace { output }) => {
483                    // Two-key gate: subscribing to after_tool_call needs
484                    // `tools.intercept` (observe); rewriting the output
485                    // additionally needs `tools.transform_output`. Without it,
486                    // ignore this handler's Replace (fail-safe: original output
487                    // preserved) and continue the chain.
488                    if !reg.permissions.has(Permission::ToolsTransformOutput) {
489                        tracing::warn!(
490                            hook = %ev.kind.as_str(),
491                            extension = %reg.handler.id(),
492                            "Extension returned Replace without tools.transform_output permission — ignoring transform"
493                        );
494                        continue;
495                    }
496                    let observed_len = ev.tool_output.as_ref().map_or(0, String::len);
497                    tracing::info!(
498                        hook = %ev.kind.as_str(),
499                        extension = %reg.handler.id(),
500                        observed_len,
501                        replacement_len = output.len(),
502                        "Hook replaced tool output by extension"
503                    );
504                    // Persistent audit trail — who rewrote which tool's output
505                    // and when, plus sizes (never content). Best-effort: a
506                    // failed audit write must not break the tool flow.
507                    let _ = crate::extensions::audit::record_tool_output_replace(
508                        reg.handler.id(),
509                        ev.kind.as_str(),
510                        ev.tool_name.clone(),
511                        observed_len,
512                        output.len(),
513                    );
514                    // First transform wins (mirrors Modify). Chaining is a future enhancement.
515                    return HookResult::Replace { output };
516                }
517                Ok(HookResult::Confirm { message, .. }) => {
518                    tracing::info!(
519                        hook = %ev.kind.as_str(),
520                        extension = %reg.handler.id(),
521                        "Hook requested confirmation by extension"
522                    );
523                    // Host-stamped attribution: the "Allow all" latch and
524                    // the dialog key on the extension that actually asked.
525                    // Keep going: a later Block must still win.
526                    confirms.push((reg.handler.id().to_string(), message));
527                    let id = reg.handler.id();
528                    if !source_keys.iter().any(|(k, _)| k == id) {
529                        source_keys.push((id.to_string(), self.extension_generation(id)));
530                    }
531                }
532                Ok(HookResult::ContextPhase { phase }) => {
533                    // Advisory only — never stops the chain. Two-key gated;
534                    // an ignored report is a plain continue.
535                    if let Some(phase) = accept_context_phase(reg, ev.kind, phase) {
536                        if context_phase.is_none() {
537                            context_phase = Some(phase);
538                        } else {
539                            tracing::debug!(
540                                hook = %ev.kind.as_str(),
541                                extension = %reg.handler.id(),
542                                "Extension context_phase superseded by an earlier report — ignoring"
543                            );
544                        }
545                    }
546                }
547                Err(_timeout) => {
548                    tracing::warn!(
549                        hook = %ev.kind.as_str(),
550                        extension = %reg.handler.id(),
551                        timeout_secs = HANDLER_TIMEOUT.as_secs(),
552                        "Hook handler timed out — skipping"
553                    );
554                    // Fail-open: timeout = continue
555                }
556            }
557        }
558
559        // No Block anywhere in the chain: resolve the Confirm(s) last.
560        if !confirms.is_empty() {
561            let tool = event
562                .tool_runtime_name
563                .clone()
564                .or_else(|| event.tool_name.clone());
565            let message = if confirms.len() == 1 {
566                confirms[0].1.clone()
567            } else {
568                confirms
569                    .iter()
570                    .map(|(id, m)| format!("[{id}] {m}"))
571                    .collect::<Vec<_>>()
572                    .join("\n\n")
573            };
574            let sources = source_keys.iter().map(|(id, _)| id.clone()).collect();
575            let generations = source_keys.iter().map(|(_, g)| *g).collect();
576            return HookResult::Confirm {
577                message,
578                sources,
579                generations,
580                tool,
581                modified_input: modified_event.and_then(|e| e.tool_input),
582                modified_by,
583            };
584        }
585        if let Some(input) = modified_event.and_then(|e| e.tool_input) {
586            return HookResult::Modify { input };
587        }
588
589        // Merge accumulated injections from all handlers. An advisory phase
590        // report is returned only when nothing else would be (Block/Inject
591        // take precedence; on_message_complete permits neither).
592        if !injections.is_empty() {
593            HookResult::Inject {
594                content: injections.join("\n\n"),
595            }
596        } else if let Some(phase) = context_phase {
597            HookResult::ContextPhase { phase }
598        } else {
599            HookResult::Continue
600        }
601    }
602
603    /// Emit a hook event to all registered handlers **concurrently**.
604    ///
605    /// All handlers race under a single shared timeout (`per_handler_timeout`).
606    /// Results are collected and the first `Block` wins; injections are merged.
607    ///
608    /// **When to use this over `emit()`:**
609    ///
610    /// Only safe for hook kinds whose handlers are order-independent — i.e.
611    /// where no handler's result depends on another's execution.  Currently
612    /// that applies to:
613    ///   - `on_session_end`: only `Continue` is a valid result; handlers are
614    ///     fire-and-forget notification calls (deck, d20, jawz-widget,
615    ///     synaps-tasks all write to their own stores independently).
616    ///
617    /// The notify-only kinds do not come through here: [`notify`](Self::notify)
618    /// delivers to each handler on its own.
619    ///
620    /// **Do NOT use for** `before_tool_call` / `after_tool_call` / `before_message`
621    /// hooks where `Block` / `Modify` / `Replace` / `Inject` semantics require a
622    /// defined winner when two handlers disagree — `join_all` completion order is
623    /// nondeterministic, so the "winner" would be unstable. Those hooks must use
624    /// the sequential [`emit`](Self::emit), which enforces first-wins ordering.
625    ///
626    /// With N extensions and a 5 s per-handler timeout, serial emit takes up
627    /// to N×5 s; concurrent emit collapses that to a single 5 s window
628    /// regardless of N — critical for teardown budgets.
629    pub async fn emit_concurrent(&self, event: &HookEvent) -> HookResult {
630        // H4: real assert, not debug_assert — this invariant protects against
631        // nondeterministic Block/Modify/Replace results in release builds.
632        assert!(
633            !matches!(
634                event.kind,
635                HookKind::BeforeToolCall | HookKind::AfterToolCall | HookKind::BeforeMessage
636            ),
637            "emit_concurrent must not be used for transform-capable hooks \
638             ({:?}); their Block/Modify/Replace/Inject results need the \
639             deterministic first-wins ordering of the sequential emit()",
640            event.kind,
641        );
642        // Snapshot handler list (same as emit()).
643        let registrations = {
644            let handlers = self.handlers.read().await;
645            match handlers.get(&event.kind) {
646                Some(regs) if !regs.is_empty() => regs.clone(),
647                _ => return HookResult::Continue, // fast path: no handlers
648            }
649        };
650
651        // Dispatch all handlers simultaneously.
652        let futures: Vec<_> =
653            registrations
654                .iter()
655                .filter(|reg| {
656                    // Apply tool filter before spawning.
657                    reg.tool_filter
658                        .as_deref()
659                        .is_none_or(|filter| tool_filter_matches(filter, event))
660                })
661                .filter(|reg| reg.matcher.as_ref().is_none_or(|m| m.matches(event)))
662                .map(|reg| {
663                    let handler = reg.handler.clone();
664                    let event_clone = event.clone();
665                    async move {
666                        (
667                            reg,
668                            tokio::time::timeout(HANDLER_TIMEOUT, handler.handle(&event_clone))
669                                .await,
670                        )
671                    }
672                })
673                .collect();
674
675        let results = join_all(futures).await;
676
677        let mut injections: Vec<String> = Vec::new();
678        // First accepted report in completion order (registration order for
679        // the collected results); advisory only, never stops the chain.
680        let mut context_phase: Option<String> = None;
681        for (reg, result) in results {
682            match result {
683                Ok(HookResult::Continue) => {}
684                Ok(HookResult::Block { reason }) => {
685                    return HookResult::Block { reason };
686                }
687                Ok(HookResult::Inject { content }) => {
688                    injections.push(content);
689                }
690                Ok(HookResult::Modify { input }) => {
691                    return HookResult::Modify { input };
692                }
693                Ok(HookResult::Replace { output }) => {
694                    return HookResult::Replace { output };
695                }
696                Ok(HookResult::Confirm { message, .. }) => {
697                    return HookResult::Confirm {
698                        message,
699                        sources: vec![reg.handler.id().to_string()],
700                        generations: vec![self.extension_generation(reg.handler.id())],
701                        tool: event.tool_runtime_name.clone().or_else(|| event.tool_name.clone()),
702                        modified_input: None,
703                        modified_by: vec![],
704                    };
705                }
706                Ok(result @ HookResult::ContextPhase { .. }) => {
707                    if !event.kind.allows_result(&result) {
708                        tracing::warn!(
709                            hook = %event.kind.as_str(),
710                            extension = %reg.handler.id(),
711                            action = hook_result_action(&result),
712                            "Extension returned action not allowed for hook — ignoring"
713                        );
714                        continue;
715                    }
716                    if let HookResult::ContextPhase { phase } = result {
717                        if let Some(phase) = accept_context_phase(reg, event.kind, phase) {
718                            context_phase.get_or_insert(phase);
719                        }
720                    }
721                }
722                Err(_timeout) => {
723                    tracing::warn!(
724                        hook = %event.kind.as_str(),
725                        extension = %reg.handler.id(),
726                        timeout_secs = HANDLER_TIMEOUT.as_secs(),
727                        "Hook handler timed out in concurrent emit — skipping"
728                    );
729                }
730            }
731        }
732
733        if !injections.is_empty() {
734            HookResult::Inject {
735                content: injections.join("\n\n"),
736            }
737        } else if let Some(phase) = context_phase {
738            HookResult::ContextPhase { phase }
739        } else {
740            HookResult::Continue
741        }
742    }
743
744    /// Deliver a notify-only event ([`HookKind::is_notification`]) without
745    /// waiting for anyone. Returns at once and cannot fail: the caller's
746    /// work is already done and no handler can delay, fail or change it.
747    ///
748    /// The handlers run concurrently on one detached task, each under
749    /// [`HANDLER_TIMEOUT`], so the task ends within that time of the event
750    /// being ready and holds nothing of the caller's. Every result, error,
751    /// timeout and panic is ignored, and costs the other handlers nothing
752    /// ([`Self::notify_with`]). Delivery is best effort and unordered; the
753    /// event is dropped when
754    ///
755    /// - nobody subscribes to the kind (then nothing is spawned at all),
756    /// - this is a worker's view ([`Self::worker_transforms`]),
757    /// - [`NOTIFY_MAX_IN_FLIGHT`] deliveries are still in flight (logged),
758    /// - there is no Tokio runtime to deliver on, or it shuts down first.
759    ///
760    /// A handler that is stopped is not started again by a late delivery
761    /// (`ProcessExtension::shutdown` is terminal).
762    pub fn notify(self: &Arc<Self>, event: HookEvent) {
763        self.notify_with(event.kind, async move { Some(event) });
764    }
765
766    /// [`Self::notify`] for events that cost something to build (a file
767    /// lookup): `events` runs on the detached task, inside the same in-flight
768    /// limit, and is not polled at all when the delivery would be dropped.
769    /// The build is not under the handler timeout, so it must end on its own.
770    ///
771    /// What it yields is `None`, `Some(event)` or a `Vec` of events. Several
772    /// are one delivery, on the one task: every handler is sent them in that
773    /// order, the next once it has answered the one before. So a caller with
774    /// many events to send takes one in-flight slot, not one each, and
775    /// cannot have its later events dropped because its earlier ones are
776    /// still in flight. An event of another kind than `kind` is not sent.
777    ///
778    /// Each handler gets the sequence on its own. One that does not answer
779    /// an event within [`HANDLER_TIMEOUT`] is sent no more of this delivery
780    /// (logged once, with the number not sent): the request that timed out
781    /// is still in its input, and each further one would keep it busy, and
782    /// its other hooks waiting, that much longer. Nobody else's events are
783    /// held up or dropped by it, and the task ends within one timeout of
784    /// the slowest answer. A handler that panics on an event loses that
785    /// event and nothing more: it is sent the next, and the other handlers
786    /// are not touched. (That is where a panic unwinds. The release profile
787    /// aborts on any panic, here as anywhere.)
788    pub fn notify_with<F, I>(self: &Arc<Self>, kind: HookKind, events: F)
789    where
790        F: std::future::Future<Output = I> + Send + 'static,
791        I: IntoIterator<Item = HookEvent> + Send,
792        I::IntoIter: Send,
793    {
794        if !kind.is_notification() {
795            // A hook whose answer matters must be awaited (`emit`).
796            tracing::error!(hook = %kind.as_str(), "notify used for a hook that is not notify-only; nothing sent");
797            return;
798        }
799        if self.is_worker_view() || !self.may_have_subscriber(kind) {
800            return;
801        }
802        let Ok(runtime) = tokio::runtime::Handle::try_current() else {
803            return;
804        };
805        let Ok(slot) = Arc::clone(&self.notify_slots).try_acquire_owned() else {
806            tracing::warn!(
807                hook = %kind.as_str(),
808                in_flight = NOTIFY_MAX_IN_FLIGHT,
809                "Notify-only hook event dropped: earlier deliveries are still in flight"
810            );
811            return;
812        };
813        let bus = Arc::clone(self);
814        runtime.spawn(async move {
815            let _slot = slot;
816            let events: Vec<HookEvent> =
817                events.await.into_iter().filter(|event| event.kind == kind).collect();
818            bus.deliver_notifications(kind, &events).await;
819        });
820    }
821
822    /// One [`Self::notify_with`] delivery: `events`, in order, to every
823    /// handler of `kind`, the handlers independent of each other.
824    async fn deliver_notifications(&self, kind: HookKind, events: &[HookEvent]) {
825        let registrations = {
826            let handlers = self.handlers.read().await;
827            handlers.get(&kind).cloned().unwrap_or_default()
828        }; // lock dropped here, as in `emit`
829        let sequences = registrations.iter().map(|reg| async move {
830            let wanted = |event: &HookEvent| {
831                reg.tool_filter
832                    .as_deref()
833                    .is_none_or(|filter| tool_filter_matches(filter, event))
834                    && reg.matcher.as_ref().is_none_or(|m| m.matches(event))
835            };
836            for (sent, event) in events.iter().enumerate() {
837                if !wanted(event) {
838                    continue;
839                }
840                // The answer is discarded. A panic is caught here, so it ends
841                // this one call and not the task every handler shares.
842                let call = tokio::time::timeout(HANDLER_TIMEOUT, reg.handler.handle(event));
843                match std::panic::AssertUnwindSafe(call).catch_unwind().await {
844                    Ok(Ok(_answer)) => {}
845                    Ok(Err(_timeout)) => {
846                        // The host has given up on this call; the handler
847                        // has not. Sending it the rest would only add to
848                        // what it is behind by, and to the time its other
849                        // hooks time out in.
850                        let not_sent =
851                            events[sent + 1..].iter().filter(|event| wanted(event)).count();
852                        tracing::warn!(
853                            hook = %kind.as_str(),
854                            extension = %reg.handler.id(),
855                            timeout_secs = HANDLER_TIMEOUT.as_secs(),
856                            not_sent,
857                            "Notify-only hook handler timed out — the later events of this delivery are not sent to it"
858                        );
859                        break;
860                    }
861                    Err(_panic) => tracing::error!(
862                        hook = %kind.as_str(),
863                        extension = %reg.handler.id(),
864                        "Notify-only hook handler panicked — event skipped for it"
865                    ),
866                }
867            }
868        });
869        join_all(sequences).await;
870    }
871
872    /// Whether `kind` may have a subscriber, without waiting: `true` while
873    /// the handler list is being changed (the delivery task then looks).
874    fn may_have_subscriber(&self, kind: HookKind) -> bool {
875        match self.handlers.try_read() {
876            Ok(handlers) => handlers.get(&kind).is_some_and(|regs| !regs.is_empty()),
877            Err(_) => true,
878        }
879    }
880
881    /// [`Self::notify`] deliveries still in flight.
882    #[cfg(test)]
883    pub(crate) fn notifies_in_flight(&self) -> usize {
884        NOTIFY_MAX_IN_FLIGHT - self.notify_slots.available_permits()
885    }
886
887    /// Remove all handlers for a given extension ID.
888    pub async fn unsubscribe_all(&self, extension_id: &str) {
889        *self
890            .generations
891            .lock()
892            .unwrap_or_else(|e| e.into_inner())
893            .entry(extension_id.to_string())
894            .or_insert(0) += 1;
895        let mut handlers = self.handlers.write().await;
896        for regs in handlers.values_mut() {
897            regs.retain(|r| r.handler.id() != extension_id);
898        }
899    }
900
901    /// Number of registered handlers across all hooks. On a worker view:
902    /// the host transform handlers it would dispatch.
903    pub async fn handler_count(&self) -> usize {
904        if let Some(host) = &self.transforms_of {
905            return host
906                .worker_transform_registrations(HookKind::AfterToolCall)
907                .await
908                .map_or(0, |regs| regs.len());
909        }
910        let handlers = self.handlers.read().await;
911        handlers.values().map(|v| v.len()).sum()
912    }
913
914    /// Check if any handlers are registered (for fast-path decisions).
915    pub async fn is_empty(&self) -> bool {
916        if self.is_worker_view() {
917            return self.handler_count().await == 0;
918        }
919        let handlers = self.handlers.read().await;
920        handlers.values().all(|v| v.is_empty())
921    }
922
923    /// Return all (kind, tool_filter) pairs subscribed by the given extension id.
924    /// Sorted by kind name, then by tool_filter (None first), for stable output.
925    pub async fn subscriptions_for(&self, extension_id: &str) -> Vec<(HookKind, Option<String>)> {
926        let handlers = self.handlers.read().await;
927        let mut out: Vec<(HookKind, Option<String>)> = Vec::new();
928        for (kind, regs) in handlers.iter() {
929            for reg in regs {
930                if reg.handler.id() == extension_id {
931                    out.push((*kind, reg.tool_filter.clone()));
932                }
933            }
934        }
935        out.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()).then_with(|| a.1.cmp(&b.1)));
936        out
937    }
938}
939
940impl Default for HookBus {
941    fn default() -> Self {
942        Self::new()
943    }
944}
945
946#[cfg(test)]
947mod tests {
948    use super::*;
949    use crate::extensions::hooks::events::HookEvent;
950    use crate::extensions::permissions::Permission;
951    use async_trait::async_trait;
952    use std::sync::atomic::{AtomicUsize, Ordering};
953
954    /// Test handler that counts calls and returns a configurable result.
955    struct TestHandler {
956        id: String,
957        call_count: AtomicUsize,
958        result: HookResult,
959    }
960
961    impl TestHandler {
962        fn new(id: &str, result: HookResult) -> Arc<Self> {
963            Arc::new(Self {
964                id: id.to_string(),
965                call_count: AtomicUsize::new(0),
966                result,
967            })
968        }
969
970        fn calls(&self) -> usize {
971            self.call_count.load(Ordering::Relaxed)
972        }
973    }
974
975    #[async_trait]
976    impl crate::extensions::runtime::ExtensionHandler for TestHandler {
977        fn id(&self) -> &str {
978            &self.id
979        }
980
981        async fn handle(&self, _event: &HookEvent) -> HookResult {
982            self.call_count.fetch_add(1, Ordering::Relaxed);
983            self.result.clone()
984        }
985
986        async fn shutdown(&self) {}
987    }
988
989    fn perms_with(perms: &[Permission]) -> PermissionSet {
990        let mut set = PermissionSet::new();
991        for p in perms {
992            set.grant(*p);
993        }
994        set
995    }
996
997    /// emit_after_tool_call returns the substituted output when an
998    /// after_tool_call handler returns Replace — this is the seam that lets an
999    /// extension compress/redact tool output before it enters history.
1000    #[tokio::test]
1001    #[serial_test::serial(synaps_base_dir)]
1002    async fn after_tool_call_replace_substitutes_recorded_output() {
1003        // #186: Replace writes the intercept audit — isolate the base dir.
1004        let base = crate::test_env::BaseDirGuard::new();
1005        let bus = std::sync::Arc::new(HookBus::new());
1006        let handler = TestHandler::new(
1007            "compressor",
1008            HookResult::Replace {
1009                output: "COMPRESSED".into(),
1010            },
1011        );
1012        bus.subscribe(
1013            HookKind::AfterToolCall,
1014            handler,
1015            None,
1016            None,
1017            perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
1018        )
1019        .await
1020        .unwrap();
1021
1022        let recorded = crate::runtime::emit_after_tool_call(
1023            &bus,
1024            "bash",
1025            None,
1026            serde_json::json!({"command": "cat huge.log"}),
1027            "RAW 10k lines".to_string(),
1028            30_000,
1029            None,
1030            None,
1031        )
1032        .await;
1033
1034        assert_eq!(recorded, "COMPRESSED", "Replace output must reach history");
1035        let audit = std::fs::read_to_string(
1036            crate::extensions::audit::intercept_file_path_for(base.path()),
1037        )
1038        .expect("intercept audit must land in the isolated base dir");
1039        assert!(audit.contains("\"plugin_id\":\"compressor\""));
1040    }
1041
1042    /// With no transform handler, the original output is preserved unchanged.
1043    #[tokio::test]
1044    async fn after_tool_call_without_replace_keeps_original_output() {
1045        let bus = std::sync::Arc::new(HookBus::new());
1046        let recorded = crate::runtime::emit_after_tool_call(
1047            &bus,
1048            "bash",
1049            None,
1050            serde_json::json!({}),
1051            "RAW".to_string(),
1052            30_000,
1053            None,
1054            None,
1055        )
1056        .await;
1057
1058        assert_eq!(recorded, "RAW", "no Replace → original output preserved");
1059    }
1060
1061    /// First Replace wins: once an earlier after_tool_call handler returns
1062    /// Replace, later handlers are never reached (mirrors block/modify
1063    /// chain-stop). Proves the "first transform wins" comment in emit().
1064    #[tokio::test]
1065    #[serial_test::serial(synaps_base_dir)]
1066    async fn replace_stops_chain_for_after_tool_call() {
1067        // #186: Replace writes the intercept audit — isolate the base dir.
1068        let _base = crate::test_env::BaseDirGuard::new();
1069        let bus = HookBus::new();
1070        let first = TestHandler::new(
1071            "first",
1072            HookResult::Replace {
1073                output: "FIRST".into(),
1074            },
1075        );
1076        let second = TestHandler::new(
1077            "second",
1078            HookResult::Replace {
1079                output: "SECOND".into(),
1080            },
1081        );
1082        let perms = perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]);
1083
1084        bus.subscribe(
1085            HookKind::AfterToolCall,
1086            first.clone(),
1087            None,
1088            None,
1089            perms.clone(),
1090        )
1091        .await
1092        .unwrap();
1093        bus.subscribe(HookKind::AfterToolCall, second.clone(), None, None, perms)
1094            .await
1095            .unwrap();
1096
1097        let event = HookEvent::after_tool_call("bash", serde_json::json!({}), "RAW".to_string());
1098        let result = bus.emit(&event).await;
1099
1100        assert_eq!(
1101            result,
1102            HookResult::Replace {
1103                output: "FIRST".into()
1104            }
1105        );
1106        assert_eq!(first.calls(), 1);
1107        assert_eq!(second.calls(), 0); // never reached — first transform wins
1108    }
1109
1110    /// Two-key gate: an extension may subscribe to after_tool_call with only
1111    /// `tools.intercept` (observe), but its Replace is IGNORED without the
1112    /// additional `tools.transform_output` — the original output is preserved.
1113    #[tokio::test]
1114    async fn replace_without_transform_permission_is_ignored() {
1115        let bus = std::sync::Arc::new(HookBus::new());
1116        let handler = TestHandler::new(
1117            "observer-only",
1118            HookResult::Replace {
1119                output: "SNEAKY".into(),
1120            },
1121        );
1122        // Only the observe key — NOT tools.transform_output.
1123        bus.subscribe(
1124            HookKind::AfterToolCall,
1125            handler.clone(),
1126            None,
1127            None,
1128            perms_with(&[Permission::ToolsIntercept]),
1129        )
1130        .await
1131        .unwrap();
1132
1133        let recorded = crate::runtime::emit_after_tool_call(
1134            &bus,
1135            "bash",
1136            None,
1137            serde_json::json!({}),
1138            "ORIGINAL".to_string(),
1139            30_000,
1140            None,
1141            None,
1142        )
1143        .await;
1144
1145        assert_eq!(handler.calls(), 1, "handler still runs (it's subscribed)");
1146        assert_eq!(
1147            recorded, "ORIGINAL",
1148            "Replace without tools.transform_output must be ignored — original preserved"
1149        );
1150    }
1151
1152    // ── context_phase (advisory work-phase report) ───────────────────────────
1153
1154    fn phase(p: &str) -> HookResult {
1155        HookResult::ContextPhase { phase: p.into() }
1156    }
1157
1158    fn message_complete_event() -> HookEvent {
1159        HookEvent::on_message_complete(
1160            "done",
1161            serde_json::json!({"content_block_count": 1, "has_tool_use": false}),
1162        )
1163    }
1164
1165    /// Two-key gate, positive side: `privacy.llm_content` (subscribe) plus
1166    /// `session.lifecycle` (report) => the advisory report is returned.
1167    #[tokio::test]
1168    async fn context_phase_accepted_with_session_lifecycle() {
1169        let bus = HookBus::new();
1170        let reporter = TestHandler::new("reporter", phase("new_task"));
1171        bus.subscribe(
1172            HookKind::OnMessageComplete,
1173            reporter.clone(),
1174            None,
1175            None,
1176            perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]),
1177        )
1178        .await
1179        .unwrap();
1180
1181        let result = bus.emit(&message_complete_event()).await;
1182        assert_eq!(result, phase("new_task"));
1183        assert_eq!(reporter.calls(), 1);
1184    }
1185
1186    /// Two-key gate, negative side: the observe key alone lets the handler
1187    /// run, but its report is ignored (treated as continue).
1188    #[tokio::test]
1189    async fn context_phase_ignored_without_session_lifecycle() {
1190        let bus = HookBus::new();
1191        let reporter = TestHandler::new("observer-only", phase("new_task"));
1192        bus.subscribe(
1193            HookKind::OnMessageComplete,
1194            reporter.clone(),
1195            None,
1196            None,
1197            perms_with(&[Permission::LlmContent]),
1198        )
1199        .await
1200        .unwrap();
1201
1202        let result = bus.emit(&message_complete_event()).await;
1203        assert_eq!(result, HookResult::Continue);
1204        assert_eq!(reporter.calls(), 1, "handler still runs (it's subscribed)");
1205    }
1206
1207    /// A report does not stop the chain: later observers still run, and the
1208    /// first VALID report wins over later ones (an unprivileged earlier
1209    /// report does not claim the slot).
1210    #[tokio::test]
1211    async fn context_phase_does_not_stop_chain_and_first_valid_wins() {
1212        let bus = HookBus::new();
1213        let unprivileged = TestHandler::new("unprivileged", phase("plan"));
1214        let first = TestHandler::new("first", phase("new_task"));
1215        let second = TestHandler::new("second", phase("wrap_up"));
1216        let observer = TestHandler::new("observer", HookResult::Continue);
1217        let both = || perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]);
1218
1219        bus.subscribe(
1220            HookKind::OnMessageComplete,
1221            unprivileged.clone(),
1222            None,
1223            None,
1224            perms_with(&[Permission::LlmContent]),
1225        )
1226        .await
1227        .unwrap();
1228        for handler in [first.clone(), second.clone()] {
1229            bus.subscribe(HookKind::OnMessageComplete, handler, None, None, both())
1230                .await
1231                .unwrap();
1232        }
1233        bus.subscribe(
1234            HookKind::OnMessageComplete,
1235            observer.clone(),
1236            None,
1237            None,
1238            perms_with(&[Permission::LlmContent]),
1239        )
1240        .await
1241        .unwrap();
1242
1243        let result = bus.emit(&message_complete_event()).await;
1244        assert_eq!(result, phase("new_task"), "first valid report wins");
1245        assert_eq!(unprivileged.calls(), 1);
1246        assert_eq!(first.calls(), 1);
1247        assert_eq!(second.calls(), 1, "later reporter still runs");
1248        assert_eq!(observer.calls(), 1, "later observer still runs");
1249    }
1250
1251    /// A Block from an on_message_complete handler is still ignored, and a
1252    /// valid phase report from another handler is still returned.
1253    #[tokio::test]
1254    async fn context_phase_survives_ignored_block_on_message_complete() {
1255        let bus = HookBus::new();
1256        let blocker = TestHandler::new(
1257            "blocker",
1258            HookResult::Block {
1259                reason: "nope".into(),
1260            },
1261        );
1262        let reporter = TestHandler::new("reporter", phase("execute"));
1263        bus.subscribe(
1264            HookKind::OnMessageComplete,
1265            blocker.clone(),
1266            None,
1267            None,
1268            perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]),
1269        )
1270        .await
1271        .unwrap();
1272        bus.subscribe(
1273            HookKind::OnMessageComplete,
1274            reporter.clone(),
1275            None,
1276            None,
1277            perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]),
1278        )
1279        .await
1280        .unwrap();
1281
1282        let result = bus.emit(&message_complete_event()).await;
1283        assert_eq!(result, phase("execute"));
1284        assert_eq!(blocker.calls(), 1);
1285        assert_eq!(reporter.calls(), 1);
1286    }
1287
1288    /// context_phase is not allowed on any other hook kind, even with both
1289    /// permissions — sequential and concurrent paths alike.
1290    #[tokio::test]
1291    async fn context_phase_not_allowed_on_other_hooks() {
1292        let bus = HookBus::new();
1293        let all_perms = perms_with(&[
1294            Permission::ToolsIntercept,
1295            Permission::LlmContent,
1296            Permission::SessionLifecycle,
1297        ]);
1298        let handlers: Vec<(HookKind, HookEvent, Arc<TestHandler>)> = vec![
1299            (
1300                HookKind::BeforeToolCall,
1301                HookEvent::before_tool_call("bash", serde_json::json!({})),
1302                TestHandler::new("btc", phase("new_task")),
1303            ),
1304            (
1305                HookKind::AfterToolCall,
1306                HookEvent::after_tool_call("bash", serde_json::json!({}), "o".into()),
1307                TestHandler::new("atc", phase("new_task")),
1308            ),
1309            (
1310                HookKind::BeforeMessage,
1311                HookEvent::before_message("hi"),
1312                TestHandler::new("bm", phase("new_task")),
1313            ),
1314            (
1315                HookKind::OnCompaction,
1316                HookEvent::on_compaction("a", "b", "s", 1, serde_json::json!({})),
1317                TestHandler::new("oc", phase("new_task")),
1318            ),
1319            (
1320                HookKind::OnSessionStart,
1321                HookEvent::on_session_start("s"),
1322                TestHandler::new("oss", phase("new_task")),
1323            ),
1324            (
1325                HookKind::OnSessionEnd,
1326                HookEvent::on_session_end("s", None),
1327                TestHandler::new("ose", phase("new_task")),
1328            ),
1329        ];
1330        for (kind, _, handler) in &handlers {
1331            bus.subscribe(*kind, handler.clone(), None, None, all_perms.clone())
1332                .await
1333                .unwrap();
1334        }
1335        for (kind, event, handler) in &handlers {
1336            let result = bus.emit(event).await;
1337            assert_eq!(result, HookResult::Continue, "{}", kind.as_str());
1338            assert_eq!(handler.calls(), 1, "{}", kind.as_str());
1339        }
1340        // Concurrent path (only legal for non-transform hooks).
1341        let end = HookEvent::on_session_end("s", None);
1342        assert_eq!(bus.emit_concurrent(&end).await, HookResult::Continue);
1343    }
1344
1345    /// The concurrent emit path applies the same two-key gate and first-wins
1346    /// aggregation for on_message_complete.
1347    #[tokio::test]
1348    async fn context_phase_concurrent_emit_two_key_gate() {
1349        let bus = HookBus::new();
1350        let unprivileged = TestHandler::new("unprivileged", phase("plan"));
1351        let reporter = TestHandler::new("reporter", phase("wrap_up"));
1352        bus.subscribe(
1353            HookKind::OnMessageComplete,
1354            unprivileged.clone(),
1355            None,
1356            None,
1357            perms_with(&[Permission::LlmContent]),
1358        )
1359        .await
1360        .unwrap();
1361        bus.subscribe(
1362            HookKind::OnMessageComplete,
1363            reporter.clone(),
1364            None,
1365            None,
1366            perms_with(&[Permission::LlmContent, Permission::SessionLifecycle]),
1367        )
1368        .await
1369        .unwrap();
1370
1371        let result = bus.emit_concurrent(&message_complete_event()).await;
1372        assert_eq!(result, phase("wrap_up"));
1373        assert_eq!(unprivileged.calls(), 1);
1374        assert_eq!(reporter.calls(), 1);
1375    }
1376
1377    #[test]
1378    fn trace_env_value_parser_accepts_common_truthy_values() {
1379        for value in ["1", "true", "TRUE", "yes", "on"] {
1380            std::env::set_var("SYNAPS_EXTENSIONS_TRACE", value);
1381            assert!(
1382                extensions_trace_enabled(),
1383                "{value} should enable trace mode"
1384            );
1385        }
1386
1387        for value in ["", "0", "false", "off", "no"] {
1388            std::env::set_var("SYNAPS_EXTENSIONS_TRACE", value);
1389            assert!(
1390                !extensions_trace_enabled(),
1391                "{value:?} should not enable trace mode"
1392            );
1393        }
1394        std::env::remove_var("SYNAPS_EXTENSIONS_TRACE");
1395    }
1396
1397    #[tokio::test]
1398    async fn matcher_skips_handler_when_input_does_not_contain_value() {
1399        let bus = HookBus::new();
1400        let handler = TestHandler::new(
1401            "matcher",
1402            HookResult::Block {
1403                reason: "matched".into(),
1404            },
1405        );
1406        let mut perms = PermissionSet::new();
1407        perms.grant(Permission::ToolsIntercept);
1408        bus.subscribe(
1409            HookKind::BeforeToolCall,
1410            handler.clone(),
1411            None,
1412            Some(HookMatcher {
1413                input_contains: Some("danger".to_string()),
1414                input_equals: None,
1415            }),
1416            perms,
1417        )
1418        .await
1419        .unwrap();
1420
1421        let safe = HookEvent::before_tool_call("bash", serde_json::json!({"command": "echo safe"}));
1422        assert!(matches!(bus.emit(&safe).await, HookResult::Continue));
1423
1424        let danger =
1425            HookEvent::before_tool_call("bash", serde_json::json!({"command": "echo danger"}));
1426        assert!(matches!(bus.emit(&danger).await, HookResult::Block { .. }));
1427    }
1428
1429    #[test]
1430    fn hook_result_action_names_are_stable_for_trace_logs() {
1431        assert_eq!(hook_result_action(&HookResult::Continue), "continue");
1432        assert_eq!(
1433            hook_result_action(&HookResult::Block {
1434                reason: "stop".into(),
1435            }),
1436            "block"
1437        );
1438        assert_eq!(
1439            hook_result_action(&HookResult::Inject {
1440                content: "context".into(),
1441            }),
1442            "inject"
1443        );
1444        assert_eq!(
1445            hook_result_action(&HookResult::confirm("Proceed?")),
1446            "confirm"
1447        );
1448        assert_eq!(
1449            hook_result_action(&HookResult::Modify {
1450                input: serde_json::json!({"command": "echo safe"}),
1451            }),
1452            "modify"
1453        );
1454    }
1455
1456    #[tokio::test]
1457    async fn empty_bus_returns_continue() {
1458        let bus = HookBus::new();
1459        let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1460        let result = bus.emit(&event).await;
1461        assert!(matches!(result, HookResult::Continue));
1462    }
1463
1464    #[tokio::test]
1465    async fn handler_receives_events() {
1466        let bus = HookBus::new();
1467        let handler = TestHandler::new("test-ext", HookResult::Continue);
1468        let perms = perms_with(&[Permission::ToolsIntercept]);
1469
1470        bus.subscribe(HookKind::BeforeToolCall, handler.clone(), None, None, perms)
1471            .await
1472            .unwrap();
1473
1474        let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "ls"}));
1475        bus.emit(&event).await;
1476
1477        assert_eq!(handler.calls(), 1);
1478    }
1479
1480    async fn bus_with(handlers: Vec<Arc<TestHandler>>) -> HookBus {
1481        let bus = HookBus::new();
1482        let perms = perms_with(&[Permission::ToolsIntercept]);
1483        for h in handlers {
1484            bus.subscribe(HookKind::BeforeToolCall, h, None, None, perms.clone())
1485                .await
1486                .unwrap();
1487        }
1488        bus
1489    }
1490
1491    /// #148 I1: a Confirm no longer stops the chain. Later handlers run, and
1492    /// with nothing else to say the Confirm is returned (resolved last).
1493    #[tokio::test]
1494    async fn confirm_does_not_stop_chain_for_before_tool_call() {
1495        let confirmer = TestHandler::new("confirmer", HookResult::confirm("Run this command?"));
1496        let after = TestHandler::new("after", HookResult::Continue);
1497        let bus = bus_with(vec![confirmer.clone(), after.clone()]).await;
1498
1499        let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1500        let result = bus.emit(&event).await;
1501
1502        assert!(matches!(result, HookResult::Confirm { .. }));
1503        assert_eq!(confirmer.calls(), 1);
1504        assert_eq!(after.calls(), 1, "later handlers are still evaluated");
1505    }
1506
1507    /// A later extension's Block wins over an earlier Confirm.
1508    #[tokio::test]
1509    async fn later_block_wins_over_earlier_confirm() {
1510        let confirmer = TestHandler::new("confirmer", HookResult::confirm("Run?"));
1511        let blocker = TestHandler::new(
1512            "policy",
1513            HookResult::Block {
1514                reason: "policy veto".into(),
1515            },
1516        );
1517        let bus = bus_with(vec![confirmer, blocker]).await;
1518        let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1519        assert!(matches!(
1520            bus.emit(&event).await,
1521            HookResult::Block { reason } if reason == "policy veto"
1522        ));
1523    }
1524
1525    /// Two extensions confirming the same call: one prompt carrying both
1526    /// sources (so "Allow all" from one cannot silently skip the other).
1527    #[tokio::test]
1528    async fn confirms_from_several_extensions_merge_sources() {
1529        let a = TestHandler::new("ext-a", HookResult::confirm("A asks"));
1530        let b = TestHandler::new("ext-b", HookResult::confirm("B asks"));
1531        let bus = bus_with(vec![a, b]).await;
1532        let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1533        match bus.emit(&event).await {
1534            HookResult::Confirm {
1535                message, sources, ..
1536            } => {
1537                assert_eq!(sources, vec!["ext-a".to_string(), "ext-b".to_string()]);
1538                assert!(message.contains("A asks") && message.contains("B asks"), "{message}");
1539                assert!(message.contains("[ext-a]") && message.contains("[ext-b]"), "{message}");
1540            }
1541            other => panic!("expected merged Confirm, got {other:?}"),
1542        }
1543    }
1544
1545    /// With the confirming extension latched ("Allow all"), a later
1546    /// extension's Block is still evaluated and still blocks.
1547    #[tokio::test]
1548    async fn armed_latch_never_skips_a_later_block() {
1549        let confirmer = TestHandler::new("jev", HookResult::confirm("Run?"));
1550        let blocker = TestHandler::new(
1551            "policy",
1552            HookResult::Block {
1553                reason: "policy veto".into(),
1554            },
1555        );
1556        let bus = bus_with(vec![confirmer, blocker.clone()]).await;
1557        let latch = crate::tools::SessionConfirmLatch::new();
1558        latch.allow(&[("jev".to_string(), 0)]);
1559        let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1560        let decision = crate::runtime::resolve_before_tool_call_decision(
1561            serde_json::json!({}),
1562            bus.emit(&event).await,
1563            None,
1564            false,
1565            Some(&latch),
1566        )
1567        .await;
1568        assert_eq!(blocker.calls(), 1);
1569        assert!(matches!(
1570            decision,
1571            crate::runtime::BeforeToolCallDecision::Block { reason } if reason == "policy veto"
1572        ));
1573    }
1574
1575    /// Records the `tool_input` it was shown, then returns `result`.
1576    struct InputSeen {
1577        id: String,
1578        seen: std::sync::Mutex<Option<serde_json::Value>>,
1579        result: HookResult,
1580    }
1581    #[async_trait]
1582    impl crate::extensions::runtime::ExtensionHandler for InputSeen {
1583        fn id(&self) -> &str {
1584            &self.id
1585        }
1586        async fn handle(&self, event: &HookEvent) -> HookResult {
1587            *self.seen.lock().unwrap() = event.tool_input.clone();
1588            self.result.clone()
1589        }
1590        async fn shutdown(&self) {}
1591    }
1592    fn input_seen(id: &str, result: HookResult) -> Arc<InputSeen> {
1593        Arc::new(InputSeen {
1594            id: id.into(),
1595            seen: std::sync::Mutex::new(None),
1596            result,
1597        })
1598    }
1599    fn modify(cmd: &str) -> HookResult {
1600        HookResult::Modify {
1601            input: serde_json::json!({ "command": cmd }),
1602        }
1603    }
1604    async fn bus_of(handlers: Vec<Arc<dyn crate::extensions::runtime::ExtensionHandler>>) -> HookBus {
1605        let bus = HookBus::new();
1606        let perms = perms_with(&[Permission::ToolsIntercept]);
1607        for h in handlers {
1608            bus.subscribe(HookKind::BeforeToolCall, h, None, None, perms.clone())
1609                .await
1610                .unwrap();
1611        }
1612        bus
1613    }
1614
1615    /// prA I1: a Modify no longer ends the chain. A later Block sees the
1616    /// MODIFIED input and still wins.
1617    #[tokio::test]
1618    async fn modify_then_block_blocks_and_blocker_vets_modified_input() {
1619        let blocker = input_seen(
1620            "policy",
1621            HookResult::Block {
1622                reason: "veto".into(),
1623            },
1624        );
1625        let bus = bus_of(vec![input_seen("rewriter", modify("rm -rf /")), blocker.clone()]).await;
1626        let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "ls"}));
1627        assert!(matches!(bus.emit(&event).await, HookResult::Block { reason } if reason == "veto"));
1628        assert_eq!(
1629            blocker.seen.lock().unwrap().clone(),
1630            Some(serde_json::json!({"command": "rm -rf /"})),
1631            "the blocker must vet the rewritten input, not the original"
1632        );
1633    }
1634
1635    /// Modify then Confirm: the user is still asked, and approval runs the
1636    /// modified input.
1637    #[tokio::test]
1638    async fn modify_then_confirm_prompts_with_modified_input() {
1639        let confirmer = input_seen("jev", HookResult::confirm("ok?"));
1640        let bus = bus_of(vec![input_seen("rewriter", modify("echo safe")), confirmer.clone()]).await;
1641        let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "ls"}));
1642        let result = bus.emit(&event).await;
1643        assert_eq!(
1644            confirmer.seen.lock().unwrap().clone(),
1645            Some(serde_json::json!({"command": "echo safe"}))
1646        );
1647        match &result {
1648            HookResult::Confirm {
1649                sources,
1650                modified_input,
1651                ..
1652            } => {
1653                assert_eq!(sources, &vec!["jev".to_string()]);
1654                assert_eq!(
1655                    modified_input,
1656                    &Some(serde_json::json!({"command": "echo safe"}))
1657                );
1658            }
1659            other => panic!("expected Confirm, got {other:?}"),
1660        }
1661        // Approved (here: auto-approve) → the MODIFIED input runs.
1662        let decision = crate::runtime::resolve_before_tool_call_decision(
1663            serde_json::json!({"command": "ls"}),
1664            result,
1665            None,
1666            true,
1667            None,
1668        )
1669        .await;
1670        assert!(matches!(
1671            decision,
1672            crate::runtime::BeforeToolCallDecision::Continue { input }
1673                if input == serde_json::json!({"command": "echo safe"})
1674        ));
1675    }
1676
1677    /// Two modifiers compose in order; with nothing else, the final input is
1678    /// returned as a Modify.
1679    #[tokio::test]
1680    async fn modifiers_compose_and_final_input_is_returned() {
1681        let second = input_seen("second", modify("two"));
1682        let bus = bus_of(vec![input_seen("first", modify("one")), second.clone()]).await;
1683        let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "zero"}));
1684        assert!(matches!(
1685            bus.emit(&event).await,
1686            HookResult::Modify { input } if input == serde_json::json!({"command": "two"})
1687        ));
1688        assert_eq!(
1689            second.seen.lock().unwrap().clone(),
1690            Some(serde_json::json!({"command": "one"}))
1691        );
1692    }
1693
1694    /// Unloading / reinstalling an extension under the same id bumps its
1695    /// load generation, so a latch armed for the old instance doesn't match.
1696    #[tokio::test]
1697    async fn reinstall_under_same_id_gets_a_new_confirm_generation() {
1698        let bus = bus_with(vec![TestHandler::new("jev", HookResult::confirm("ok?"))]).await;
1699        let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1700        let gens = |r: HookResult| match r {
1701            HookResult::Confirm { generations, .. } => generations,
1702            other => panic!("{other:?}"),
1703        };
1704        assert_eq!(gens(bus.emit(&event).await), vec![0]);
1705        bus.unsubscribe_all("jev").await;
1706        bus.subscribe(
1707            HookKind::BeforeToolCall,
1708            TestHandler::new("jev", HookResult::confirm("ok?")),
1709            None,
1710            None,
1711            perms_with(&[Permission::ToolsIntercept]),
1712        )
1713        .await
1714        .unwrap();
1715        let latch = crate::tools::SessionConfirmLatch::new();
1716        latch.allow(&[("jev".to_string(), 0)]);
1717        let result = bus.emit(&event).await;
1718        let decision = crate::runtime::resolve_before_tool_call_decision(
1719            serde_json::json!({}),
1720            result.clone(),
1721            None,
1722            false,
1723            Some(&latch),
1724        )
1725        .await;
1726        assert_eq!(gens(result), vec![1]);
1727        assert!(
1728            matches!(decision, crate::runtime::BeforeToolCallDecision::Block { .. }),
1729            "old consent must not carry over to the reinstalled extension"
1730        );
1731    }
1732
1733    /// A Modify after a pending Confirm is not applied (the user is asked to
1734    /// confirm the original call) — same outcome as before, when the chain
1735    /// stopped at the Confirm.
1736    #[tokio::test]
1737    async fn modify_after_confirm_is_ignored() {
1738        let confirmer = TestHandler::new("confirmer", HookResult::confirm("Run?"));
1739        let modifier = TestHandler::new(
1740            "modifier",
1741            HookResult::Modify {
1742                input: serde_json::json!({"command": "echo other"}),
1743            },
1744        );
1745        let bus = bus_with(vec![confirmer, modifier]).await;
1746        let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1747        assert!(matches!(
1748            bus.emit(&event).await,
1749            HookResult::Confirm { modified_input: None, .. }
1750        ), "Confirm→Modify: the modify is ignored (logged), the original input is confirmed");
1751    }
1752
1753    #[tokio::test]
1754    async fn confirm_is_stamped_with_source_extension_and_tool() {
1755        let bus = HookBus::new();
1756        let confirmer = TestHandler::new("jev", HookResult::confirm("Run ls?"));
1757        let perms = perms_with(&[Permission::ToolsIntercept]);
1758        bus.subscribe(HookKind::BeforeToolCall, confirmer, None, None, perms)
1759            .await
1760            .unwrap();
1761        let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1762        match bus.emit(&event).await {
1763            HookResult::Confirm {
1764                message,
1765                sources,
1766                tool,
1767                ..
1768            } => {
1769                assert_eq!(message, "Run ls?");
1770                assert_eq!(sources, vec!["jev".to_string()]);
1771                assert_eq!(tool.as_deref(), Some("bash"));
1772            }
1773            other => panic!("expected Confirm, got {other:?}"),
1774        }
1775    }
1776
1777    #[tokio::test]
1778    async fn confirm_is_ignored_for_non_tool_hooks() {
1779        let bus = HookBus::new();
1780        let confirmer = TestHandler::new(
1781            "confirmer",
1782            HookResult::confirm("Not allowed here"),
1783        );
1784        let perms = perms_with(&[Permission::LlmContent]);
1785
1786        bus.subscribe(
1787            HookKind::BeforeMessage,
1788            confirmer.clone(),
1789            None,
1790            None,
1791            perms,
1792        )
1793        .await
1794        .unwrap();
1795
1796        let event = HookEvent::before_message("hello");
1797        let result = bus.emit(&event).await;
1798
1799        assert!(matches!(result, HookResult::Continue));
1800        assert_eq!(confirmer.calls(), 1);
1801    }
1802
1803    #[tokio::test]
1804    async fn block_stops_chain() {
1805        let bus = HookBus::new();
1806        let blocker = TestHandler::new(
1807            "blocker",
1808            HookResult::Block {
1809                reason: "dangerous".into(),
1810            },
1811        );
1812        let after = TestHandler::new("after", HookResult::Continue);
1813        let perms = perms_with(&[Permission::ToolsIntercept]);
1814
1815        bus.subscribe(
1816            HookKind::BeforeToolCall,
1817            blocker.clone(),
1818            None,
1819            None,
1820            perms.clone(),
1821        )
1822        .await
1823        .unwrap();
1824        bus.subscribe(HookKind::BeforeToolCall, after.clone(), None, None, perms)
1825            .await
1826            .unwrap();
1827
1828        let event = HookEvent::before_tool_call("bash", serde_json::json!({}));
1829        let result = bus.emit(&event).await;
1830
1831        assert!(matches!(result, HookResult::Block { .. }));
1832        assert_eq!(blocker.calls(), 1);
1833        assert_eq!(after.calls(), 0); // never reached
1834    }
1835
1836    /// prA I1 (was `modify_stops_chain`, which pinned the bypass): a later
1837    /// Block still runs after a Modify, and wins.
1838    #[tokio::test]
1839    async fn modify_does_not_stop_chain_later_block_wins() {
1840        let modifier = TestHandler::new(
1841            "modifier",
1842            HookResult::Modify {
1843                input: serde_json::json!({"command": "echo safe"}),
1844            },
1845        );
1846        let after = TestHandler::new(
1847            "after",
1848            HookResult::Block {
1849                reason: "vetoed".into(),
1850            },
1851        );
1852        let bus = bus_with(vec![modifier.clone(), after.clone()]).await;
1853        let event = HookEvent::before_tool_call("bash", serde_json::json!({"command": "rm -rf /"}));
1854        let result = bus.emit(&event).await;
1855        assert!(matches!(result, HookResult::Block { reason } if reason == "vetoed"));
1856        assert_eq!(modifier.calls(), 1);
1857        assert_eq!(after.calls(), 1);
1858    }
1859
1860    #[tokio::test]
1861    async fn tool_filter_only_matches_specified_tool() {
1862        let bus = HookBus::new();
1863        let handler = TestHandler::new("bash-only", HookResult::Continue);
1864        let perms = perms_with(&[Permission::ToolsIntercept]);
1865
1866        bus.subscribe(
1867            HookKind::AfterToolCall,
1868            handler.clone(),
1869            Some("bash".into()),
1870            None,
1871            perms,
1872        )
1873        .await
1874        .unwrap();
1875
1876        // Should NOT fire for 'read' tool
1877        let event = HookEvent::after_tool_call("read", serde_json::json!({}), "content".into());
1878        bus.emit(&event).await;
1879        assert_eq!(handler.calls(), 0);
1880
1881        // SHOULD fire for 'bash' tool
1882        let event = HookEvent::after_tool_call("bash", serde_json::json!({}), "output".into());
1883        bus.emit(&event).await;
1884        assert_eq!(handler.calls(), 1);
1885    }
1886
1887    /// A guard written for `edit`/`write` (from before `multi_edit`
1888    /// existed) still sees `multi_edit` before the call, so it can block or
1889    /// confirm it. Output transforms stay exact.
1890    #[tokio::test]
1891    async fn edit_and_write_filters_also_guard_multi_edit_before_the_call() {
1892        for filter in ["edit", "write"] {
1893            let bus = HookBus::new();
1894            let guard = TestHandler::new(
1895                "guard",
1896                HookResult::Block {
1897                    reason: "guarded".into(),
1898                },
1899            );
1900            let after = TestHandler::new("after", HookResult::Continue);
1901            let perms = perms_with(&[Permission::ToolsIntercept]);
1902            bus.subscribe(HookKind::BeforeToolCall, guard.clone(), Some(filter.into()), None, perms.clone())
1903                .await
1904                .unwrap();
1905            bus.subscribe(HookKind::AfterToolCall, after.clone(), Some(filter.into()), None, perms)
1906                .await
1907                .unwrap();
1908            let input = serde_json::json!({"edits": [{"path": "/etc/hosts", "op": "append", "text": "x"}]});
1909            let blocked = bus.emit(&HookEvent::before_tool_call("multi_edit", input.clone())).await;
1910            assert!(matches!(blocked, HookResult::Block { reason } if reason == "guarded"), "{filter}");
1911            assert_eq!(guard.calls(), 1, "{filter}");
1912            let other = bus.emit(&HookEvent::before_tool_call("read", serde_json::json!({}))).await;
1913            assert!(matches!(other, HookResult::Continue), "{filter}: only the file-changing tool");
1914            bus.emit(&HookEvent::after_tool_call("multi_edit", input, "ok".into())).await;
1915            assert_eq!(after.calls(), 0, "{filter}: after_tool_call filters stay exact");
1916        }
1917    }
1918
1919    #[tokio::test]
1920    async fn permission_denied_rejects_subscribe() {
1921        let bus = HookBus::new();
1922        let handler = TestHandler::new("no-perms", HookResult::Continue);
1923        let perms = PermissionSet::new(); // empty — no permissions
1924
1925        let result = bus
1926            .subscribe(HookKind::BeforeToolCall, handler, None, None, perms)
1927            .await;
1928
1929        assert!(result.is_err());
1930        assert!(result.unwrap_err().contains("lacks permission"));
1931    }
1932
1933    #[tokio::test]
1934    async fn unsubscribe_removes_handlers() {
1935        let bus = HookBus::new();
1936        let handler = TestHandler::new("removable", HookResult::Continue);
1937        let perms = perms_with(&[Permission::ToolsIntercept]);
1938
1939        bus.subscribe(HookKind::BeforeToolCall, handler.clone(), None, None, perms)
1940            .await
1941            .unwrap();
1942        assert_eq!(bus.handler_count().await, 1);
1943
1944        bus.unsubscribe_all("removable").await;
1945        assert_eq!(bus.handler_count().await, 0);
1946    }
1947
1948    #[tokio::test]
1949    async fn subscriptions_for_lists_only_matching_extension() {
1950        let bus = HookBus::new();
1951        let alpha = TestHandler::new("alpha", HookResult::Continue);
1952        let beta = TestHandler::new("beta", HookResult::Continue);
1953        let perms = perms_with(&[Permission::ToolsIntercept]);
1954
1955        bus.subscribe(
1956            HookKind::BeforeToolCall,
1957            alpha.clone(),
1958            Some("bash".into()),
1959            None,
1960            perms.clone(),
1961        )
1962        .await
1963        .unwrap();
1964        bus.subscribe(
1965            HookKind::AfterToolCall,
1966            alpha.clone(),
1967            None,
1968            None,
1969            perms.clone(),
1970        )
1971        .await
1972        .unwrap();
1973        bus.subscribe(HookKind::BeforeToolCall, beta.clone(), None, None, perms)
1974            .await
1975            .unwrap();
1976
1977        let alpha_subs = bus.subscriptions_for("alpha").await;
1978        assert_eq!(alpha_subs.len(), 2);
1979        // sorted by kind name then by tool_filter (None first)
1980        assert_eq!(alpha_subs[0].0, HookKind::AfterToolCall);
1981        assert_eq!(alpha_subs[0].1, None);
1982        assert_eq!(alpha_subs[1].0, HookKind::BeforeToolCall);
1983        assert_eq!(alpha_subs[1].1, Some("bash".to_string()));
1984
1985        let beta_subs = bus.subscriptions_for("beta").await;
1986        assert_eq!(beta_subs, vec![(HookKind::BeforeToolCall, None)]);
1987
1988        let none_subs = bus.subscriptions_for("ghost").await;
1989        assert!(none_subs.is_empty());
1990    }
1991
1992    #[tokio::test]
1993    async fn is_empty_reflects_state() {
1994        let bus = HookBus::new();
1995        assert!(bus.is_empty().await);
1996
1997        let handler = TestHandler::new("ext", HookResult::Continue);
1998        let perms = perms_with(&[Permission::ToolsIntercept]);
1999        bus.subscribe(HookKind::BeforeToolCall, handler, None, None, perms)
2000            .await
2001            .unwrap();
2002        assert!(!bus.is_empty().await);
2003    }
2004
2005    // ────────────────────────────────────────────────────────────────────────
2006    // compress-then-truncate ordering (Synaps Fork 1) — RED stub
2007    // ────────────────────────────────────────────────────────────────────────
2008
2009    #[cfg(feature = "command-exec")]
2010    #[derive(Debug)]
2011    struct RecordingHandler {
2012        id: String,
2013        recorded_len: Arc<AtomicUsize>,
2014        result: HookResult,
2015    }
2016    #[cfg(feature = "command-exec")]
2017    impl RecordingHandler {
2018        fn new(id: &str, result: HookResult) -> (Arc<Self>, Arc<AtomicUsize>) {
2019            let recorded_len = Arc::new(AtomicUsize::new(0));
2020            let h = Arc::new(Self {
2021                id: id.to_string(),
2022                recorded_len: recorded_len.clone(),
2023                result,
2024            });
2025            (h, recorded_len)
2026        }
2027    }
2028    #[cfg(feature = "command-exec")]
2029    #[async_trait]
2030    impl crate::extensions::runtime::ExtensionHandler for RecordingHandler {
2031        fn id(&self) -> &str {
2032            &self.id
2033        }
2034        async fn handle(&self, event: &HookEvent) -> HookResult {
2035            if let Some(out) = event.tool_output.as_ref() {
2036                self.recorded_len.store(out.len(), Ordering::Relaxed);
2037            }
2038            self.result.clone()
2039        }
2040        async fn shutdown(&self) {}
2041    }
2042
2043    /// Test A (RED stub, current signature) — proves the bug.
2044    #[cfg(feature = "command-exec")]
2045    #[tokio::test]
2046    async fn after_tool_call_transform_receives_full_output_not_pre_truncated() {
2047        use crate::tools::Tool;
2048        let bus = std::sync::Arc::new(HookBus::new());
2049        let (handler, recorded_len) = RecordingHandler::new("len-recorder", HookResult::Continue);
2050        bus.subscribe(
2051            HookKind::AfterToolCall,
2052            handler,
2053            None,
2054            None,
2055            perms_with(&[Permission::ToolsIntercept]),
2056        )
2057        .await
2058        .unwrap();
2059
2060        let ctx = crate::tools::ToolContext {
2061            channels: crate::tools::ToolChannels {
2062                tx_delta: None,
2063                tx_events: None,
2064            },
2065            capabilities: crate::tools::ToolCapabilities {
2066                session_allow_all: None,
2067                launch_cancel: None,
2068                memory_backend: None,
2069                telemetry: None,
2070                tool_register_tx: None,
2071                session_manager: None,
2072                subagent_registry: None,
2073                event_queue: None,
2074                delegation_parent: None,
2075                codex_parent_plan: None,
2076                secret_prompt: None,
2077                orchestration: None,
2078                tool_activation: None,
2079                mcp_leases: None,
2080                extension_leases: None,
2081                cwd: None,
2082                env: None,
2083                env_stripped: Vec::new(),
2084                env_warned: Default::default(),
2085                session_id: None,
2086                hook_bus: None,
2087                continuation: None,
2088            },
2089            limits: crate::tools::ToolLimits {
2090                max_tool_output: 30_000,
2091                max_tool_buffer: 256 * 1024,
2092                bash_timeout: 30,
2093                bash_max_timeout: 300,
2094                subagent_timeout: 300,
2095            },
2096        };
2097
2098        let bash = crate::tools::BashTool;
2099        let output = bash
2100            .execute(
2101                serde_json::json!({"command": "yes hello | head -c 150000"}),
2102                ctx,
2103            )
2104            .await
2105            .expect("bash should succeed");
2106
2107        let _ = crate::runtime::emit_after_tool_call(
2108            &bus,
2109            "bash",
2110            None,
2111            serde_json::json!({"command": "yes hello | head -c 150000"}),
2112            output,
2113            30_000,
2114            None,
2115            None,
2116        )
2117        .await;
2118
2119        let len = recorded_len.load(Ordering::Relaxed);
2120        assert!(
2121            len > 100_000,
2122            "after_tool_call handler was starved: received {len} bytes — \
2123             expected the full ~150KB buffered output (max_tool_buffer=256KB)"
2124        );
2125    }
2126
2127    /// Test B: when a transform Replaces with a >max_tool_output string, the
2128    /// FINAL emit_after_tool_call return is ≤ max_tool_output (+marker overhead).
2129    /// Proves compress-then-truncate ordering at the new site.
2130    #[tokio::test]
2131    #[serial_test::serial(synaps_base_dir)]
2132    async fn final_output_truncated_to_max_tool_output_after_hook() {
2133        // #186: Replace writes the intercept audit — isolate the base dir.
2134        let _base = crate::test_env::BaseDirGuard::new();
2135        let bus = std::sync::Arc::new(HookBus::new());
2136        let big: String = "x".repeat(100_000);
2137        let handler = TestHandler::new("bloater", HookResult::Replace { output: big });
2138        bus.subscribe(
2139            HookKind::AfterToolCall,
2140            handler,
2141            None,
2142            None,
2143            perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
2144        )
2145        .await
2146        .unwrap();
2147
2148        let result = crate::runtime::emit_after_tool_call(
2149            &bus,
2150            "bash",
2151            None,
2152            serde_json::json!({}),
2153            "tiny original".to_string(),
2154            30_000,
2155            None,
2156            None,
2157        )
2158        .await;
2159
2160        assert!(
2161            result.len() <= 30_000 + 200,
2162            "post-hook output should be capped at max_tool_output (got {} bytes)",
2163            result.len()
2164        );
2165    }
2166
2167    /// Test C: with NO transform, the final output is byte-identical to the
2168    /// legacy truncate_tool_result(...) result — behavior preservation invariant.
2169    #[tokio::test]
2170    async fn no_transform_extension_preserves_legacy_truncation() {
2171        let bus = std::sync::Arc::new(HookBus::new());
2172        let huge: String = "y".repeat(80_000);
2173
2174        let result = crate::runtime::emit_after_tool_call(
2175            &bus,
2176            "bash",
2177            None,
2178            serde_json::json!({}),
2179            huge.clone(),
2180            30_000,
2181            None,
2182            None,
2183        )
2184        .await;
2185
2186        let legacy = crate::runtime::helpers::HelperMethods::truncate_tool_result(&huge, 30_000);
2187        assert_eq!(
2188            result, legacy,
2189            "no-extension path must be byte-identical to legacy truncation"
2190        );
2191    }
2192
2193    /// Test D: emit_after_tool_call unit-level — Continue and Replace branches
2194    /// both truncate to max_tool_output, and truncation is char-boundary safe.
2195    #[tokio::test]
2196    #[serial_test::serial(synaps_base_dir)]
2197    async fn emit_after_tool_call_truncates_char_boundary_safe() {
2198        // #186: Replace writes the intercept audit — isolate the base dir.
2199        let _base = crate::test_env::BaseDirGuard::new();
2200        // Continue path with multibyte content.
2201        let bus = std::sync::Arc::new(HookBus::new());
2202        let multibyte: String = "héllo🌟".repeat(5_000);
2203        assert!(multibyte.len() > 30_000);
2204
2205        let result = crate::runtime::emit_after_tool_call(
2206            &bus,
2207            "bash",
2208            None,
2209            serde_json::json!({}),
2210            multibyte,
2211            30_000,
2212            None,
2213            None,
2214        )
2215        .await;
2216        assert!(result.is_char_boundary(result.len()));
2217
2218        // Replace path with multibyte content.
2219        let bus2 = std::sync::Arc::new(HookBus::new());
2220        let big_multi = "✨".repeat(20_000);
2221        assert!(big_multi.len() > 30_000);
2222        let handler = TestHandler::new("mb-replacer", HookResult::Replace { output: big_multi });
2223        bus2.subscribe(
2224            HookKind::AfterToolCall,
2225            handler,
2226            None,
2227            None,
2228            perms_with(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput]),
2229        )
2230        .await
2231        .unwrap();
2232
2233        let result2 = crate::runtime::emit_after_tool_call(
2234            &bus2,
2235            "bash",
2236            None,
2237            serde_json::json!({}),
2238            "orig".to_string(),
2239            30_000,
2240            None,
2241            None,
2242        )
2243        .await;
2244        assert!(result2.is_char_boundary(result2.len()));
2245    }
2246
2247    #[tokio::test]
2248    async fn keyed_injection_isolated_per_session() {
2249        let bus = HookBus::new();
2250        bus.set_session_injection_for("s1", "one".into()).await;
2251        bus.set_session_injection_for("s2", "two".into()).await;
2252        assert_eq!(bus.session_injection_for("s1").await.as_deref(), Some("one"));
2253        assert_eq!(bus.session_injection_for("s2").await.as_deref(), Some("two"));
2254        assert_eq!(bus.session_injection_for("s3").await, None);
2255    }
2256
2257    #[tokio::test]
2258    async fn clear_removes_only_that_session() {
2259        let bus = HookBus::new();
2260        bus.set_session_injection_for("s1", "one".into()).await;
2261        bus.set_session_injection_for("s2", "two".into()).await;
2262        bus.clear_session_injection("s1").await;
2263        assert_eq!(bus.session_injection_for("s1").await, None);
2264        assert_eq!(bus.session_injection_for("s2").await.as_deref(), Some("two"));
2265    }
2266
2267    #[tokio::test]
2268    #[allow(deprecated)]
2269    async fn deprecated_shims_use_empty_key() {
2270        let bus = HookBus::new();
2271        bus.set_session_injection("legacy".into()).await;
2272        assert_eq!(bus.session_injection().await.as_deref(), Some("legacy"));
2273        assert_eq!(bus.session_injection_for("").await.as_deref(), Some("legacy"));
2274        assert_eq!(bus.session_injection_for("s1").await, None);
2275    }
2276}
2277
2278/// Subagent workers' hook view ([`HookBus::worker_transforms`]): only the
2279/// host's `after_tool_call` output transforms, live, dispatched without
2280/// ever (re)starting an extension.
2281#[cfg(test)]
2282mod worker_view_tests {
2283    use super::*;
2284    use crate::extensions::permissions::Permission;
2285    use async_trait::async_trait;
2286    use std::sync::atomic::{AtomicUsize, Ordering};
2287
2288    /// Counts `handle` / `handle_no_spawn` calls separately. `result` is
2289    /// what both return; `tag_output` instead replaces with
2290    /// `"<tag>:<tool_output>"` (per-call, so concurrent callers can tell
2291    /// their replacements apart). `delay` sleeps first.
2292    struct Probe {
2293        id: String,
2294        result: HookResult,
2295        tag_output: Option<String>,
2296        delay: Duration,
2297        handled: AtomicUsize,
2298        handled_no_spawn: AtomicUsize,
2299    }
2300
2301    impl Probe {
2302        fn build(id: &str, result: HookResult, tag: Option<&str>, delay: Duration) -> Arc<Self> {
2303            Arc::new(Self {
2304                id: id.into(),
2305                result,
2306                tag_output: tag.map(str::to_string),
2307                delay,
2308                handled: AtomicUsize::new(0),
2309                handled_no_spawn: AtomicUsize::new(0),
2310            })
2311        }
2312
2313        fn new(id: &str, result: HookResult) -> Arc<Self> {
2314            Self::build(id, result, None, Duration::ZERO)
2315        }
2316
2317        fn tagging(id: &str, tag: &str, delay: Duration) -> Arc<Self> {
2318            Self::build(id, HookResult::Continue, Some(tag), delay)
2319        }
2320
2321        fn calls(&self) -> (usize, usize) {
2322            (
2323                self.handled.load(Ordering::SeqCst),
2324                self.handled_no_spawn.load(Ordering::SeqCst),
2325            )
2326        }
2327
2328        async fn answer(&self, event: &HookEvent) -> HookResult {
2329            if !self.delay.is_zero() {
2330                tokio::time::sleep(self.delay).await;
2331            }
2332            match &self.tag_output {
2333                Some(tag) => HookResult::Replace {
2334                    output: format!("{tag}:{}", event.tool_output.as_deref().unwrap_or("")),
2335                },
2336                None => self.result.clone(),
2337            }
2338        }
2339    }
2340
2341    #[async_trait]
2342    impl crate::extensions::runtime::ExtensionHandler for Probe {
2343        fn id(&self) -> &str {
2344            &self.id
2345        }
2346        async fn handle(&self, event: &HookEvent) -> HookResult {
2347            self.handled.fetch_add(1, Ordering::SeqCst);
2348            self.answer(event).await
2349        }
2350        async fn handle_no_spawn(&self, event: &HookEvent) -> HookResult {
2351            self.handled_no_spawn.fetch_add(1, Ordering::SeqCst);
2352            self.answer(event).await
2353        }
2354        async fn shutdown(&self) {}
2355    }
2356
2357    fn perms(perms: &[Permission]) -> PermissionSet {
2358        let mut set = PermissionSet::new();
2359        for p in perms {
2360            set.grant(*p);
2361        }
2362        set
2363    }
2364
2365    fn transform() -> PermissionSet {
2366        perms(&[Permission::ToolsIntercept, Permission::ToolsTransformOutput])
2367    }
2368
2369    async fn after(bus: &Arc<HookBus>, output: &str) -> String {
2370        crate::runtime::emit_after_tool_call(
2371            bus,
2372            "bash",
2373            None,
2374            serde_json::json!({"command": "cat big.log"}),
2375            output.to_string(),
2376            30_000,
2377            None,
2378            None,
2379        )
2380        .await
2381    }
2382
2383    /// The host bus a worker views: a confirm and a block on
2384    /// before_tool_call, an inject on before_message, an observe-only
2385    /// after_tool_call, and one real output transform.
2386    async fn host_with_every_kind() -> (Arc<HookBus>, Vec<Arc<Probe>>, Arc<Probe>) {
2387        let host = Arc::new(HookBus::new());
2388        let confirm = Probe::new(
2389            "confirmer",
2390            HookResult::Confirm {
2391                message: "really?".into(),
2392                sources: vec![],
2393                generations: vec![],
2394                tool: None,
2395                modified_input: None,
2396                modified_by: vec![],
2397            },
2398        );
2399        let block = Probe::new("blocker", HookResult::Block { reason: "no".into() });
2400        let inject = Probe::new("injector", HookResult::Inject { content: "ctx".into() });
2401        let observer = Probe::new("observer", HookResult::Replace { output: "SNEAKY".into() });
2402        let crush = Probe::new("crush", HookResult::Replace { output: "CRUSHED".into() });
2403        let intercept = perms(&[Permission::ToolsIntercept]);
2404        host.subscribe(HookKind::BeforeToolCall, confirm.clone(), None, None, intercept.clone())
2405            .await
2406            .unwrap();
2407        host.subscribe(HookKind::BeforeToolCall, block.clone(), None, None, transform())
2408            .await
2409            .unwrap();
2410        host.subscribe(
2411            HookKind::BeforeMessage,
2412            inject.clone(),
2413            None,
2414            None,
2415            perms(&[Permission::LlmContent, Permission::ToolsTransformOutput]),
2416        )
2417        .await
2418        .unwrap();
2419        // Observe-only first, so on the host it is reached before `crush`.
2420        host.subscribe(HookKind::AfterToolCall, observer.clone(), None, None, intercept)
2421            .await
2422            .unwrap();
2423        host.subscribe(HookKind::AfterToolCall, crush.clone(), None, None, transform())
2424            .await
2425            .unwrap();
2426        (host, vec![confirm, block, inject, observer], crush)
2427    }
2428
2429    #[tokio::test]
2430    #[serial_test::serial(synaps_base_dir)]
2431    async fn a_worker_runs_only_the_hosts_after_tool_call_transforms() {
2432        let _base = crate::test_env::BaseDirGuard::new();
2433        let (host, others, crush) = host_with_every_kind().await;
2434        let worker = HookBus::worker_transforms(&host);
2435        assert!(worker.is_worker_view() && !host.is_worker_view());
2436
2437        // The worker's bash result is replaced by the transform...
2438        assert_eq!(after(&worker, "RAW 10k lines").await, "CRUSHED");
2439        assert_eq!(crush.calls(), (0, 1), "through handle_no_spawn");
2440        // ...and nothing else fires: no confirm prompt, no block, no inject.
2441        let before = crate::runtime::emit_before_tool_call(
2442            &worker,
2443            "bash",
2444            None,
2445            serde_json::json!({"command": "rm -rf /"}),
2446            None,
2447            None,
2448        )
2449        .await;
2450        assert_eq!(before, HookResult::Continue);
2451        let decision = crate::runtime::resolve_before_tool_call_decision(
2452            serde_json::json!({"command": "rm -rf /"}),
2453            before,
2454            None,
2455            false,
2456            None,
2457        )
2458        .await;
2459        assert!(matches!(decision, crate::runtime::BeforeToolCallDecision::Continue { .. }));
2460        for kind in [HookKind::BeforeMessage, HookKind::OnMessageComplete, HookKind::OnSessionStart] {
2461            let mut event = HookEvent::after_tool_call("bash", serde_json::json!({}), "x".into());
2462            event.kind = kind;
2463            assert_eq!(worker.emit(&event).await, HookResult::Continue, "{kind:?}");
2464        }
2465        for probe in &others {
2466            assert_eq!(probe.calls(), (0, 0), "{} never reached from a worker", probe.id);
2467        }
2468        assert_eq!(worker.handler_count().await, 1, "the one transform");
2469        assert!(!worker.is_empty().await);
2470
2471        // The foreground (host bus) is unchanged: observer first, then crush.
2472        assert_eq!(after(&host, "RAW").await, "CRUSHED");
2473        assert_eq!(others[3].calls(), (1, 0));
2474        assert_eq!(crush.calls(), (1, 1));
2475    }
2476
2477    /// Two-key gate on the worker path: an after_tool_call subscriber
2478    /// without `tools.transform_output` is not even dispatched, so it can
2479    /// neither replace nor observe a worker's output.
2480    #[tokio::test]
2481    async fn an_extension_without_transform_output_cannot_replace_a_workers_output() {
2482        let host = Arc::new(HookBus::new());
2483        let observer = Probe::new("observer", HookResult::Replace { output: "SNEAKY".into() });
2484        host.subscribe(
2485            HookKind::AfterToolCall,
2486            observer.clone(),
2487            None,
2488            None,
2489            perms(&[Permission::ToolsIntercept]),
2490        )
2491        .await
2492        .unwrap();
2493        let worker = HookBus::worker_transforms(&host);
2494        assert_eq!(after(&worker, "RAW").await, "RAW");
2495        assert_eq!(observer.calls(), (0, 0));
2496        assert!(worker.is_empty().await);
2497        // The foreground still ignores its Replace (the existing gate).
2498        assert_eq!(after(&host, "RAW").await, "RAW");
2499        assert_eq!(observer.calls(), (1, 0));
2500    }
2501
2502    /// Fail-safe: a transform that times out (5 s) or answers `Continue`
2503    /// (what a crashed or not-running extension answers) leaves the
2504    /// original output, and the chain moves on to the next transform.
2505    #[tokio::test(start_paused = true)]
2506    #[serial_test::serial(synaps_base_dir)]
2507    async fn a_timed_out_or_crashed_transform_leaves_the_original_output() {
2508        let _base = crate::test_env::BaseDirGuard::new();
2509        let host = Arc::new(HookBus::new());
2510        let slow = Probe::tagging("slow", "SLOW", Duration::from_secs(60));
2511        host.subscribe(HookKind::AfterToolCall, slow.clone(), None, None, transform())
2512            .await
2513            .unwrap();
2514        let worker = HookBus::worker_transforms(&host);
2515        let started = tokio::time::Instant::now();
2516        assert_eq!(after(&worker, "RAW").await, "RAW", "timed out → original");
2517        let waited = started.elapsed();
2518        assert!(
2519            waited >= HANDLER_TIMEOUT && waited < HANDLER_TIMEOUT + Duration::from_secs(1),
2520            "bounded by the hook timeout: {waited:?}"
2521        );
2522
2523        let crashed = Probe::new("crashed", HookResult::Continue);
2524        let next = Probe::tagging("next", "NEXT", Duration::ZERO);
2525        let host = Arc::new(HookBus::new());
2526        host.subscribe(HookKind::AfterToolCall, crashed.clone(), None, None, transform())
2527            .await
2528            .unwrap();
2529        let worker = HookBus::worker_transforms(&host);
2530        assert_eq!(after(&worker, "RAW").await, "RAW", "crashed → original");
2531        host.subscribe(HookKind::AfterToolCall, next.clone(), None, None, transform())
2532            .await
2533            .unwrap();
2534        assert_eq!(after(&worker, "RAW").await, "NEXT:RAW", "the chain continues");
2535    }
2536
2537    /// Live view, not a snapshot: a worker created before an extension
2538    /// loads, reloads or unloads sees each change on its next call, and
2539    /// never calls a stopped instance.
2540    #[tokio::test]
2541    #[serial_test::serial(synaps_base_dir)]
2542    async fn a_worker_sees_extension_loads_reloads_and_unloads_live() {
2543        let _base = crate::test_env::BaseDirGuard::new();
2544        let host = Arc::new(HookBus::new());
2545        let worker = HookBus::worker_transforms(&host);
2546        assert_eq!(after(&worker, "RAW").await, "RAW", "nothing loaded yet");
2547
2548        let v1 = Probe::tagging("crush", "V1", Duration::ZERO);
2549        host.subscribe(HookKind::AfterToolCall, v1.clone(), None, None, transform())
2550            .await
2551            .unwrap();
2552        assert_eq!(after(&worker, "RAW").await, "V1:RAW", "loaded after the worker started");
2553
2554        // Reload = unsubscribe_all + subscribe the new instance.
2555        host.unsubscribe_all("crush").await;
2556        assert_eq!(after(&worker, "RAW").await, "RAW", "mid-reload: pass-through");
2557        let v2 = Probe::tagging("crush", "V2", Duration::ZERO);
2558        host.subscribe(HookKind::AfterToolCall, v2.clone(), None, None, transform())
2559            .await
2560            .unwrap();
2561        assert_eq!(after(&worker, "RAW").await, "V2:RAW");
2562        assert_eq!(v1.calls(), (0, 1), "the stopped instance is never called again");
2563
2564        host.unsubscribe_all("crush").await;
2565        assert_eq!(after(&worker, "RAW").await, "RAW", "unloaded");
2566    }
2567
2568    /// Parallel workers, each on its own OS thread and current-thread
2569    /// runtime (as subagents run), share one handler and each get the
2570    /// replacement of their own output.
2571    #[test]
2572    #[serial_test::serial(synaps_base_dir)]
2573    fn parallel_workers_each_get_their_own_replacement() {
2574        let _base = crate::test_env::BaseDirGuard::new();
2575        let crush = Probe::tagging("crush", "CRUSHED", Duration::from_millis(2));
2576        let host = Arc::new(HookBus::new());
2577        tokio::runtime::Builder::new_current_thread()
2578            .enable_all()
2579            .build()
2580            .unwrap()
2581            .block_on(host.subscribe(HookKind::AfterToolCall, crush.clone(), None, None, transform()))
2582            .unwrap();
2583        let threads: Vec<_> = (0..8)
2584            .map(|w| {
2585                let worker = HookBus::worker_transforms(&host);
2586                std::thread::spawn(move || {
2587                    let rt = tokio::runtime::Builder::new_current_thread()
2588                        .enable_all()
2589                        .build()
2590                        .unwrap();
2591                    rt.block_on(async {
2592                        for call in 0..10 {
2593                            let output = format!("worker {w} call {call}");
2594                            assert_eq!(after(&worker, &output).await, format!("CRUSHED:{output}"));
2595                        }
2596                    });
2597                })
2598            })
2599            .collect();
2600        for t in threads {
2601            t.join().expect("worker thread");
2602        }
2603        assert_eq!(crush.calls(), (0, 80));
2604    }
2605
2606    #[tokio::test]
2607    async fn a_view_is_read_only_and_a_view_of_a_view_reads_the_host() {
2608        let host = Arc::new(HookBus::new());
2609        let worker = HookBus::worker_transforms(&host);
2610        let probe = Probe::tagging("crush", "C", Duration::ZERO);
2611        let err = worker
2612            .subscribe(HookKind::AfterToolCall, probe.clone(), None, None, transform())
2613            .await
2614            .unwrap_err();
2615        assert!(err.contains("worker"), "{err}");
2616        let nested = HookBus::worker_transforms(&worker);
2617        assert!(nested.is_worker_view());
2618        assert!(Arc::ptr_eq(nested.transforms_of.as_ref().unwrap(), &host));
2619        assert_eq!(nested.handler_count().await, 0);
2620    }
2621}
2622
2623/// A hook subscriber for tests of code that sends notify-only events.
2624#[cfg(test)]
2625pub(crate) mod test_support {
2626    use super::*;
2627    use async_trait::async_trait;
2628    use std::sync::atomic::{AtomicUsize, Ordering};
2629
2630    /// Records the events it is sent and counts the calls it was entered
2631    /// for and the ones it answered. `delay` is slept first
2632    /// (`Duration::MAX`: never answers); its first `panics` calls panic
2633    /// instead of answering.
2634    pub(crate) struct Listener {
2635        pub(crate) id: String,
2636        delay: Duration,
2637        panics: usize,
2638        result: HookResult,
2639        seen: std::sync::Mutex<Vec<HookEvent>>,
2640        answered: AtomicUsize,
2641    }
2642
2643    impl Listener {
2644        fn build(id: &str, delay: Duration, panics: usize, result: HookResult) -> Arc<Self> {
2645            Arc::new(Self {
2646                id: id.into(),
2647                delay,
2648                panics,
2649                result,
2650                seen: Default::default(),
2651                answered: AtomicUsize::new(0),
2652            })
2653        }
2654        pub(crate) fn answering(id: &str, result: HookResult) -> Arc<Self> {
2655            Self::build(id, Duration::ZERO, 0, result)
2656        }
2657        pub(crate) fn prompt(id: &str) -> Arc<Self> {
2658            Self::answering(id, HookResult::Continue)
2659        }
2660        pub(crate) fn slow(id: &str, delay: Duration) -> Arc<Self> {
2661            Self::build(id, delay, 0, HookResult::Continue)
2662        }
2663        pub(crate) fn hanging(id: &str) -> Arc<Self> {
2664            Self::slow(id, Duration::MAX)
2665        }
2666        pub(crate) fn panicking(id: &str) -> Arc<Self> {
2667            Self::build(id, Duration::ZERO, usize::MAX, HookResult::Continue)
2668        }
2669        /// Panics on the first event it is sent and answers every later one.
2670        pub(crate) fn panicking_once(id: &str) -> Arc<Self> {
2671            Self::build(id, Duration::ZERO, 1, HookResult::Continue)
2672        }
2673
2674        /// Subscribe to `kind` on `bus`, holding only the permission that
2675        /// kind requires.
2676        pub(crate) async fn subscribe(self: &Arc<Self>, bus: &HookBus, kind: HookKind) {
2677            let mut permissions = PermissionSet::new();
2678            permissions.grant(kind.required_permission());
2679            bus.subscribe(kind, self.clone(), None, None, permissions)
2680                .await
2681                .unwrap();
2682        }
2683
2684        /// Every event this listener was entered for, in arrival order.
2685        pub(crate) fn events(&self) -> Vec<HookEvent> {
2686            self.seen.lock().unwrap().clone()
2687        }
2688
2689        /// (entered, answered)
2690        pub(crate) fn calls(&self) -> (usize, usize) {
2691            (self.seen.lock().unwrap().len(), self.answered.load(Ordering::SeqCst))
2692        }
2693
2694        /// The first `count` events, once that many have arrived. Deliveries
2695        /// are detached, so a test has to wait for them; 30 s without them
2696        /// is a failure.
2697        pub(crate) async fn wait_for(&self, count: usize) -> Vec<HookEvent> {
2698            let waited = tokio::time::timeout(Duration::from_secs(30), async {
2699                while self.seen.lock().unwrap().len() < count {
2700                    tokio::time::sleep(Duration::from_millis(5)).await;
2701                }
2702            })
2703            .await;
2704            let events = self.events();
2705            assert!(waited.is_ok(), "{}: expected {count} events, got {events:?}", self.id);
2706            events
2707        }
2708    }
2709
2710    #[async_trait]
2711    impl crate::extensions::runtime::ExtensionHandler for Listener {
2712        fn id(&self) -> &str {
2713            &self.id
2714        }
2715        async fn handle(&self, event: &HookEvent) -> HookResult {
2716            let call = {
2717                let mut seen = self.seen.lock().unwrap();
2718                seen.push(event.clone());
2719                seen.len()
2720            };
2721            if self.delay == Duration::MAX {
2722                std::future::pending::<()>().await;
2723            }
2724            if !self.delay.is_zero() {
2725                tokio::time::sleep(self.delay).await;
2726            }
2727            if call <= self.panics {
2728                panic!("notify test: this handler panics on purpose");
2729            }
2730            self.answered.fetch_add(1, Ordering::SeqCst);
2731            self.result.clone()
2732        }
2733        async fn shutdown(&self) {}
2734    }
2735}
2736
2737/// [`HookBus::notify`]: fire and forget. The caller never waits, every
2738/// answer is discarded, and the detached delivery is bounded in time
2739/// ([`HANDLER_TIMEOUT`]) and in number ([`NOTIFY_MAX_IN_FLIGHT`]). Time is
2740/// Tokio's paused clock, so "did not wait" is exact and cannot flake.
2741#[cfg(test)]
2742mod notify_tests {
2743    use super::test_support::Listener;
2744    use super::*;
2745    use crate::extensions::permissions::Permission;
2746    use std::sync::atomic::{AtomicBool, Ordering};
2747
2748    const KIND: HookKind = HookKind::OnContextArchived;
2749
2750    fn event() -> HookEvent {
2751        HookEvent::on_context_archived("chat", &"a".repeat(32), None, "p0", 1, 1, 2)
2752    }
2753
2754    async fn bus_with(listeners: &[&Arc<Listener>]) -> Arc<HookBus> {
2755        let bus = Arc::new(HookBus::new());
2756        for listener in listeners {
2757            listener.subscribe(&bus, KIND).await;
2758        }
2759        bus
2760    }
2761
2762    /// Let the detached deliveries run, without reaching any handler timer.
2763    async fn settle() {
2764        tokio::time::sleep(Duration::from_millis(1)).await;
2765    }
2766
2767    #[tokio::test(start_paused = true)]
2768    async fn notify_returns_before_a_hanging_handler() {
2769        let slow = Listener::slow("slow", Duration::from_secs(60));
2770        let bus = bus_with(&[&slow]).await;
2771        let (virtual_start, real_start) = (tokio::time::Instant::now(), Instant::now());
2772        bus.notify(event());
2773        // Not one tick of the paused clock: nothing was awaited. The real
2774        // bound is loose on purpose; waiting would cost the 5 s timeout.
2775        assert_eq!(virtual_start.elapsed(), Duration::ZERO);
2776        assert!(real_start.elapsed() < Duration::from_secs(1));
2777        assert_eq!(slow.calls(), (0, 0), "the handler runs on the detached task");
2778        assert_eq!(bus.notifies_in_flight(), 1);
2779
2780        // The delivery is still waiting just before the handler timeout...
2781        tokio::time::sleep(HANDLER_TIMEOUT - Duration::from_millis(1)).await;
2782        assert_eq!(slow.calls(), (1, 0));
2783        assert_eq!(bus.notifies_in_flight(), 1);
2784        // ...and gone just after it: the task ended, the handler never answered.
2785        tokio::time::sleep(Duration::from_millis(2)).await;
2786        assert_eq!(bus.notifies_in_flight(), 0);
2787        assert_eq!(slow.calls(), (1, 0));
2788    }
2789
2790    /// The panicking handler is first in line: its panic used to end the
2791    /// task before the healthy one was called at all.
2792    #[tokio::test(start_paused = true)]
2793    async fn notify_survives_a_panicking_handler() {
2794        let (bad, good) = (Listener::panicking("bad"), Listener::prompt("good"));
2795        let bus = bus_with(&[&bad, &good]).await;
2796        bus.notify(event());
2797        settle().await;
2798        assert_eq!(bad.calls(), (1, 0));
2799        assert_eq!(good.calls(), (1, 1), "the healthy listener got the event");
2800        assert_eq!(bus.notifies_in_flight(), 0, "the panic released its slot");
2801        // The bus still delivers, here and on its awaited paths.
2802        bus.unsubscribe_all("bad").await;
2803        bus.notify(event());
2804        settle().await;
2805        assert!(good.calls().1 >= 1, "{:?}", good.calls());
2806        assert_eq!(bus.emit(&HookEvent::before_message("hi")).await, HookResult::Continue);
2807        assert_eq!(bus.notifies_in_flight(), 0);
2808    }
2809
2810    #[tokio::test(start_paused = true)]
2811    async fn notify_ignores_block_and_inject_results() {
2812        let listeners = [
2813            Listener::answering("block", HookResult::Block { reason: "no".into() }),
2814            Listener::answering("inject", HookResult::Inject { content: "ctx".into() }),
2815            Listener::answering("replace", HookResult::Replace { output: "x".into() }),
2816            Listener::answering("confirm", HookResult::confirm("sure?")),
2817            Listener::answering("phase", HookResult::ContextPhase { phase: "new_task".into() }),
2818            Listener::prompt("plain"),
2819        ];
2820        let bus = bus_with(&listeners.iter().collect::<Vec<_>>()).await;
2821        bus.notify(event());
2822        settle().await;
2823        // Nobody's answer stopped the others, and none had any effect.
2824        for listener in &listeners {
2825            assert_eq!(listener.calls(), (1, 1), "{}", listener.id);
2826        }
2827        assert_eq!(bus.session_injection_for("chat").await, None);
2828        assert_eq!(bus.notifies_in_flight(), 0);
2829    }
2830
2831    #[tokio::test(start_paused = true)]
2832    async fn notify_with_no_subscribers_is_a_no_op() {
2833        // A subscriber to another kind only.
2834        let other = Listener::prompt("other");
2835        let bus = Arc::new(HookBus::new());
2836        other.subscribe(&bus, HookKind::BeforeMessage).await;
2837        bus.notify(event());
2838        assert_eq!(bus.notifies_in_flight(), 0, "no task was spawned");
2839        // An event that costs something to build is not even built.
2840        let built = Arc::new(AtomicBool::new(false));
2841        let flag = built.clone();
2842        bus.notify_with(KIND, async move {
2843            flag.store(true, Ordering::SeqCst);
2844            Some(event())
2845        });
2846        assert_eq!(bus.notifies_in_flight(), 0);
2847        settle().await;
2848        assert!(!built.load(Ordering::SeqCst));
2849        assert_eq!(other.calls(), (0, 0));
2850    }
2851
2852    #[tokio::test(start_paused = true)]
2853    async fn notify_on_worker_view_sends_nothing() {
2854        let listener = Listener::prompt("listener");
2855        let host = bus_with(&[&listener]).await;
2856        let worker = HookBus::worker_transforms(&host);
2857        worker.notify(event());
2858        settle().await;
2859        assert_eq!(listener.calls(), (0, 0));
2860        assert_eq!((worker.notifies_in_flight(), host.notifies_in_flight()), (0, 0));
2861        // The same event on the host bus is delivered.
2862        host.notify(event());
2863        settle().await;
2864        assert_eq!(listener.calls(), (1, 1));
2865    }
2866
2867    #[tokio::test(start_paused = true)]
2868    async fn notify_reaches_every_subscriber_concurrently() {
2869        // The slow one is first in line; `emit` would make the fast one wait.
2870        let slow = Listener::slow("slow", Duration::from_secs(3));
2871        let fast = Listener::prompt("fast");
2872        let bus = bus_with(&[&slow, &fast]).await;
2873        bus.notify(event());
2874        settle().await;
2875        assert_eq!(fast.calls(), (1, 1), "answered without waiting for the slow one");
2876        assert_eq!(slow.calls(), (1, 0));
2877        tokio::time::sleep(Duration::from_secs(3)).await;
2878        assert_eq!(slow.calls(), (1, 1));
2879        assert_eq!(bus.notifies_in_flight(), 0);
2880    }
2881
2882    /// Handlers that never answer while events keep coming: at most
2883    /// `NOTIFY_MAX_IN_FLIGHT` deliveries exist, the rest are dropped rather
2884    /// than queued, and the bus delivers again once the timeout clears them.
2885    #[tokio::test(start_paused = true)]
2886    async fn notify_drops_events_beyond_the_in_flight_limit() {
2887        let stuck = Listener::hanging("stuck");
2888        let bus = bus_with(&[&stuck]).await;
2889        for _ in 0..NOTIFY_MAX_IN_FLIGHT + 10 {
2890            bus.notify(event());
2891        }
2892        assert_eq!(bus.notifies_in_flight(), NOTIFY_MAX_IN_FLIGHT);
2893        settle().await;
2894        assert_eq!(stuck.calls(), (NOTIFY_MAX_IN_FLIGHT, 0), "the extra ten were dropped");
2895        tokio::time::sleep(HANDLER_TIMEOUT).await;
2896        assert_eq!(bus.notifies_in_flight(), 0, "every delivery ended at the timeout");
2897        bus.notify(event());
2898        settle().await;
2899        assert_eq!(stuck.calls(), (NOTIFY_MAX_IN_FLIGHT + 1, 0));
2900    }
2901
2902    /// An event built on the detached task: the caller does not wait for the
2903    /// build either, and a build that yields nothing sends nothing.
2904    #[tokio::test(start_paused = true)]
2905    async fn notify_with_builds_the_event_off_the_callers_path() {
2906        let listener = Listener::prompt("listener");
2907        let bus = bus_with(&[&listener]).await;
2908        let started = tokio::time::Instant::now();
2909        bus.notify_with(KIND, async {
2910            tokio::time::sleep(Duration::from_secs(10)).await;
2911            Some(event())
2912        });
2913        assert_eq!(started.elapsed(), Duration::ZERO);
2914        settle().await;
2915        assert_eq!(listener.calls(), (0, 0), "still being built");
2916        tokio::time::sleep(Duration::from_secs(10)).await;
2917        assert_eq!(listener.calls(), (1, 1));
2918
2919        bus.notify_with(KIND, async { None });
2920        // An event of another kind than the one announced is not sent.
2921        bus.notify_with(KIND, async { Some(HookEvent::on_session_start("chat")) });
2922        settle().await;
2923        assert_eq!(listener.calls(), (1, 1));
2924        assert_eq!(bus.notifies_in_flight(), 0);
2925    }
2926
2927    /// Several events from one build are one delivery: one slot, sent to a
2928    /// handler in order. More of them than the in-flight limit lose none.
2929    #[tokio::test(start_paused = true)]
2930    async fn notify_with_sends_several_events_in_order_as_one_delivery() {
2931        let numbered = |n: usize| part(n, NOTIFY_MAX_IN_FLIGHT + 6);
2932        let listener = Listener::prompt("listener");
2933        let bus = bus_with(&[&listener]).await;
2934        let count = NOTIFY_MAX_IN_FLIGHT + 6;
2935        bus.notify_with(KIND, async move {
2936            let mut events: Vec<_> = (1..=count).map(numbered).collect();
2937            // One of another kind in the middle is skipped, not a stop.
2938            events.insert(3, HookEvent::on_session_start("chat"));
2939            events
2940        });
2941        assert_eq!(bus.notifies_in_flight(), 1);
2942        settle().await;
2943        assert_eq!(parts_seen(&listener), (1..=count as u64).collect::<Vec<_>>());
2944        assert_eq!(bus.notifies_in_flight(), 0);
2945
2946        // A handler that never answers: the delivery ends at the first
2947        // event's timeout, and the second is not sent.
2948        let hung = Listener::hanging("hung");
2949        let bus = bus_with(&[&hung]).await;
2950        bus.notify_with(KIND, async move { vec![numbered(1), numbered(2)] });
2951        settle().await;
2952        assert_eq!(parts_seen(&hung), [1]);
2953        assert_eq!(bus.notifies_in_flight(), 1);
2954        tokio::time::sleep(HANDLER_TIMEOUT).await;
2955        settle().await;
2956        assert_eq!(bus.notifies_in_flight(), 0);
2957        assert_eq!(hung.calls(), (1, 0));
2958    }
2959
2960    fn part(n: usize, of: usize) -> HookEvent {
2961        event().with_part(n, of, 0, 0)
2962    }
2963
2964    fn parts_seen(listener: &Listener) -> Vec<u64> {
2965        let events = listener.events();
2966        events.iter().map(|event| event.data["part"].as_u64().unwrap()).collect()
2967    }
2968
2969    /// Two handlers, the first of which never answers. The healthy one used
2970    /// to get each later part only when the stuck one's 5 s for the part
2971    /// before had run out, so the last of `n` parts up to 5·(n-1) s late. It
2972    /// now has them all at once, in order.
2973    ///
2974    /// The stuck one is sent the first part and no other. It used to be sent
2975    /// the next at each timeout, so an extension that was only slow had `n`
2976    /// requests in its input after 5·n s and was busy for `n` times its own
2977    /// time, with every other hook sent to it (a `before_tool_call`) timing
2978    /// out meanwhile. Its backlog is now one event whatever `n` is, and the
2979    /// delivery's slot is free after one timeout.
2980    #[tokio::test(start_paused = true)]
2981    async fn a_stuck_handler_does_not_delay_another_handlers_parts() {
2982        const PARTS: usize = 4;
2983        let (stuck, healthy) = (Listener::hanging("stuck"), Listener::prompt("healthy"));
2984        let bus = bus_with(&[&stuck, &healthy]).await;
2985        bus.notify_with(KIND, async { (1..=PARTS).map(|n| part(n, PARTS)).collect::<Vec<_>>() });
2986        settle().await;
2987        assert_eq!(parts_seen(&healthy), [1, 2, 3, 4], "none waited for the stuck handler");
2988        assert_eq!(healthy.calls(), (PARTS, PARTS));
2989        assert_eq!(parts_seen(&stuck), [1]);
2990        assert_eq!(bus.notifies_in_flight(), 1);
2991
2992        // The stuck handler's sequence ends at its first timeout, and the
2993        // delivery with it: parts 2 to 4 are never sent to it.
2994        tokio::time::sleep(HANDLER_TIMEOUT).await;
2995        settle().await;
2996        assert_eq!(bus.notifies_in_flight(), 0, "one timeout, not one per part");
2997        assert_eq!(parts_seen(&stuck), [1]);
2998        tokio::time::sleep(HANDLER_TIMEOUT * PARTS as u32).await;
2999        assert_eq!((stuck.calls(), healthy.calls()), ((1, 0), (PARTS, PARTS)));
3000
3001        // A handler that is slow but answers in time is sent every part.
3002        let slow = Listener::slow("slow", HANDLER_TIMEOUT - Duration::from_millis(1));
3003        let bus = bus_with(&[&slow]).await;
3004        bus.notify_with(KIND, async { (1..=PARTS).map(|n| part(n, PARTS)).collect::<Vec<_>>() });
3005        tokio::time::sleep(HANDLER_TIMEOUT * PARTS as u32).await;
3006        assert_eq!(slow.calls(), (PARTS, PARTS));
3007        assert_eq!(bus.notifies_in_flight(), 0);
3008    }
3009
3010    /// A handler that panics on part 1 of several. It used to end the
3011    /// delivery task: no handler got parts 2 and 3, and the one behind it in
3012    /// line did not get part 1 either. The panic now costs that handler that
3013    /// event.
3014    #[tokio::test(start_paused = true)]
3015    async fn a_panic_on_the_first_part_loses_only_that_part_for_that_handler() {
3016        let (bad, good) = (Listener::panicking_once("bad"), Listener::prompt("good"));
3017        let bus = bus_with(&[&bad, &good]).await;
3018        bus.notify_with(KIND, async { (1..=3).map(|n| part(n, 3)).collect::<Vec<_>>() });
3019        settle().await;
3020        assert_eq!(parts_seen(&good), [1, 2, 3]);
3021        assert_eq!(good.calls(), (3, 3));
3022        assert_eq!(parts_seen(&bad), [1, 2, 3], "it is sent the parts after the panic");
3023        assert_eq!(bad.calls(), (3, 2), "and answers them");
3024        assert_eq!(bus.notifies_in_flight(), 0);
3025
3026        // A handler that panics on every part costs the other nothing.
3027        let (bad, good) = (Listener::panicking("bad"), Listener::prompt("good"));
3028        let bus = bus_with(&[&bad, &good]).await;
3029        bus.notify_with(KIND, async { (1..=3).map(|n| part(n, 3)).collect::<Vec<_>>() });
3030        settle().await;
3031        assert_eq!((parts_seen(&good), good.calls()), (vec![1, 2, 3], (3, 3)));
3032        assert_eq!(bad.calls(), (3, 0));
3033        assert_eq!(bus.notifies_in_flight(), 0);
3034    }
3035
3036    /// A hook whose answer matters cannot be sent this way.
3037    #[tokio::test(start_paused = true)]
3038    async fn notify_refuses_a_hook_that_is_not_notify_only() {
3039        let listener = Listener::answering("guard", HookResult::Block { reason: "no".into() });
3040        let bus = Arc::new(HookBus::new());
3041        listener.subscribe(&bus, HookKind::BeforeToolCall).await;
3042        bus.notify(HookEvent::before_tool_call("bash", serde_json::json!({})));
3043        settle().await;
3044        assert_eq!(listener.calls(), (0, 0));
3045        assert_eq!(bus.notifies_in_flight(), 0);
3046    }
3047
3048    /// No runtime to deliver on (a synchronous caller): nothing is sent and
3049    /// nothing panics.
3050    #[test]
3051    fn notify_outside_a_runtime_sends_nothing() {
3052        let listener = Listener::prompt("listener");
3053        let bus = tokio::runtime::Builder::new_current_thread()
3054            .build()
3055            .unwrap()
3056            .block_on(bus_with(&[&listener]));
3057        bus.notify(event());
3058        assert_eq!(bus.notifies_in_flight(), 0);
3059        assert_eq!(listener.calls(), (0, 0));
3060    }
3061
3062    /// The path leads to conversation content: `privacy.llm_content`.
3063    #[tokio::test]
3064    async fn on_context_archived_needs_llm_content_to_subscribe() {
3065        let bus = HookBus::new();
3066        let mut perms = PermissionSet::new();
3067        perms.grant(Permission::SessionLifecycle);
3068        let err = bus
3069            .subscribe(KIND, Listener::prompt("nosy"), None, None, perms.clone())
3070            .await
3071            .unwrap_err();
3072        assert!(err.contains("privacy.llm_content"), "{err}");
3073        perms.grant(Permission::LlmContent);
3074        bus.subscribe(KIND, Listener::prompt("ok"), None, None, perms)
3075            .await
3076            .unwrap();
3077    }
3078
3079    /// Ids only, no content: `session.lifecycle`, and nothing more.
3080    #[tokio::test(start_paused = true)]
3081    async fn on_memory_forgotten_needs_session_lifecycle_and_is_delivered_by_notify() {
3082        let forgotten = HookKind::OnMemoryForgotten;
3083        let bus = Arc::new(HookBus::new());
3084        let mut perms = PermissionSet::new();
3085        perms.grant(Permission::LlmContent);
3086        let err = bus
3087            .subscribe(forgotten, Listener::prompt("nosy"), None, None, perms)
3088            .await
3089            .unwrap_err();
3090        assert!(err.contains("session.lifecycle"), "{err}");
3091
3092        let listener = Listener::prompt("indexer");
3093        listener.subscribe(&bus, forgotten).await;
3094        bus.notify(HookEvent::on_memory_forgotten(Some("chat"), "mem-1", "note", "p0", "repository"));
3095        settle().await;
3096        assert_eq!(listener.calls(), (1, 1));
3097        assert_eq!(listener.events()[0].data["id"], "mem-1");
3098    }
3099}