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    /// Canonical string identifier for this kind, suitable for serialization
51    /// keys, log output, and manifest declarations.
52    pub fn as_str(&self) -> &'static str {
53        match self {
54            Self::BeforeToolCall => "before_tool_call",
55            Self::AfterToolCall => "after_tool_call",
56            Self::BeforeMessage => "before_message",
57            Self::OnMessageComplete => "on_message_complete",
58            Self::OnCompaction => "on_compaction",
59            Self::OnSessionStart => "on_session_start",
60            Self::OnSessionEnd => "on_session_end",
61        }
62    }
63
64    /// Parse from the canonical string representation.
65    ///
66    /// Returns `None` for unrecognised strings so callers can surface
67    /// a manifest validation error rather than silently dropping hooks.
68    #[allow(clippy::should_implement_trait)]
69    pub fn from_str(s: &str) -> Option<Self> {
70        match s {
71            "before_tool_call" => Some(Self::BeforeToolCall),
72            "after_tool_call" => Some(Self::AfterToolCall),
73            "before_message" => Some(Self::BeforeMessage),
74            "on_message_complete" => Some(Self::OnMessageComplete),
75            "on_compaction" => Some(Self::OnCompaction),
76            "on_session_start" => Some(Self::OnSessionStart),
77            "on_session_end" => Some(Self::OnSessionEnd),
78            _ => None,
79        }
80    }
81
82    /// Supported action names for this hook in the extension contract.
83    pub fn allowed_action_names(&self) -> &'static [&'static str] {
84        match self {
85            Self::BeforeToolCall => &["continue", "block", "confirm", "modify"],
86            Self::AfterToolCall => &["continue", "replace"],
87            Self::BeforeMessage => &["continue", "inject"],
88            Self::OnMessageComplete
89            | Self::OnCompaction
90            | Self::OnSessionStart
91            | Self::OnSessionEnd => &["continue"],
92        }
93    }
94
95    /// Whether this hook can be filtered by tool name in a manifest.
96    pub fn allows_tool_filter(&self) -> bool {
97        matches!(self, Self::BeforeToolCall | Self::AfterToolCall)
98    }
99
100    /// Whether this hook accepts a handler result action.
101    pub fn allows_result(&self, result: &HookResult) -> bool {
102        matches!(
103            (self, result),
104            (_, HookResult::Continue)
105                | (Self::BeforeToolCall, HookResult::Block { .. })
106                | (Self::BeforeToolCall, HookResult::Confirm { .. })
107                | (Self::BeforeToolCall, HookResult::Modify { .. })
108                | (Self::AfterToolCall, HookResult::Replace { .. })
109                | (Self::BeforeMessage, HookResult::Inject { .. })
110        )
111    }
112
113    /// The [`Permission`] an extension must hold to subscribe to this hook.
114    ///
115    /// Called by the permission gate before delivering any event; if the
116    /// extension's [`PermissionSet`][crate::extensions::permissions::PermissionSet]
117    /// does not include this permission, `HookBus::subscribe()` returns an error.
118    pub fn required_permission(&self) -> Permission {
119        match self {
120            Self::BeforeToolCall | Self::AfterToolCall => Permission::ToolsIntercept,
121            Self::BeforeMessage | Self::OnMessageComplete | Self::OnCompaction => {
122                Permission::LlmContent
123            }
124            Self::OnSessionStart | Self::OnSessionEnd => Permission::SessionLifecycle,
125        }
126    }
127}
128
129// ── HookEvent ─────────────────────────────────────────────────────────────────
130
131/// A hook event payload dispatched to extension handlers.
132///
133/// Fields are optional and populated only when relevant to the hook kind:
134///
135/// | Kind                  | tool_name | tool_input | tool_output | message | session_id |
136/// |-----------------------|-----------|------------|-------------|---------|------------|
137/// | `before_tool_call`    | ✓         | ✓          |             |         |            |
138/// | `after_tool_call`     | ✓         | ✓          | ✓           |         |            |
139/// | `before_message`      |           |            |             | ✓       |            |
140/// | `on_message_complete` |           |            |             | ✓       |            |
141/// | `on_compaction`       |           |            |             | ✓       | ✓          |
142/// | `on_session_start`    |           |            |             |         | ✓          |
143/// | `on_session_end`      |           |            |             |         | ✓          |
144///
145/// The `data` field is available on all events for extensions that need to
146/// attach arbitrary structured context when constructing synthetic events.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct HookEvent {
149    /// Which hook fired.
150    pub kind: HookKind,
151    /// Tool name for tool-specific hooks; `None` for general hooks.
152    /// This is the API-safe name (sanitized for the LLM).
153    pub tool_name: Option<String>,
154    /// Original runtime name of the tool (before API sanitization).
155    /// Extension authors typically write runtime names in their manifests.
156    #[serde(default)]
157    pub tool_runtime_name: Option<String>,
158    /// Tool input arguments for `before_tool_call` and `after_tool_call`.
159    pub tool_input: Option<Value>,
160    /// Tool output for `after_tool_call`.
161    pub tool_output: Option<String>,
162    /// LLM message content for `before_message`.
163    pub message: Option<String>,
164    /// Session identifier for session lifecycle hooks.
165    pub session_id: Option<String>,
166    /// Session message history for `on_session_end`.
167    /// Contains the conversation transcript so extensions (like Stelline)
168    /// can extract memories without reaching into runtime internals.
169    #[serde(default)]
170    pub transcript: Option<Vec<crate::SharedMessage>>,
171    /// Arbitrary extension-defined data, passed through without inspection.
172    pub data: Value,
173}
174
175impl HookEvent {
176    /// Construct a `before_tool_call` event.
177    pub fn before_tool_call(tool_name: &str, input: Value) -> Self {
178        Self {
179            kind: HookKind::BeforeToolCall,
180            tool_name: Some(tool_name.to_string()),
181            tool_input: Some(input),
182            tool_output: None,
183            message: None,
184            session_id: None,
185            tool_runtime_name: None,
186            transcript: None,
187            data: Value::Null,
188        }
189    }
190
191    /// Construct an `after_tool_call` event carrying both input and output.
192    /// Output is truncated to MAX_HOOK_OUTPUT to bound the JSON-RPC payload
193    /// to extensions. This cap matches the runtime's `max_tool_buffer`
194    /// (the OOM safety net) so transform extensions see the FULL buffered
195    /// tool output and can decide what to keep — the final context-budget
196    /// truncation to `max_tool_output` happens AFTER the hook in
197    /// `emit_after_tool_call` (compress-then-truncate).
198    pub fn after_tool_call(tool_name: &str, input: Value, output: String) -> Self {
199        let bounded = agent_core::BoundedText::new(&output, MAX_HOOK_OUTPUT);
200        let truncated_output = if bounded.truncated {
201            format!(
202                "{}…[truncated, {} total bytes]",
203                bounded.text, bounded.original_bytes
204            )
205        } else {
206            output
207        };
208        Self {
209            kind: HookKind::AfterToolCall,
210            tool_name: Some(tool_name.to_string()),
211            tool_input: Some(input),
212            tool_output: Some(truncated_output),
213            message: None,
214            session_id: None,
215            tool_runtime_name: None,
216            transcript: None,
217            data: Value::Null,
218        }
219    }
220
221    /// Construct a `before_message` event.
222    pub fn before_message(message: &str) -> Self {
223        Self {
224            kind: HookKind::BeforeMessage,
225            tool_name: None,
226            tool_input: None,
227            tool_output: None,
228            message: Some(message.to_string()),
229            session_id: None,
230            tool_runtime_name: None,
231            transcript: None,
232            data: Value::Null,
233        }
234    }
235
236    /// Construct an `on_message_complete` event.
237    pub fn on_message_complete(message: &str, data: Value) -> Self {
238        Self {
239            kind: HookKind::OnMessageComplete,
240            tool_name: None,
241            tool_input: None,
242            tool_output: None,
243            message: Some(message.to_string()),
244            session_id: None,
245            tool_runtime_name: None,
246            transcript: None,
247            data,
248        }
249    }
250
251    /// Construct an `on_compaction` event.
252    pub fn on_compaction(
253        old_session_id: &str,
254        new_session_id: &str,
255        summary: &str,
256        message_count: usize,
257        mut data: Value,
258    ) -> Self {
259        if !data.is_object() {
260            data = Value::Object(Default::default());
261        }
262        if let Some(object) = data.as_object_mut() {
263            object.insert(
264                "old_session_id".to_string(),
265                Value::String(old_session_id.to_string()),
266            );
267            object.insert(
268                "new_session_id".to_string(),
269                Value::String(new_session_id.to_string()),
270            );
271            object.insert(
272                "message_count".to_string(),
273                Value::Number(message_count.into()),
274            );
275        }
276        Self {
277            kind: HookKind::OnCompaction,
278            tool_name: None,
279            tool_input: None,
280            tool_output: None,
281            message: Some(summary.to_string()),
282            session_id: Some(new_session_id.to_string()),
283            tool_runtime_name: None,
284            transcript: None,
285            data,
286        }
287    }
288
289    /// Construct an `on_session_start` event.
290    pub fn on_session_start(session_id: &str) -> Self {
291        Self {
292            kind: HookKind::OnSessionStart,
293            tool_name: None,
294            tool_input: None,
295            tool_output: None,
296            message: None,
297            session_id: Some(session_id.to_string()),
298            tool_runtime_name: None,
299            transcript: None,
300            data: Value::Null,
301        }
302    }
303
304    /// Construct an `on_session_end` event.
305    pub fn on_session_end(session_id: &str, transcript: Option<Vec<crate::SharedMessage>>) -> Self {
306        Self {
307            kind: HookKind::OnSessionEnd,
308            tool_name: None,
309            tool_input: None,
310            tool_output: None,
311            message: None,
312            session_id: Some(session_id.to_string()),
313            tool_runtime_name: None,
314            transcript,
315            data: Value::Null,
316        }
317    }
318}
319
320// ── HookResult ────────────────────────────────────────────────────────────────
321
322/// What an extension handler returns after processing a hook event.
323///
324/// The runtime resolves multiple handlers by precedence:
325/// - `Block`, `Confirm`, and `Modify` stop the handler chain for `before_tool_call`.
326/// - `Inject` results are accumulated for `before_message`.
327/// - `Continue` is the no-op default — processing continues normally.
328#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
329#[serde(tag = "action", rename_all = "snake_case")]
330pub enum HookResult {
331    /// Allow execution to proceed unchanged.
332    #[default]
333    Continue,
334    /// Prevent the hooked operation. The `reason` is surfaced to the user.
335    Block { reason: String },
336    /// Inject context — the extension provides text to prepend to the
337    /// system prompt or conversation. Used by before_message hooks.
338    Inject { content: String },
339    /// Ask the runtime to get explicit user confirmation before proceeding.
340    /// Only valid on before_tool_call hooks.
341    Confirm { message: String },
342    /// Replace the tool **input** before execution (structured JSON args).
343    /// Only valid on before_tool_call hooks. The output-side counterpart is
344    /// [`HookResult::Replace`]. The first modifier stops the handler chain.
345    Modify { input: Value },
346    /// Replace the tool **output** after execution (flat string), before it
347    /// enters history. Only valid on after_tool_call hooks. The input-side
348    /// counterpart is [`HookResult::Modify`] — distinct name because the payload
349    /// is an unstructured `String`, not structured `Value`. Enables native
350    /// output transforms (compression, redaction, summarization) by extensions.
351    /// The first transform stops the handler chain.
352    Replace { output: String },
353}
354
355// ── Tests ─────────────────────────────────────────────────────────────────────
356
357impl HookEvent {
358    /// Set the runtime name for tool-related events.
359    pub fn with_runtime_name(mut self, name: &str) -> Self {
360        self.tool_runtime_name = Some(name.to_string());
361        self
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use serde_json::json;
369
370    /// The extension wire format `{"action":"replace","output":"..."}`
371    /// deserializes to HookResult::Replace via the serde action tag — and
372    /// round-trips back. This locks the contract a transform extension speaks.
373    #[test]
374    fn replace_action_roundtrips_extension_wire_format() {
375        // Inbound: what a process extension sends on hook.handle.
376        let wire = json!({"action": "replace", "output": "compressed"});
377        let parsed: HookResult = serde_json::from_value(wire).expect("must deserialize");
378        match parsed {
379            HookResult::Replace { output } => assert_eq!(output, "compressed"),
380            other => panic!("expected Replace, got {other:?}"),
381        }
382
383        // Outbound: the action tag is exactly "replace".
384        let out = serde_json::to_value(HookResult::Replace { output: "x".into() }).unwrap();
385        assert_eq!(out["action"], "replace");
386        assert_eq!(out["output"], "x");
387    }
388
389    // ── HookKind ──────────────────────────────────────────────────────────────
390
391    /// after_tool_call may return Replace; other hooks may not, and
392    /// after_tool_call advertises "replace" in its action contract.
393    #[test]
394    fn after_tool_call_allows_replace_output() {
395        let replace = HookResult::Replace {
396            output: "compressed".into(),
397        };
398
399        // The transform seam: after_tool_call accepts Replace.
400        assert!(
401            HookKind::AfterToolCall.allows_result(&replace),
402            "after_tool_call must permit Replace to enable output transforms"
403        );
404
405        // Replace is scoped strictly to after_tool_call.
406        assert!(
407            !HookKind::BeforeToolCall.allows_result(&replace),
408            "before_tool_call must not permit Replace (input-only)"
409        );
410        assert!(
411            !HookKind::BeforeMessage.allows_result(&replace),
412            "before_message must not permit Replace"
413        );
414
415        // The extension-facing action contract advertises "replace".
416        assert!(
417            HookKind::AfterToolCall
418                .allowed_action_names()
419                .contains(&"replace"),
420            "after_tool_call must advertise the 'replace' action"
421        );
422    }
423
424    /// Every variant's as_str round-trips through from_str.
425    #[test]
426    fn hook_kind_as_str_roundtrip() {
427        let all = [
428            HookKind::BeforeToolCall,
429            HookKind::AfterToolCall,
430            HookKind::BeforeMessage,
431            HookKind::OnMessageComplete,
432            HookKind::OnCompaction,
433            HookKind::OnSessionStart,
434            HookKind::OnSessionEnd,
435        ];
436        for kind in all {
437            let s = kind.as_str();
438            assert_eq!(
439                HookKind::from_str(s),
440                Some(kind),
441                "round-trip failed for {s}"
442            );
443        }
444    }
445
446    /// Unknown strings return None, not a panic or a default.
447    #[test]
448    fn hook_kind_from_str_unknown_returns_none() {
449        assert_eq!(HookKind::from_str(""), None);
450        assert_eq!(HookKind::from_str("BeforeToolCall"), None); // wrong case
451        assert_eq!(HookKind::from_str("on_crash"), None);
452    }
453
454    /// Serde uses snake_case via the attribute — spot-check two variants.
455    #[test]
456    fn hook_kind_serde_snake_case() {
457        let serialized = serde_json::to_string(&HookKind::BeforeToolCall).unwrap();
458        assert_eq!(serialized, r#""before_tool_call""#);
459
460        let back: HookKind = serde_json::from_str(r#""on_session_end""#).unwrap();
461        assert_eq!(back, HookKind::OnSessionEnd);
462    }
463
464    /// Each kind maps to the expected permission.
465    #[test]
466    fn hook_kind_required_permission() {
467        assert_eq!(
468            HookKind::BeforeToolCall.required_permission(),
469            Permission::ToolsIntercept
470        );
471        assert_eq!(
472            HookKind::AfterToolCall.required_permission(),
473            Permission::ToolsIntercept
474        );
475        assert_eq!(
476            HookKind::BeforeMessage.required_permission(),
477            Permission::LlmContent
478        );
479        assert_eq!(
480            HookKind::OnMessageComplete.required_permission(),
481            Permission::LlmContent
482        );
483        assert_eq!(
484            HookKind::OnCompaction.required_permission(),
485            Permission::LlmContent
486        );
487        assert_eq!(
488            HookKind::OnSessionStart.required_permission(),
489            Permission::SessionLifecycle
490        );
491        assert_eq!(
492            HookKind::OnSessionEnd.required_permission(),
493            Permission::SessionLifecycle
494        );
495    }
496
497    // ── HookEvent constructors ────────────────────────────────────────────────
498
499    #[test]
500    fn hook_event_before_tool_call() {
501        let input = json!({"path": "/tmp/foo"});
502        let ev = HookEvent::before_tool_call("read_file", input.clone());
503
504        assert_eq!(ev.kind, HookKind::BeforeToolCall);
505        assert_eq!(ev.tool_name.as_deref(), Some("read_file"));
506        assert_eq!(ev.tool_input.as_ref(), Some(&input));
507        assert!(ev.tool_output.is_none());
508        assert!(ev.message.is_none());
509        assert!(ev.session_id.is_none());
510        assert_eq!(ev.data, Value::Null);
511    }
512
513    #[test]
514    fn hook_event_after_tool_call() {
515        let input = json!({"query": "select 1"});
516        let ev = HookEvent::after_tool_call("sql_query", input.clone(), "1 row".to_string());
517
518        assert_eq!(ev.kind, HookKind::AfterToolCall);
519        assert_eq!(ev.tool_name.as_deref(), Some("sql_query"));
520        assert_eq!(ev.tool_input.as_ref(), Some(&input));
521        assert_eq!(ev.tool_output.as_deref(), Some("1 row"));
522        assert!(ev.message.is_none());
523        assert!(ev.session_id.is_none());
524    }
525
526    #[test]
527    fn hook_event_before_message() {
528        let ev = HookEvent::before_message("Hello, LLM");
529
530        assert_eq!(ev.kind, HookKind::BeforeMessage);
531        assert!(ev.tool_name.is_none());
532        assert!(ev.tool_input.is_none());
533        assert!(ev.tool_output.is_none());
534        assert_eq!(ev.message.as_deref(), Some("Hello, LLM"));
535        assert!(ev.session_id.is_none());
536    }
537
538    #[test]
539    fn hook_event_on_message_complete() {
540        let ev = HookEvent::on_message_complete("Done", json!({"content_block_count": 1}));
541
542        assert_eq!(ev.kind, HookKind::OnMessageComplete);
543        assert!(ev.tool_name.is_none());
544        assert!(ev.tool_input.is_none());
545        assert!(ev.tool_output.is_none());
546        assert_eq!(ev.message.as_deref(), Some("Done"));
547        assert_eq!(ev.data["content_block_count"], 1);
548        assert!(ev.session_id.is_none());
549    }
550
551    #[test]
552    fn hook_event_on_compaction() {
553        let ev = HookEvent::on_compaction(
554            "old-session",
555            "new-session",
556            "Summary",
557            7,
558            json!({"source": "manual"}),
559        );
560
561        assert_eq!(ev.kind, HookKind::OnCompaction);
562        assert_eq!(ev.message.as_deref(), Some("Summary"));
563        assert_eq!(ev.session_id.as_deref(), Some("new-session"));
564        assert_eq!(ev.data["old_session_id"], "old-session");
565        assert_eq!(ev.data["new_session_id"], "new-session");
566        assert_eq!(ev.data["message_count"], 7);
567        assert_eq!(ev.data["source"], "manual");
568        assert!(ev.transcript.is_none());
569    }
570
571    #[test]
572    fn hook_event_on_session_start() {
573        let ev = HookEvent::on_session_start("sess-abc-123");
574
575        assert_eq!(ev.kind, HookKind::OnSessionStart);
576        assert_eq!(ev.session_id.as_deref(), Some("sess-abc-123"));
577        assert!(ev.tool_name.is_none());
578        assert!(ev.message.is_none());
579    }
580
581    #[test]
582    fn hook_event_on_session_end() {
583        let ev = HookEvent::on_session_end("sess-abc-123", None);
584
585        assert_eq!(ev.kind, HookKind::OnSessionEnd);
586        assert_eq!(ev.session_id.as_deref(), Some("sess-abc-123"));
587        assert!(ev.tool_name.is_none());
588        assert!(ev.message.is_none());
589    }
590
591    /// HookEvent is round-trippable through JSON without loss.
592    #[test]
593    fn hook_event_serde_roundtrip() {
594        let ev = HookEvent::before_tool_call("bash", json!({"cmd": "ls"}));
595        let json = serde_json::to_string(&ev).unwrap();
596        let back: HookEvent = serde_json::from_str(&json).unwrap();
597
598        assert_eq!(back.kind, ev.kind);
599        assert_eq!(back.tool_name, ev.tool_name);
600        assert_eq!(back.tool_input, ev.tool_input);
601    }
602
603    // ── HookResult ────────────────────────────────────────────────────────────
604
605    /// Default is Continue.
606    #[test]
607    fn hook_result_default_is_continue() {
608        assert!(matches!(HookResult::default(), HookResult::Continue));
609    }
610
611    /// Block carries its reason through serialization.
612    #[test]
613    fn hook_result_block_serde() {
614        let r = HookResult::Block {
615            reason: "denied by policy".to_string(),
616        };
617        let json = serde_json::to_string(&r).unwrap();
618        // `tag = "action"` means the JSON object has {"action":"block","reason":"..."}
619        assert!(json.contains(r#""action":"block""#));
620        assert!(json.contains("denied by policy"));
621
622        let back: HookResult = serde_json::from_str(&json).unwrap();
623        assert!(matches!(back, HookResult::Block { reason } if reason == "denied by policy"));
624    }
625
626    /// Confirm carries its message through serialization.
627    #[test]
628    fn hook_result_confirm_serde() {
629        let r = HookResult::Confirm {
630            message: "Run this command?".to_string(),
631        };
632        let json = serde_json::to_string(&r).unwrap();
633        assert_eq!(
634            json,
635            r#"{"action":"confirm","message":"Run this command?"}"#
636        );
637
638        let back: HookResult = serde_json::from_str(&json).unwrap();
639        assert!(matches!(back, HookResult::Confirm { message } if message == "Run this command?"));
640    }
641
642    /// Modify carries replacement input through serialization.
643    #[test]
644    fn hook_result_modify_serde() {
645        let r = HookResult::Modify {
646            input: json!({"command": "echo safe"}),
647        };
648        let json = serde_json::to_string(&r).unwrap();
649        assert_eq!(
650            json,
651            r#"{"action":"modify","input":{"command":"echo safe"}}"#
652        );
653
654        let back: HookResult = serde_json::from_str(&json).unwrap();
655        assert!(
656            matches!(back, HookResult::Modify { input } if input == json!({"command": "echo safe"}))
657        );
658    }
659
660    /// Continue serialises as {"action":"continue"}.
661    #[test]
662    fn hook_result_continue_serde() {
663        let json = serde_json::to_string(&HookResult::Continue).unwrap();
664        assert_eq!(json, r#"{"action":"continue"}"#);
665    }
666}