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            Self::OnMessageComplete | Self::OnCompaction | Self::OnSessionEnd => &["continue"],
110        }
111    }
112
113    /// Whether this hook can be filtered by tool name in a manifest.
114    pub fn allows_tool_filter(&self) -> bool {
115        matches!(self, Self::BeforeToolCall | Self::AfterToolCall)
116    }
117
118    /// Whether this hook accepts a handler result action.
119    pub fn allows_result(&self, result: &HookResult) -> bool {
120        matches!(
121            (self, result),
122            (_, HookResult::Continue)
123                | (Self::BeforeToolCall, HookResult::Block { .. })
124                | (Self::BeforeToolCall, HookResult::Confirm { .. })
125                | (Self::BeforeToolCall, HookResult::Modify { .. })
126                | (Self::AfterToolCall, HookResult::Replace { .. })
127                | (Self::BeforeMessage, HookResult::Inject { .. })
128                | (Self::OnSessionStart, HookResult::Inject { .. })
129        )
130    }
131
132    /// The [`Permission`] an extension must hold to subscribe to this hook.
133    ///
134    /// Called by the permission gate before delivering any event; if the
135    /// extension's [`PermissionSet`][crate::extensions::permissions::PermissionSet]
136    /// does not include this permission, `HookBus::subscribe()` returns an error.
137    pub fn required_permission(&self) -> Permission {
138        match self {
139            Self::BeforeToolCall | Self::AfterToolCall => Permission::ToolsIntercept,
140            Self::BeforeMessage | Self::OnMessageComplete | Self::OnCompaction => {
141                Permission::LlmContent
142            }
143            Self::OnSessionStart | Self::OnSessionEnd => Permission::SessionLifecycle,
144        }
145    }
146}
147
148// ── HookEvent ─────────────────────────────────────────────────────────────────
149
150/// `SYNAPS_HOOK_SESSION_ID=0` disables the `session_id` field on tool/message
151/// hook events (they emit `null`, as before daemon-mode phase 2).
152pub fn hook_session_id_enabled() -> bool {
153    !matches!(
154        std::env::var("SYNAPS_HOOK_SESSION_ID").as_deref(),
155        Ok("0") | Ok("false") | Ok("off")
156    )
157}
158
159/// A hook event payload dispatched to extension handlers.
160///
161/// Fields are optional and populated only when relevant to the hook kind:
162///
163/// | Kind                  | tool_name | tool_input | tool_output | message | session_id |
164/// |-----------------------|-----------|------------|-------------|---------|------------|
165/// | `before_tool_call`    | ✓         | ✓          |             |         | ✓*         |
166/// | `after_tool_call`     | ✓         | ✓          | ✓           |         | ✓*         |
167/// | `before_message`      |           |            |             | ✓       | ✓*         |
168/// | `on_message_complete` |           |            |             | ✓       | ✓*         |
169/// | `on_compaction`       |           |            |             | ✓       | ✓          |
170/// | `on_session_start`    |           |            |             |         | ✓          |
171/// | `on_session_end`      |           |            |             |         | ✓          |
172///
173/// `✓*` — set via [`HookEvent::with_session`] for foreground sessions; `null`
174/// for workers (no conversation id) and when `SYNAPS_HOOK_SESSION_ID=0`.
175///
176/// The `data` field is available on all events for extensions that need to
177/// attach arbitrary structured context when constructing synthetic events.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct HookEvent {
180    /// Which hook fired.
181    pub kind: HookKind,
182    /// Tool name for tool-specific hooks; `None` for general hooks.
183    /// This is the API-safe name (sanitized for the LLM).
184    pub tool_name: Option<String>,
185    /// Original runtime name of the tool (before API sanitization).
186    /// Extension authors typically write runtime names in their manifests.
187    #[serde(default)]
188    pub tool_runtime_name: Option<String>,
189    /// Tool input arguments for `before_tool_call` and `after_tool_call`.
190    pub tool_input: Option<Value>,
191    /// Tool output for `after_tool_call`.
192    pub tool_output: Option<String>,
193    /// LLM message content for `before_message`.
194    pub message: Option<String>,
195    /// Session identifier for session lifecycle hooks.
196    pub session_id: Option<String>,
197    /// Session message history for `on_session_end`.
198    /// Contains the conversation transcript so extensions (like Stelline)
199    /// can extract memories without reaching into runtime internals.
200    #[serde(default)]
201    pub transcript: Option<Vec<crate::SharedMessage>>,
202    /// Arbitrary extension-defined data, passed through without inspection.
203    pub data: Value,
204}
205
206impl HookEvent {
207    /// Attach the owning conversation id to a tool/message event (builder).
208    ///
209    /// Additive: the JSON field already exists (`session_id`), so extensions
210    /// that ignore it are unaffected. Workers pass `None` and keep emitting
211    /// `null`. Kill-switch `SYNAPS_HOOK_SESSION_ID=0` forces `null` for a
212    /// misbehaving plugin.
213    pub fn with_session(mut self, id: Option<&str>) -> Self {
214        self.session_id = if hook_session_id_enabled() {
215            id.map(str::to_string)
216        } else {
217            None
218        };
219        self
220    }
221
222    /// Construct a `before_tool_call` event.
223    pub fn before_tool_call(tool_name: &str, input: Value) -> Self {
224        Self {
225            kind: HookKind::BeforeToolCall,
226            tool_name: Some(tool_name.to_string()),
227            tool_input: Some(input),
228            tool_output: None,
229            message: None,
230            session_id: None,
231            tool_runtime_name: None,
232            transcript: None,
233            data: Value::Null,
234        }
235    }
236
237    /// Construct an `after_tool_call` event carrying both input and output.
238    /// Output is truncated to MAX_HOOK_OUTPUT to bound the JSON-RPC payload
239    /// to extensions. This cap matches the runtime's `max_tool_buffer`
240    /// (the OOM safety net) so transform extensions see the FULL buffered
241    /// tool output and can decide what to keep — the final context-budget
242    /// truncation to `max_tool_output` happens AFTER the hook in
243    /// `emit_after_tool_call` (compress-then-truncate).
244    pub fn after_tool_call(tool_name: &str, input: Value, output: String) -> Self {
245        let bounded = agent_core::BoundedText::new(&output, MAX_HOOK_OUTPUT);
246        let truncated_output = if bounded.truncated {
247            format!(
248                "{}…[truncated, {} total bytes]",
249                bounded.text, bounded.original_bytes
250            )
251        } else {
252            output
253        };
254        Self {
255            kind: HookKind::AfterToolCall,
256            tool_name: Some(tool_name.to_string()),
257            tool_input: Some(input),
258            tool_output: Some(truncated_output),
259            message: None,
260            session_id: None,
261            tool_runtime_name: None,
262            transcript: None,
263            data: Value::Null,
264        }
265    }
266
267    /// Construct a `before_message` event.
268    pub fn before_message(message: &str) -> Self {
269        Self {
270            kind: HookKind::BeforeMessage,
271            tool_name: None,
272            tool_input: None,
273            tool_output: None,
274            message: Some(message.to_string()),
275            session_id: None,
276            tool_runtime_name: None,
277            transcript: None,
278            data: Value::Null,
279        }
280    }
281
282    /// Construct an `on_message_complete` event.
283    pub fn on_message_complete(message: &str, data: Value) -> Self {
284        Self {
285            kind: HookKind::OnMessageComplete,
286            tool_name: None,
287            tool_input: None,
288            tool_output: None,
289            message: Some(message.to_string()),
290            session_id: None,
291            tool_runtime_name: None,
292            transcript: None,
293            data,
294        }
295    }
296
297    /// Construct an `on_compaction` event.
298    pub fn on_compaction(
299        old_session_id: &str,
300        new_session_id: &str,
301        summary: &str,
302        message_count: usize,
303        mut data: Value,
304    ) -> Self {
305        if !data.is_object() {
306            data = Value::Object(Default::default());
307        }
308        if let Some(object) = data.as_object_mut() {
309            object.insert(
310                "old_session_id".to_string(),
311                Value::String(old_session_id.to_string()),
312            );
313            object.insert(
314                "new_session_id".to_string(),
315                Value::String(new_session_id.to_string()),
316            );
317            object.insert(
318                "message_count".to_string(),
319                Value::Number(message_count.into()),
320            );
321        }
322        Self {
323            kind: HookKind::OnCompaction,
324            tool_name: None,
325            tool_input: None,
326            tool_output: None,
327            message: Some(summary.to_string()),
328            session_id: Some(new_session_id.to_string()),
329            tool_runtime_name: None,
330            transcript: None,
331            data,
332        }
333    }
334
335    /// Construct an `on_session_start` event.
336    pub fn on_session_start(session_id: &str) -> Self {
337        Self {
338            kind: HookKind::OnSessionStart,
339            tool_name: None,
340            tool_input: None,
341            tool_output: None,
342            message: None,
343            session_id: Some(session_id.to_string()),
344            tool_runtime_name: None,
345            transcript: None,
346            data: Value::Null,
347        }
348    }
349
350    /// Construct an `on_session_end` event.
351    pub fn on_session_end(session_id: &str, transcript: Option<Vec<crate::SharedMessage>>) -> Self {
352        Self {
353            kind: HookKind::OnSessionEnd,
354            tool_name: None,
355            tool_input: None,
356            tool_output: None,
357            message: None,
358            session_id: Some(session_id.to_string()),
359            tool_runtime_name: None,
360            transcript,
361            data: Value::Null,
362        }
363    }
364}
365
366// ── HookResult ────────────────────────────────────────────────────────────────
367
368/// What an extension handler returns after processing a hook event.
369///
370/// The runtime resolves multiple handlers by precedence:
371/// - `Block`, `Confirm`, and `Modify` stop the handler chain for `before_tool_call`.
372/// - `Inject` results are accumulated for `before_message`.
373/// - `Continue` is the no-op default — processing continues normally.
374#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
375#[serde(tag = "action", rename_all = "snake_case")]
376pub enum HookResult {
377    /// Allow execution to proceed unchanged.
378    #[default]
379    Continue,
380    /// Prevent the hooked operation. The `reason` is surfaced to the user.
381    Block { reason: String },
382    /// Inject context — the extension provides text for the current request.
383    /// Placement depends on the hook (#297): `on_session_start` content is
384    /// appended to the system prompt (session-stable, cache-safe);
385    /// `before_message` content is attached to the newest user message on the
386    /// outgoing request as an ephemeral trailing block positioned AFTER the
387    /// conversational cache marker (per-turn, uncached tail, cache-neutral).
388    Inject { content: String },
389    /// Ask the runtime to get explicit user confirmation before proceeding.
390    /// Only valid on before_tool_call hooks.
391    Confirm { message: String },
392    /// Replace the tool **input** before execution (structured JSON args).
393    /// Only valid on before_tool_call hooks. The output-side counterpart is
394    /// [`HookResult::Replace`]. The first modifier stops the handler chain.
395    Modify { input: Value },
396    /// Replace the tool **output** after execution (flat string), before it
397    /// enters history. Only valid on after_tool_call hooks. The input-side
398    /// counterpart is [`HookResult::Modify`] — distinct name because the payload
399    /// is an unstructured `String`, not structured `Value`. Enables native
400    /// output transforms (compression, redaction, summarization) by extensions.
401    /// The first transform stops the handler chain.
402    Replace { output: String },
403}
404
405// ── Tests ─────────────────────────────────────────────────────────────────────
406
407impl HookEvent {
408    /// Set the runtime name for tool-related events.
409    pub fn with_runtime_name(mut self, name: &str) -> Self {
410        self.tool_runtime_name = Some(name.to_string());
411        self
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use serde_json::json;
419
420    /// The extension wire format `{"action":"replace","output":"..."}`
421    /// deserializes to HookResult::Replace via the serde action tag — and
422    /// round-trips back. This locks the contract a transform extension speaks.
423    #[test]
424    fn replace_action_roundtrips_extension_wire_format() {
425        // Inbound: what a process extension sends on hook.handle.
426        let wire = json!({"action": "replace", "output": "compressed"});
427        let parsed: HookResult = serde_json::from_value(wire).expect("must deserialize");
428        match parsed {
429            HookResult::Replace { output } => assert_eq!(output, "compressed"),
430            other => panic!("expected Replace, got {other:?}"),
431        }
432
433        // Outbound: the action tag is exactly "replace".
434        let out = serde_json::to_value(HookResult::Replace { output: "x".into() }).unwrap();
435        assert_eq!(out["action"], "replace");
436        assert_eq!(out["output"], "x");
437    }
438
439    // ── HookKind ──────────────────────────────────────────────────────────────
440
441    /// after_tool_call may return Replace; other hooks may not, and
442    /// after_tool_call advertises "replace" in its action contract.
443    #[test]
444    fn after_tool_call_allows_replace_output() {
445        let replace = HookResult::Replace {
446            output: "compressed".into(),
447        };
448
449        // The transform seam: after_tool_call accepts Replace.
450        assert!(
451            HookKind::AfterToolCall.allows_result(&replace),
452            "after_tool_call must permit Replace to enable output transforms"
453        );
454
455        // Replace is scoped strictly to after_tool_call.
456        assert!(
457            !HookKind::BeforeToolCall.allows_result(&replace),
458            "before_tool_call must not permit Replace (input-only)"
459        );
460        assert!(
461            !HookKind::BeforeMessage.allows_result(&replace),
462            "before_message must not permit Replace"
463        );
464
465        // The extension-facing action contract advertises "replace".
466        assert!(
467            HookKind::AfterToolCall
468                .allowed_action_names()
469                .contains(&"replace"),
470            "after_tool_call must advertise the 'replace' action"
471        );
472    }
473
474    /// Every variant's as_str round-trips through from_str.
475    #[test]
476    fn hook_kind_as_str_roundtrip() {
477        let all = [
478            HookKind::BeforeToolCall,
479            HookKind::AfterToolCall,
480            HookKind::BeforeMessage,
481            HookKind::OnMessageComplete,
482            HookKind::OnCompaction,
483            HookKind::OnSessionStart,
484            HookKind::OnSessionEnd,
485        ];
486        for kind in all {
487            let s = kind.as_str();
488            assert_eq!(
489                HookKind::from_str(s),
490                Some(kind),
491                "round-trip failed for {s}"
492            );
493        }
494    }
495
496    /// Unknown strings return None, not a panic or a default.
497    #[test]
498    fn hook_kind_from_str_unknown_returns_none() {
499        assert_eq!(HookKind::from_str(""), None);
500        assert_eq!(HookKind::from_str("BeforeToolCall"), None); // wrong case
501        assert_eq!(HookKind::from_str("on_crash"), None);
502    }
503
504    /// Serde uses snake_case via the attribute — spot-check two variants.
505    #[test]
506    fn hook_kind_serde_snake_case() {
507        let serialized = serde_json::to_string(&HookKind::BeforeToolCall).unwrap();
508        assert_eq!(serialized, r#""before_tool_call""#);
509
510        let back: HookKind = serde_json::from_str(r#""on_session_end""#).unwrap();
511        assert_eq!(back, HookKind::OnSessionEnd);
512    }
513
514    /// Each kind maps to the expected permission.
515    #[test]
516    fn hook_kind_required_permission() {
517        assert_eq!(
518            HookKind::BeforeToolCall.required_permission(),
519            Permission::ToolsIntercept
520        );
521        assert_eq!(
522            HookKind::AfterToolCall.required_permission(),
523            Permission::ToolsIntercept
524        );
525        assert_eq!(
526            HookKind::BeforeMessage.required_permission(),
527            Permission::LlmContent
528        );
529        assert_eq!(
530            HookKind::OnMessageComplete.required_permission(),
531            Permission::LlmContent
532        );
533        assert_eq!(
534            HookKind::OnCompaction.required_permission(),
535            Permission::LlmContent
536        );
537        assert_eq!(
538            HookKind::OnSessionStart.required_permission(),
539            Permission::SessionLifecycle
540        );
541        assert_eq!(
542            HookKind::OnSessionEnd.required_permission(),
543            Permission::SessionLifecycle
544        );
545    }
546
547    // ── HookEvent constructors ────────────────────────────────────────────────
548
549    #[test]
550    fn hook_event_before_tool_call() {
551        let input = json!({"path": "/tmp/foo"});
552        let ev = HookEvent::before_tool_call("read_file", input.clone());
553
554        assert_eq!(ev.kind, HookKind::BeforeToolCall);
555        assert_eq!(ev.tool_name.as_deref(), Some("read_file"));
556        assert_eq!(ev.tool_input.as_ref(), Some(&input));
557        assert!(ev.tool_output.is_none());
558        assert!(ev.message.is_none());
559        assert!(ev.session_id.is_none());
560        assert_eq!(ev.data, Value::Null);
561    }
562
563    #[test]
564    fn hook_event_after_tool_call() {
565        let input = json!({"query": "select 1"});
566        let ev = HookEvent::after_tool_call("sql_query", input.clone(), "1 row".to_string());
567
568        assert_eq!(ev.kind, HookKind::AfterToolCall);
569        assert_eq!(ev.tool_name.as_deref(), Some("sql_query"));
570        assert_eq!(ev.tool_input.as_ref(), Some(&input));
571        assert_eq!(ev.tool_output.as_deref(), Some("1 row"));
572        assert!(ev.message.is_none());
573        assert!(ev.session_id.is_none());
574    }
575
576    #[test]
577    fn hook_event_before_message() {
578        let ev = HookEvent::before_message("Hello, LLM");
579
580        assert_eq!(ev.kind, HookKind::BeforeMessage);
581        assert!(ev.tool_name.is_none());
582        assert!(ev.tool_input.is_none());
583        assert!(ev.tool_output.is_none());
584        assert_eq!(ev.message.as_deref(), Some("Hello, LLM"));
585        assert!(ev.session_id.is_none());
586    }
587
588    #[test]
589    fn hook_event_on_message_complete() {
590        let ev = HookEvent::on_message_complete("Done", json!({"content_block_count": 1}));
591
592        assert_eq!(ev.kind, HookKind::OnMessageComplete);
593        assert!(ev.tool_name.is_none());
594        assert!(ev.tool_input.is_none());
595        assert!(ev.tool_output.is_none());
596        assert_eq!(ev.message.as_deref(), Some("Done"));
597        assert_eq!(ev.data["content_block_count"], 1);
598        assert!(ev.session_id.is_none());
599    }
600
601    #[test]
602    fn hook_event_on_compaction() {
603        let ev = HookEvent::on_compaction(
604            "old-session",
605            "new-session",
606            "Summary",
607            7,
608            json!({"source": "manual"}),
609        );
610
611        assert_eq!(ev.kind, HookKind::OnCompaction);
612        assert_eq!(ev.message.as_deref(), Some("Summary"));
613        assert_eq!(ev.session_id.as_deref(), Some("new-session"));
614        assert_eq!(ev.data["old_session_id"], "old-session");
615        assert_eq!(ev.data["new_session_id"], "new-session");
616        assert_eq!(ev.data["message_count"], 7);
617        assert_eq!(ev.data["source"], "manual");
618        assert!(ev.transcript.is_none());
619    }
620
621    #[test]
622    fn hook_event_on_session_start() {
623        let ev = HookEvent::on_session_start("sess-abc-123");
624
625        assert_eq!(ev.kind, HookKind::OnSessionStart);
626        assert_eq!(ev.session_id.as_deref(), Some("sess-abc-123"));
627        assert!(ev.tool_name.is_none());
628        assert!(ev.message.is_none());
629    }
630
631    #[test]
632    fn hook_event_on_session_end() {
633        let ev = HookEvent::on_session_end("sess-abc-123", None);
634
635        assert_eq!(ev.kind, HookKind::OnSessionEnd);
636        assert_eq!(ev.session_id.as_deref(), Some("sess-abc-123"));
637        assert!(ev.tool_name.is_none());
638        assert!(ev.message.is_none());
639    }
640
641    /// HookEvent is round-trippable through JSON without loss.
642    #[test]
643    fn hook_event_serde_roundtrip() {
644        let ev = HookEvent::before_tool_call("bash", json!({"cmd": "ls"}));
645        let json = serde_json::to_string(&ev).unwrap();
646        let back: HookEvent = serde_json::from_str(&json).unwrap();
647
648        assert_eq!(back.kind, ev.kind);
649        assert_eq!(back.tool_name, ev.tool_name);
650        assert_eq!(back.tool_input, ev.tool_input);
651    }
652
653    // ── HookResult ────────────────────────────────────────────────────────────
654
655    /// Default is Continue.
656    #[test]
657    fn hook_result_default_is_continue() {
658        assert!(matches!(HookResult::default(), HookResult::Continue));
659    }
660
661    /// Block carries its reason through serialization.
662    #[test]
663    fn hook_result_block_serde() {
664        let r = HookResult::Block {
665            reason: "denied by policy".to_string(),
666        };
667        let json = serde_json::to_string(&r).unwrap();
668        // `tag = "action"` means the JSON object has {"action":"block","reason":"..."}
669        assert!(json.contains(r#""action":"block""#));
670        assert!(json.contains("denied by policy"));
671
672        let back: HookResult = serde_json::from_str(&json).unwrap();
673        assert!(matches!(back, HookResult::Block { reason } if reason == "denied by policy"));
674    }
675
676    /// Confirm carries its message through serialization.
677    #[test]
678    fn hook_result_confirm_serde() {
679        let r = HookResult::Confirm {
680            message: "Run this command?".to_string(),
681        };
682        let json = serde_json::to_string(&r).unwrap();
683        assert_eq!(
684            json,
685            r#"{"action":"confirm","message":"Run this command?"}"#
686        );
687
688        let back: HookResult = serde_json::from_str(&json).unwrap();
689        assert!(matches!(back, HookResult::Confirm { message } if message == "Run this command?"));
690    }
691
692    /// Modify carries replacement input through serialization.
693    #[test]
694    fn hook_result_modify_serde() {
695        let r = HookResult::Modify {
696            input: json!({"command": "echo safe"}),
697        };
698        let json = serde_json::to_string(&r).unwrap();
699        assert_eq!(
700            json,
701            r#"{"action":"modify","input":{"command":"echo safe"}}"#
702        );
703
704        let back: HookResult = serde_json::from_str(&json).unwrap();
705        assert!(
706            matches!(back, HookResult::Modify { input } if input == json!({"command": "echo safe"}))
707        );
708    }
709
710    /// Continue serialises as {"action":"continue"}.
711    #[test]
712    fn hook_result_continue_serde() {
713        let json = serde_json::to_string(&HookResult::Continue).unwrap();
714        assert_eq!(json, r#"{"action":"continue"}"#);
715    }
716
717    /// `HookKind::ALL` must list every variant exactly once. Adding a variant
718    /// makes the match below non-exhaustive => compile error => the author is
719    /// forced to update `ALL` and, via the contract test, contract.json.
720    #[test]
721    fn all_covers_every_variant() {
722        for kind in HookKind::ALL {
723            match kind {
724                HookKind::BeforeToolCall
725                | HookKind::AfterToolCall
726                | HookKind::BeforeMessage
727                | HookKind::OnMessageComplete
728                | HookKind::OnCompaction
729                | HookKind::OnSessionStart
730                | HookKind::OnSessionEnd => {}
731            }
732        }
733        assert_eq!(
734            HookKind::ALL.len(),
735            7,
736            "a HookKind variant was added or removed -- update HookKind::ALL \
737             and docs/extensions/contract.json to match"
738        );
739        let unique: std::collections::HashSet<HookKind> = HookKind::ALL.iter().copied().collect();
740        assert_eq!(
741            unique.len(),
742            HookKind::ALL.len(),
743            "HookKind::ALL has a duplicate"
744        );
745    }
746
747    /// Every hook kind must round-trip through its canonical wire string.
748    #[test]
749    fn all_hook_kinds_round_trip_through_wire_format() {
750        for kind in HookKind::ALL {
751            assert_eq!(
752                HookKind::from_str(kind.as_str()),
753                Some(*kind),
754                "{} does not round-trip",
755                kind.as_str()
756            );
757        }
758    }
759
760    /// `allowed_action_names` is the contract's action list, and
761    /// `allows_result` is what the hook bus actually enforces. They must
762    /// agree, or the documented action set is a lie in one direction or the
763    /// other. (#291: on_session_start advertised only "continue" while Axel
764    /// returned "inject" -- caught here now.)
765    #[test]
766    fn advertised_actions_match_enforced_actions() {
767        let samples: [(&str, HookResult); 6] = [
768            ("continue", HookResult::Continue),
769            ("block", HookResult::Block { reason: "r".into() }),
770            (
771                "confirm",
772                HookResult::Confirm {
773                    message: "m".into(),
774                },
775            ),
776            (
777                "modify",
778                HookResult::Modify {
779                    input: serde_json::json!({}),
780                },
781            ),
782            ("replace", HookResult::Replace { output: "o".into() }),
783            (
784                "inject",
785                HookResult::Inject {
786                    content: "c".into(),
787                },
788            ),
789        ];
790        for kind in HookKind::ALL {
791            for (name, result) in &samples {
792                let advertised = kind.allowed_action_names().contains(name);
793                let enforced = kind.allows_result(result);
794                assert_eq!(
795                    advertised,
796                    enforced,
797                    "hook {} disagrees about action {:?}: advertised={} enforced={}",
798                    kind.as_str(),
799                    name,
800                    advertised,
801                    enforced
802                );
803            }
804        }
805    }
806}