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