Skip to main content

agent_engine/extensions/hooks/
events.rs

1//! Hook events — typed payloads for each extension point.
2//!
3//! Each [`HookKind`] maps to a discrete phase of SynapsCLI's execution loop.
4//! [`HookEvent`] is the concrete payload dispatched to subscribers at that
5//! phase; [`HookResult`] is what a handler returns to control execution flow.
6//!
7//! Permission enforcement lives in [`crate::extensions::permissions`]:
8//! every `HookKind` declares a [`required_permission`][HookKind::required_permission]
9//! so the runtime can gate subscriptions before any payload is delivered.
10
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use crate::extensions::permissions::Permission;
15
16/// Maximum hook output size (bytes) before truncation on the JSON-RPC path.
17/// Matches `max_tool_buffer` (256 KB) — keeps the payload bounded while still
18/// giving transform extensions the full buffered output to work with.
19/// Exported so tests can build payloads that derive from the real cap and
20/// won't rot when the constant changes.
21pub const MAX_HOOK_OUTPUT: usize = 256 * 1024; // 256 KB
22
23// ── HookKind ──────────────────────────────────────────────────────────────────
24
25/// All hook event kinds in the phase-1 catalog.
26///
27/// Each variant identifies a well-defined extension point in the agent loop.
28/// The set is intentionally closed; new kinds are added via a breaking version
29/// bump so existing permission grants stay coherent.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum HookKind {
33    /// Fires immediately before a tool is invoked. Handlers may block the call.
34    BeforeToolCall,
35    /// Fires immediately after a tool returns. Handlers receive the output.
36    AfterToolCall,
37    /// Fires before an LLM message is sent. Handlers may inspect or block.
38    BeforeMessage,
39    /// Fires after an assistant response is completed and added to history.
40    OnMessageComplete,
41    /// Fires after conversation compaction creates a replacement session.
42    OnCompaction,
43    /// Fires when a new session is created.
44    OnSessionStart,
45    /// Fires when a session is torn down.
46    OnSessionEnd,
47}
48
49impl HookKind {
50    /// Every hook kind, in declaration order.
51    ///
52    /// Exhaustiveness is enforced by `all_covers_every_variant` in this
53    /// module's tests. This list is what `docs/extensions/contract.json` is
54    /// drift-checked against (see
55    /// `crates/agent-tui/tests/extensions_contract.rs`).
56    pub const ALL: &'static [HookKind] = &[
57        Self::BeforeToolCall,
58        Self::AfterToolCall,
59        Self::BeforeMessage,
60        Self::OnMessageComplete,
61        Self::OnCompaction,
62        Self::OnSessionStart,
63        Self::OnSessionEnd,
64    ];
65
66    /// Canonical string identifier for this kind, suitable for serialization
67    /// keys, log output, and manifest declarations.
68    pub fn as_str(&self) -> &'static str {
69        match self {
70            Self::BeforeToolCall => "before_tool_call",
71            Self::AfterToolCall => "after_tool_call",
72            Self::BeforeMessage => "before_message",
73            Self::OnMessageComplete => "on_message_complete",
74            Self::OnCompaction => "on_compaction",
75            Self::OnSessionStart => "on_session_start",
76            Self::OnSessionEnd => "on_session_end",
77        }
78    }
79
80    /// Parse from the canonical string representation.
81    ///
82    /// Returns `None` for unrecognised strings so callers can surface
83    /// a manifest validation error rather than silently dropping hooks.
84    #[allow(clippy::should_implement_trait)]
85    pub fn from_str(s: &str) -> Option<Self> {
86        match s {
87            "before_tool_call" => Some(Self::BeforeToolCall),
88            "after_tool_call" => Some(Self::AfterToolCall),
89            "before_message" => Some(Self::BeforeMessage),
90            "on_message_complete" => Some(Self::OnMessageComplete),
91            "on_compaction" => Some(Self::OnCompaction),
92            "on_session_start" => Some(Self::OnSessionStart),
93            "on_session_end" => Some(Self::OnSessionEnd),
94            _ => None,
95        }
96    }
97
98    /// Supported action names for this hook in the extension contract.
99    pub fn allowed_action_names(&self) -> &'static [&'static str] {
100        match self {
101            Self::BeforeToolCall => &["continue", "block", "confirm", "modify"],
102            Self::AfterToolCall => &["continue", "replace"],
103            Self::BeforeMessage => &["continue", "inject"],
104            // Session-scoped context. Injected ONCE when the session starts
105            // and carried on the system prompt for the rest of it, rather
106            // than re-appended on every message the way `before_message`
107            // injection is.
108            Self::OnSessionStart => &["continue", "inject"],
109            // Advisory work-phase report for the host's automatic context
110            // management (same semantics as the model's own
111            // `context_checkpoint` tool without a note). Never capacity or
112            // permission authority.
113            Self::OnMessageComplete => &["continue", "context_phase"],
114            Self::OnCompaction | Self::OnSessionEnd => &["continue"],
115        }
116    }
117
118    /// Whether this hook can be filtered by tool name in a manifest.
119    pub fn allows_tool_filter(&self) -> bool {
120        matches!(self, Self::BeforeToolCall | Self::AfterToolCall)
121    }
122
123    /// Whether this hook accepts a handler result action.
124    pub fn allows_result(&self, result: &HookResult) -> bool {
125        matches!(
126            (self, result),
127            (_, HookResult::Continue)
128                | (Self::BeforeToolCall, HookResult::Block { .. })
129                | (Self::BeforeToolCall, HookResult::Confirm { .. })
130                | (Self::BeforeToolCall, HookResult::Modify { .. })
131                | (Self::AfterToolCall, HookResult::Replace { .. })
132                | (Self::BeforeMessage, HookResult::Inject { .. })
133                | (Self::OnSessionStart, HookResult::Inject { .. })
134                | (Self::OnMessageComplete, HookResult::ContextPhase { .. })
135        )
136    }
137
138    /// The [`Permission`] an extension must hold to subscribe to this hook.
139    ///
140    /// Called by the permission gate before delivering any event; if the
141    /// extension's [`PermissionSet`][crate::extensions::permissions::PermissionSet]
142    /// does not include this permission, `HookBus::subscribe()` returns an error.
143    pub fn required_permission(&self) -> Permission {
144        match self {
145            Self::BeforeToolCall | Self::AfterToolCall => Permission::ToolsIntercept,
146            Self::BeforeMessage | Self::OnMessageComplete | Self::OnCompaction => {
147                Permission::LlmContent
148            }
149            Self::OnSessionStart | Self::OnSessionEnd => Permission::SessionLifecycle,
150        }
151    }
152}
153
154// ── HookEvent ─────────────────────────────────────────────────────────────────
155
156/// `SYNAPS_HOOK_SESSION_ID=0` disables the `session_id` field on tool/message
157/// hook events (they emit `null`, as before daemon-mode phase 2).
158pub fn hook_session_id_enabled() -> bool {
159    !matches!(
160        std::env::var("SYNAPS_HOOK_SESSION_ID").as_deref(),
161        Ok("0") | Ok("false") | Ok("off")
162    )
163}
164
165/// A hook event payload dispatched to extension handlers.
166///
167/// Fields are optional and populated only when relevant to the hook kind:
168///
169/// | Kind                  | tool_name | tool_input | tool_output | message | session_id |
170/// |-----------------------|-----------|------------|-------------|---------|------------|
171/// | `before_tool_call`    | ✓         | ✓          |             |         | ✓*         |
172/// | `after_tool_call`     | ✓         | ✓          | ✓           |         | ✓*         |
173/// | `before_message`      |           |            |             | ✓       | ✓*         |
174/// | `on_message_complete` |           |            |             | ✓       | ✓*         |
175/// | `on_compaction`       |           |            |             | ✓       | ✓          |
176/// | `on_session_start`    |           |            |             |         | ✓          |
177/// | `on_session_end`      |           |            |             |         | ✓          |
178///
179/// `✓*` — set via [`HookEvent::with_session`] for foreground sessions; `null`
180/// for workers (no conversation id) and when `SYNAPS_HOOK_SESSION_ID=0`.
181///
182/// The `data` field is available on all events for extensions that need to
183/// attach arbitrary structured context when constructing synthetic events.
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct HookEvent {
186    /// Which hook fired.
187    pub kind: HookKind,
188    /// Tool name for tool-specific hooks; `None` for general hooks.
189    /// This is the API-safe name (sanitized for the LLM).
190    pub tool_name: Option<String>,
191    /// Original runtime name of the tool (before API sanitization).
192    /// Extension authors typically write runtime names in their manifests.
193    #[serde(default)]
194    pub tool_runtime_name: Option<String>,
195    /// Tool input arguments for `before_tool_call` and `after_tool_call`.
196    pub tool_input: Option<Value>,
197    /// Tool output for `after_tool_call`.
198    pub tool_output: Option<String>,
199    /// LLM message content for `before_message`.
200    pub message: Option<String>,
201    /// Session identifier for session lifecycle hooks.
202    pub session_id: Option<String>,
203    /// Session message history for `on_session_end`.
204    /// Contains the conversation transcript so extensions (like Stelline)
205    /// can extract memories without reaching into runtime internals.
206    #[serde(default)]
207    pub transcript: Option<Vec<crate::SharedMessage>>,
208    /// Arbitrary extension-defined data, passed through without inspection.
209    pub data: Value,
210}
211
212impl HookEvent {
213    /// Attach the owning conversation id to a tool/message event (builder).
214    ///
215    /// Additive: the JSON field already exists (`session_id`), so extensions
216    /// that ignore it are unaffected. Workers pass `None` and keep emitting
217    /// `null`. Kill-switch `SYNAPS_HOOK_SESSION_ID=0` forces `null` for a
218    /// misbehaving plugin.
219    pub fn with_session(mut self, id: Option<&str>) -> Self {
220        self.session_id = if hook_session_id_enabled() {
221            id.map(str::to_string)
222        } else {
223            None
224        };
225        self
226    }
227
228    /// Construct a `before_tool_call` event.
229    pub fn before_tool_call(tool_name: &str, input: Value) -> Self {
230        Self {
231            kind: HookKind::BeforeToolCall,
232            tool_name: Some(tool_name.to_string()),
233            tool_input: Some(input),
234            tool_output: None,
235            message: None,
236            session_id: None,
237            tool_runtime_name: None,
238            transcript: None,
239            data: Value::Null,
240        }
241    }
242
243    /// Construct an `after_tool_call` event carrying both input and output.
244    /// Output is truncated to MAX_HOOK_OUTPUT to bound the JSON-RPC payload
245    /// to extensions. This cap matches the runtime's `max_tool_buffer`
246    /// (the OOM safety net) so transform extensions see the FULL buffered
247    /// tool output and can decide what to keep — the final context-budget
248    /// truncation to `max_tool_output` happens AFTER the hook in
249    /// `emit_after_tool_call` (compress-then-truncate).
250    pub fn after_tool_call(tool_name: &str, input: Value, output: String) -> Self {
251        let bounded = agent_core::BoundedText::new(&output, MAX_HOOK_OUTPUT);
252        let truncated_output = if bounded.truncated {
253            format!(
254                "{}…[truncated, {} total bytes]",
255                bounded.text, bounded.original_bytes
256            )
257        } else {
258            output
259        };
260        Self {
261            kind: HookKind::AfterToolCall,
262            tool_name: Some(tool_name.to_string()),
263            tool_input: Some(input),
264            tool_output: Some(truncated_output),
265            message: None,
266            session_id: None,
267            tool_runtime_name: None,
268            transcript: None,
269            data: Value::Null,
270        }
271    }
272
273    /// Construct a `before_message` event.
274    pub fn before_message(message: &str) -> Self {
275        Self {
276            kind: HookKind::BeforeMessage,
277            tool_name: None,
278            tool_input: None,
279            tool_output: None,
280            message: Some(message.to_string()),
281            session_id: None,
282            tool_runtime_name: None,
283            transcript: None,
284            data: Value::Null,
285        }
286    }
287
288    /// Construct an `on_message_complete` event.
289    pub fn on_message_complete(message: &str, data: Value) -> Self {
290        Self {
291            kind: HookKind::OnMessageComplete,
292            tool_name: None,
293            tool_input: None,
294            tool_output: None,
295            message: Some(message.to_string()),
296            session_id: None,
297            tool_runtime_name: None,
298            transcript: None,
299            data,
300        }
301    }
302
303    /// Construct an `on_compaction` event.
304    pub fn on_compaction(
305        old_session_id: &str,
306        new_session_id: &str,
307        summary: &str,
308        message_count: usize,
309        mut data: Value,
310    ) -> Self {
311        if !data.is_object() {
312            data = Value::Object(Default::default());
313        }
314        if let Some(object) = data.as_object_mut() {
315            object.insert(
316                "old_session_id".to_string(),
317                Value::String(old_session_id.to_string()),
318            );
319            object.insert(
320                "new_session_id".to_string(),
321                Value::String(new_session_id.to_string()),
322            );
323            object.insert(
324                "message_count".to_string(),
325                Value::Number(message_count.into()),
326            );
327        }
328        Self {
329            kind: HookKind::OnCompaction,
330            tool_name: None,
331            tool_input: None,
332            tool_output: None,
333            message: Some(summary.to_string()),
334            session_id: Some(new_session_id.to_string()),
335            tool_runtime_name: None,
336            transcript: None,
337            data,
338        }
339    }
340
341    /// Construct an `on_session_start` event.
342    pub fn on_session_start(session_id: &str) -> Self {
343        Self {
344            kind: HookKind::OnSessionStart,
345            tool_name: None,
346            tool_input: None,
347            tool_output: None,
348            message: None,
349            session_id: Some(session_id.to_string()),
350            tool_runtime_name: None,
351            transcript: None,
352            data: Value::Null,
353        }
354    }
355
356    /// Construct an `on_session_end` event.
357    pub fn on_session_end(session_id: &str, transcript: Option<Vec<crate::SharedMessage>>) -> Self {
358        Self {
359            kind: HookKind::OnSessionEnd,
360            tool_name: None,
361            tool_input: None,
362            tool_output: None,
363            message: None,
364            session_id: Some(session_id.to_string()),
365            tool_runtime_name: None,
366            transcript,
367            data: Value::Null,
368        }
369    }
370}
371
372// ── HookResult ────────────────────────────────────────────────────────────────
373
374/// What an extension handler returns after processing a hook event.
375///
376/// The runtime resolves multiple handlers by precedence:
377/// - For `before_tool_call` only `Block` stops the handler chain. `Modify`
378///   rewrites the input that later handlers see; `Confirm` is merged and
379///   resolved after the whole chain (a `Modify` after it is ignored).
380/// - `Inject` results are accumulated for `before_message`.
381/// - `Continue` is the no-op default — processing continues normally.
382#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
383#[serde(tag = "action", rename_all = "snake_case")]
384pub enum HookResult {
385    /// Allow execution to proceed unchanged.
386    #[default]
387    Continue,
388    /// Prevent the hooked operation. The `reason` is surfaced to the user.
389    Block { reason: String },
390    /// Inject context — the extension provides text for the current request.
391    /// Placement depends on the hook (#297): `on_session_start` content is
392    /// appended to the system prompt (session-stable, cache-safe);
393    /// `before_message` content is attached to the newest user message on the
394    /// outgoing request as an ephemeral trailing block positioned AFTER the
395    /// conversational cache marker (per-turn, uncached tail, cache-neutral).
396    Inject { content: String },
397    /// Ask the runtime to get explicit user confirmation before proceeding.
398    /// Only valid on before_tool_call hooks.
399    ///
400    /// `sources` / `tool` are host-stamped by `HookBus::emit` (the extension
401    /// id(s) that asked, and the tool name). They are never read from or
402    /// written to the extension wire format, so an extension cannot claim to
403    /// be another extension. The "Allow all" latch is keyed by `sources`.
404    Confirm {
405        message: String,
406        #[serde(skip)]
407        sources: Vec<String>,
408        /// Host-set load generation of each source (parallel to `sources`;
409        /// missing entries mean 0). Part of the latch key, so a plugin
410        /// reinstalled under the same id starts unlatched.
411        #[serde(skip)]
412        generations: Vec<u64>,
413        #[serde(skip)]
414        tool: Option<String>,
415        /// Host-set: the input as rewritten by `Modify` results earlier in
416        /// the chain. Approval runs THIS input (never extension-settable).
417        #[serde(skip)]
418        modified_input: Option<Value>,
419        /// Host-set: ids of the extensions whose `Modify` produced
420        /// `modified_input` (chain order, de-duplicated). Shown in the
421        /// prompt so the user knows the input was rewritten, and by whom.
422        #[serde(skip)]
423        modified_by: Vec<String>,
424    },
425    /// Replace the tool **input** before execution (structured JSON args).
426    /// Only valid on before_tool_call hooks. The output-side counterpart is
427    /// [`HookResult::Replace`]. Does not stop the chain: later handlers see
428    /// (and may block or confirm) the modified input.
429    Modify { input: Value },
430    /// Replace the tool **output** after execution (flat string), before it
431    /// enters history. Only valid on after_tool_call hooks. The input-side
432    /// counterpart is [`HookResult::Modify`] — distinct name because the payload
433    /// is an unstructured `String`, not structured `Value`. Enables native
434    /// output transforms (compression, redaction, summarization) by extensions.
435    /// The first transform stops the handler chain.
436    Replace { output: String },
437    /// Advisory work-phase report for the host's automatic context
438    /// management. Only valid on `on_message_complete`, and only honoured
439    /// when the extension additionally holds `session.lifecycle`. Semantics
440    /// are identical to the model calling `context_checkpoint` with this
441    /// phase and no note: the host may roll the context window over at the
442    /// next task boundary. It is a lower-authority report — never capacity,
443    /// permission, or history authority — and does not stop the handler
444    /// chain (other observers still run; the first valid report wins).
445    /// `phase` is parsed host-side with `WorkPhase::parse`; unknown strings
446    /// are ignored with a warning.
447    ContextPhase { phase: String },
448}
449
450impl HookResult {
451    /// An unattributed `Confirm` (as an extension returns it, before
452    /// `HookBus::emit` stamps the source).
453    pub fn confirm(message: impl Into<String>) -> Self {
454        Self::Confirm {
455            message: message.into(),
456            sources: Vec::new(),
457            generations: Vec::new(),
458            tool: None,
459            modified_input: None,
460            modified_by: vec![],
461        }
462    }
463}
464
465// ── Tests ─────────────────────────────────────────────────────────────────────
466
467impl HookEvent {
468    /// Set the runtime name for tool-related events.
469    pub fn with_runtime_name(mut self, name: &str) -> Self {
470        self.tool_runtime_name = Some(name.to_string());
471        self
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use serde_json::json;
479
480    /// The extension wire format `{"action":"replace","output":"..."}`
481    /// deserializes to HookResult::Replace via the serde action tag — and
482    /// round-trips back. This locks the contract a transform extension speaks.
483    #[test]
484    fn replace_action_roundtrips_extension_wire_format() {
485        // Inbound: what a process extension sends on hook.handle.
486        let wire = json!({"action": "replace", "output": "compressed"});
487        let parsed: HookResult = serde_json::from_value(wire).expect("must deserialize");
488        match parsed {
489            HookResult::Replace { output } => assert_eq!(output, "compressed"),
490            other => panic!("expected Replace, got {other:?}"),
491        }
492
493        // Outbound: the action tag is exactly "replace".
494        let out = serde_json::to_value(HookResult::Replace { output: "x".into() }).unwrap();
495        assert_eq!(out["action"], "replace");
496        assert_eq!(out["output"], "x");
497    }
498
499    /// The extension wire format `{"action":"context_phase","phase":"..."}`
500    /// deserializes to HookResult::ContextPhase and round-trips back.
501    #[test]
502    fn context_phase_action_roundtrips_extension_wire_format() {
503        let wire = json!({"action": "context_phase", "phase": "new_task"});
504        let parsed: HookResult = serde_json::from_value(wire).expect("must deserialize");
505        match &parsed {
506            HookResult::ContextPhase { phase } => assert_eq!(phase, "new_task"),
507            other => panic!("expected ContextPhase, got {other:?}"),
508        }
509
510        let out = serde_json::to_value(&parsed).unwrap();
511        assert_eq!(out, json!({"action": "context_phase", "phase": "new_task"}));
512        let back: HookResult = serde_json::from_value(out).unwrap();
513        assert_eq!(back, parsed);
514
515        // A missing `phase` is a malformed result, not a silent default.
516        assert!(serde_json::from_value::<HookResult>(json!({"action": "context_phase"})).is_err());
517    }
518
519    /// on_message_complete may return ContextPhase; no other hook may, and
520    /// on_message_complete advertises "context_phase" in its action contract.
521    #[test]
522    fn only_on_message_complete_allows_context_phase() {
523        let report = HookResult::ContextPhase {
524            phase: "execute".into(),
525        };
526        for kind in HookKind::ALL {
527            assert_eq!(
528                kind.allows_result(&report),
529                *kind == HookKind::OnMessageComplete,
530                "context_phase must be allowed only on on_message_complete (checked {})",
531                kind.as_str()
532            );
533        }
534        assert!(HookKind::OnMessageComplete
535            .allowed_action_names()
536            .contains(&"context_phase"));
537        // Still observe-only for everything else.
538        assert!(
539            !HookKind::OnMessageComplete.allows_result(&HookResult::Block { reason: "r".into() })
540        );
541        assert!(
542            !HookKind::OnMessageComplete.allows_result(&HookResult::Inject {
543                content: "c".into()
544            })
545        );
546    }
547
548    // ── HookKind ──────────────────────────────────────────────────────────────
549
550    /// after_tool_call may return Replace; other hooks may not, and
551    /// after_tool_call advertises "replace" in its action contract.
552    #[test]
553    fn after_tool_call_allows_replace_output() {
554        let replace = HookResult::Replace {
555            output: "compressed".into(),
556        };
557
558        // The transform seam: after_tool_call accepts Replace.
559        assert!(
560            HookKind::AfterToolCall.allows_result(&replace),
561            "after_tool_call must permit Replace to enable output transforms"
562        );
563
564        // Replace is scoped strictly to after_tool_call.
565        assert!(
566            !HookKind::BeforeToolCall.allows_result(&replace),
567            "before_tool_call must not permit Replace (input-only)"
568        );
569        assert!(
570            !HookKind::BeforeMessage.allows_result(&replace),
571            "before_message must not permit Replace"
572        );
573
574        // The extension-facing action contract advertises "replace".
575        assert!(
576            HookKind::AfterToolCall
577                .allowed_action_names()
578                .contains(&"replace"),
579            "after_tool_call must advertise the 'replace' action"
580        );
581    }
582
583    /// Every variant's as_str round-trips through from_str.
584    #[test]
585    fn hook_kind_as_str_roundtrip() {
586        let all = [
587            HookKind::BeforeToolCall,
588            HookKind::AfterToolCall,
589            HookKind::BeforeMessage,
590            HookKind::OnMessageComplete,
591            HookKind::OnCompaction,
592            HookKind::OnSessionStart,
593            HookKind::OnSessionEnd,
594        ];
595        for kind in all {
596            let s = kind.as_str();
597            assert_eq!(
598                HookKind::from_str(s),
599                Some(kind),
600                "round-trip failed for {s}"
601            );
602        }
603    }
604
605    /// Unknown strings return None, not a panic or a default.
606    #[test]
607    fn hook_kind_from_str_unknown_returns_none() {
608        assert_eq!(HookKind::from_str(""), None);
609        assert_eq!(HookKind::from_str("BeforeToolCall"), None); // wrong case
610        assert_eq!(HookKind::from_str("on_crash"), None);
611    }
612
613    /// Serde uses snake_case via the attribute — spot-check two variants.
614    #[test]
615    fn hook_kind_serde_snake_case() {
616        let serialized = serde_json::to_string(&HookKind::BeforeToolCall).unwrap();
617        assert_eq!(serialized, r#""before_tool_call""#);
618
619        let back: HookKind = serde_json::from_str(r#""on_session_end""#).unwrap();
620        assert_eq!(back, HookKind::OnSessionEnd);
621    }
622
623    /// Each kind maps to the expected permission.
624    #[test]
625    fn hook_kind_required_permission() {
626        assert_eq!(
627            HookKind::BeforeToolCall.required_permission(),
628            Permission::ToolsIntercept
629        );
630        assert_eq!(
631            HookKind::AfterToolCall.required_permission(),
632            Permission::ToolsIntercept
633        );
634        assert_eq!(
635            HookKind::BeforeMessage.required_permission(),
636            Permission::LlmContent
637        );
638        assert_eq!(
639            HookKind::OnMessageComplete.required_permission(),
640            Permission::LlmContent
641        );
642        assert_eq!(
643            HookKind::OnCompaction.required_permission(),
644            Permission::LlmContent
645        );
646        assert_eq!(
647            HookKind::OnSessionStart.required_permission(),
648            Permission::SessionLifecycle
649        );
650        assert_eq!(
651            HookKind::OnSessionEnd.required_permission(),
652            Permission::SessionLifecycle
653        );
654    }
655
656    // ── HookEvent constructors ────────────────────────────────────────────────
657
658    #[test]
659    fn hook_event_before_tool_call() {
660        let input = json!({"path": "/tmp/foo"});
661        let ev = HookEvent::before_tool_call("read_file", input.clone());
662
663        assert_eq!(ev.kind, HookKind::BeforeToolCall);
664        assert_eq!(ev.tool_name.as_deref(), Some("read_file"));
665        assert_eq!(ev.tool_input.as_ref(), Some(&input));
666        assert!(ev.tool_output.is_none());
667        assert!(ev.message.is_none());
668        assert!(ev.session_id.is_none());
669        assert_eq!(ev.data, Value::Null);
670    }
671
672    #[test]
673    fn hook_event_after_tool_call() {
674        let input = json!({"query": "select 1"});
675        let ev = HookEvent::after_tool_call("sql_query", input.clone(), "1 row".to_string());
676
677        assert_eq!(ev.kind, HookKind::AfterToolCall);
678        assert_eq!(ev.tool_name.as_deref(), Some("sql_query"));
679        assert_eq!(ev.tool_input.as_ref(), Some(&input));
680        assert_eq!(ev.tool_output.as_deref(), Some("1 row"));
681        assert!(ev.message.is_none());
682        assert!(ev.session_id.is_none());
683    }
684
685    #[test]
686    fn hook_event_before_message() {
687        let ev = HookEvent::before_message("Hello, LLM");
688
689        assert_eq!(ev.kind, HookKind::BeforeMessage);
690        assert!(ev.tool_name.is_none());
691        assert!(ev.tool_input.is_none());
692        assert!(ev.tool_output.is_none());
693        assert_eq!(ev.message.as_deref(), Some("Hello, LLM"));
694        assert!(ev.session_id.is_none());
695    }
696
697    #[test]
698    fn hook_event_on_message_complete() {
699        let ev = HookEvent::on_message_complete("Done", json!({"content_block_count": 1}));
700
701        assert_eq!(ev.kind, HookKind::OnMessageComplete);
702        assert!(ev.tool_name.is_none());
703        assert!(ev.tool_input.is_none());
704        assert!(ev.tool_output.is_none());
705        assert_eq!(ev.message.as_deref(), Some("Done"));
706        assert_eq!(ev.data["content_block_count"], 1);
707        assert!(ev.session_id.is_none());
708    }
709
710    #[test]
711    fn hook_event_on_compaction() {
712        let ev = HookEvent::on_compaction(
713            "old-session",
714            "new-session",
715            "Summary",
716            7,
717            json!({"source": "manual"}),
718        );
719
720        assert_eq!(ev.kind, HookKind::OnCompaction);
721        assert_eq!(ev.message.as_deref(), Some("Summary"));
722        assert_eq!(ev.session_id.as_deref(), Some("new-session"));
723        assert_eq!(ev.data["old_session_id"], "old-session");
724        assert_eq!(ev.data["new_session_id"], "new-session");
725        assert_eq!(ev.data["message_count"], 7);
726        assert_eq!(ev.data["source"], "manual");
727        assert!(ev.transcript.is_none());
728    }
729
730    #[test]
731    fn hook_event_on_session_start() {
732        let ev = HookEvent::on_session_start("sess-abc-123");
733
734        assert_eq!(ev.kind, HookKind::OnSessionStart);
735        assert_eq!(ev.session_id.as_deref(), Some("sess-abc-123"));
736        assert!(ev.tool_name.is_none());
737        assert!(ev.message.is_none());
738    }
739
740    #[test]
741    fn hook_event_on_session_end() {
742        let ev = HookEvent::on_session_end("sess-abc-123", None);
743
744        assert_eq!(ev.kind, HookKind::OnSessionEnd);
745        assert_eq!(ev.session_id.as_deref(), Some("sess-abc-123"));
746        assert!(ev.tool_name.is_none());
747        assert!(ev.message.is_none());
748    }
749
750    /// HookEvent is round-trippable through JSON without loss.
751    #[test]
752    fn hook_event_serde_roundtrip() {
753        let ev = HookEvent::before_tool_call("bash", json!({"cmd": "ls"}));
754        let json = serde_json::to_string(&ev).unwrap();
755        let back: HookEvent = serde_json::from_str(&json).unwrap();
756
757        assert_eq!(back.kind, ev.kind);
758        assert_eq!(back.tool_name, ev.tool_name);
759        assert_eq!(back.tool_input, ev.tool_input);
760    }
761
762    // ── HookResult ────────────────────────────────────────────────────────────
763
764    /// Default is Continue.
765    #[test]
766    fn hook_result_default_is_continue() {
767        assert!(matches!(HookResult::default(), HookResult::Continue));
768    }
769
770    /// Block carries its reason through serialization.
771    #[test]
772    fn hook_result_block_serde() {
773        let r = HookResult::Block {
774            reason: "denied by policy".to_string(),
775        };
776        let json = serde_json::to_string(&r).unwrap();
777        // `tag = "action"` means the JSON object has {"action":"block","reason":"..."}
778        assert!(json.contains(r#""action":"block""#));
779        assert!(json.contains("denied by policy"));
780
781        let back: HookResult = serde_json::from_str(&json).unwrap();
782        assert!(matches!(back, HookResult::Block { reason } if reason == "denied by policy"));
783    }
784
785    /// Confirm carries its message through serialization.
786    #[test]
787    fn hook_result_confirm_serde() {
788        let r = HookResult::Confirm {
789            message: "Run this command?".to_string(),
790            sources: vec!["host-stamped".into()],
791            generations: vec![3],
792            tool: Some("bash".into()),
793            modified_input: Some(serde_json::json!({"x": 1})),
794            modified_by: vec![],
795        };
796        let json = serde_json::to_string(&r).unwrap();
797        assert_eq!(
798            json,
799            r#"{"action":"confirm","message":"Run this command?"}"#
800        );
801
802        let back: HookResult = serde_json::from_str(&json).unwrap();
803        assert!(matches!(back, HookResult::Confirm { message, .. } if message == "Run this command?"));
804
805        // An extension cannot name a source: wire fields are ignored.
806        let spoof: HookResult = serde_json::from_str(
807            r#"{"action":"confirm","message":"m","sources":["trusted"],"tool":"x"}"#,
808        )
809        .unwrap();
810        assert!(
811            matches!(&spoof, HookResult::Confirm { sources, tool: None, modified_input: None, modified_by, .. } if sources.is_empty() && modified_by.is_empty()),
812            "{spoof:?}"
813        );
814    }
815
816    /// Modify carries replacement input through serialization.
817    #[test]
818    fn hook_result_modify_serde() {
819        let r = HookResult::Modify {
820            input: json!({"command": "echo safe"}),
821        };
822        let json = serde_json::to_string(&r).unwrap();
823        assert_eq!(
824            json,
825            r#"{"action":"modify","input":{"command":"echo safe"}}"#
826        );
827
828        let back: HookResult = serde_json::from_str(&json).unwrap();
829        assert!(
830            matches!(back, HookResult::Modify { input } if input == json!({"command": "echo safe"}))
831        );
832    }
833
834    /// Continue serialises as {"action":"continue"}.
835    #[test]
836    fn hook_result_continue_serde() {
837        let json = serde_json::to_string(&HookResult::Continue).unwrap();
838        assert_eq!(json, r#"{"action":"continue"}"#);
839    }
840
841    /// `HookKind::ALL` must list every variant exactly once. Adding a variant
842    /// makes the match below non-exhaustive => compile error => the author is
843    /// forced to update `ALL` and, via the contract test, contract.json.
844    #[test]
845    fn all_covers_every_variant() {
846        for kind in HookKind::ALL {
847            match kind {
848                HookKind::BeforeToolCall
849                | HookKind::AfterToolCall
850                | HookKind::BeforeMessage
851                | HookKind::OnMessageComplete
852                | HookKind::OnCompaction
853                | HookKind::OnSessionStart
854                | HookKind::OnSessionEnd => {}
855            }
856        }
857        assert_eq!(
858            HookKind::ALL.len(),
859            7,
860            "a HookKind variant was added or removed -- update HookKind::ALL \
861             and docs/extensions/contract.json to match"
862        );
863        let unique: std::collections::HashSet<HookKind> = HookKind::ALL.iter().copied().collect();
864        assert_eq!(
865            unique.len(),
866            HookKind::ALL.len(),
867            "HookKind::ALL has a duplicate"
868        );
869    }
870
871    /// Every hook kind must round-trip through its canonical wire string.
872    #[test]
873    fn all_hook_kinds_round_trip_through_wire_format() {
874        for kind in HookKind::ALL {
875            assert_eq!(
876                HookKind::from_str(kind.as_str()),
877                Some(*kind),
878                "{} does not round-trip",
879                kind.as_str()
880            );
881        }
882    }
883
884    /// `allowed_action_names` is the contract's action list, and
885    /// `allows_result` is what the hook bus actually enforces. They must
886    /// agree, or the documented action set is a lie in one direction or the
887    /// other. (#291: on_session_start advertised only "continue" while Axel
888    /// returned "inject" -- caught here now.)
889    #[test]
890    fn advertised_actions_match_enforced_actions() {
891        let samples: [(&str, HookResult); 7] = [
892            ("continue", HookResult::Continue),
893            ("block", HookResult::Block { reason: "r".into() }),
894            (
895                "confirm",
896                HookResult::Confirm {
897                    message: "m".into(),
898                    sources: vec![],
899                    generations: vec![],
900                    tool: None,
901                    modified_input: None,
902                    modified_by: vec![],
903                },
904            ),
905            (
906                "modify",
907                HookResult::Modify {
908                    input: serde_json::json!({}),
909                },
910            ),
911            ("replace", HookResult::Replace { output: "o".into() }),
912            (
913                "inject",
914                HookResult::Inject {
915                    content: "c".into(),
916                },
917            ),
918            (
919                "context_phase",
920                HookResult::ContextPhase {
921                    phase: "new_task".into(),
922                },
923            ),
924        ];
925        for kind in HookKind::ALL {
926            for (name, result) in &samples {
927                let advertised = kind.allowed_action_names().contains(name);
928                let enforced = kind.allows_result(result);
929                assert_eq!(
930                    advertised,
931                    enforced,
932                    "hook {} disagrees about action {:?}: advertised={} enforced={}",
933                    kind.as_str(),
934                    name,
935                    advertised,
936                    enforced
937                );
938            }
939        }
940    }
941}