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 truncated_output = if output.len() > MAX_HOOK_OUTPUT {
200            let boundary = output
201                .char_indices()
202                .map(|(idx, _)| idx)
203                .take_while(|idx| *idx <= MAX_HOOK_OUTPUT)
204                .last()
205                .unwrap_or(0);
206            format!(
207                "{}…[truncated, {} total bytes]",
208                &output[..boundary],
209                output.len()
210            )
211        } else {
212            output
213        };
214        Self {
215            kind: HookKind::AfterToolCall,
216            tool_name: Some(tool_name.to_string()),
217            tool_input: Some(input),
218            tool_output: Some(truncated_output),
219            message: None,
220            session_id: None,
221            tool_runtime_name: None,
222            transcript: None,
223            data: Value::Null,
224        }
225    }
226
227    /// Construct a `before_message` event.
228    pub fn before_message(message: &str) -> Self {
229        Self {
230            kind: HookKind::BeforeMessage,
231            tool_name: None,
232            tool_input: None,
233            tool_output: None,
234            message: Some(message.to_string()),
235            session_id: None,
236            tool_runtime_name: None,
237            transcript: None,
238            data: Value::Null,
239        }
240    }
241
242    /// Construct an `on_message_complete` event.
243    pub fn on_message_complete(message: &str, data: Value) -> Self {
244        Self {
245            kind: HookKind::OnMessageComplete,
246            tool_name: None,
247            tool_input: None,
248            tool_output: None,
249            message: Some(message.to_string()),
250            session_id: None,
251            tool_runtime_name: None,
252            transcript: None,
253            data,
254        }
255    }
256
257    /// Construct an `on_compaction` event.
258    pub fn on_compaction(
259        old_session_id: &str,
260        new_session_id: &str,
261        summary: &str,
262        message_count: usize,
263        mut data: Value,
264    ) -> Self {
265        if !data.is_object() {
266            data = Value::Object(Default::default());
267        }
268        if let Some(object) = data.as_object_mut() {
269            object.insert(
270                "old_session_id".to_string(),
271                Value::String(old_session_id.to_string()),
272            );
273            object.insert(
274                "new_session_id".to_string(),
275                Value::String(new_session_id.to_string()),
276            );
277            object.insert(
278                "message_count".to_string(),
279                Value::Number(message_count.into()),
280            );
281        }
282        Self {
283            kind: HookKind::OnCompaction,
284            tool_name: None,
285            tool_input: None,
286            tool_output: None,
287            message: Some(summary.to_string()),
288            session_id: Some(new_session_id.to_string()),
289            tool_runtime_name: None,
290            transcript: None,
291            data,
292        }
293    }
294
295    /// Construct an `on_session_start` event.
296    pub fn on_session_start(session_id: &str) -> Self {
297        Self {
298            kind: HookKind::OnSessionStart,
299            tool_name: None,
300            tool_input: None,
301            tool_output: None,
302            message: None,
303            session_id: Some(session_id.to_string()),
304            tool_runtime_name: None,
305            transcript: None,
306            data: Value::Null,
307        }
308    }
309
310    /// Construct an `on_session_end` event.
311    pub fn on_session_end(session_id: &str, transcript: Option<Vec<crate::SharedMessage>>) -> Self {
312        Self {
313            kind: HookKind::OnSessionEnd,
314            tool_name: None,
315            tool_input: None,
316            tool_output: None,
317            message: None,
318            session_id: Some(session_id.to_string()),
319            tool_runtime_name: None,
320            transcript,
321            data: Value::Null,
322        }
323    }
324}
325
326// ── HookResult ────────────────────────────────────────────────────────────────
327
328/// What an extension handler returns after processing a hook event.
329///
330/// The runtime resolves multiple handlers by precedence:
331/// - `Block`, `Confirm`, and `Modify` stop the handler chain for `before_tool_call`.
332/// - `Inject` results are accumulated for `before_message`.
333/// - `Continue` is the no-op default — processing continues normally.
334#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
335#[serde(tag = "action", rename_all = "snake_case")]
336pub enum HookResult {
337    /// Allow execution to proceed unchanged.
338    #[default]
339    Continue,
340    /// Prevent the hooked operation. The `reason` is surfaced to the user.
341    Block { reason: String },
342    /// Inject context — the extension provides text to prepend to the
343    /// system prompt or conversation. Used by before_message hooks.
344    Inject { content: String },
345    /// Ask the runtime to get explicit user confirmation before proceeding.
346    /// Only valid on before_tool_call hooks.
347    Confirm { message: String },
348    /// Replace the tool **input** before execution (structured JSON args).
349    /// Only valid on before_tool_call hooks. The output-side counterpart is
350    /// [`HookResult::Replace`]. The first modifier stops the handler chain.
351    Modify { input: Value },
352    /// Replace the tool **output** after execution (flat string), before it
353    /// enters history. Only valid on after_tool_call hooks. The input-side
354    /// counterpart is [`HookResult::Modify`] — distinct name because the payload
355    /// is an unstructured `String`, not structured `Value`. Enables native
356    /// output transforms (compression, redaction, summarization) by extensions.
357    /// The first transform stops the handler chain.
358    Replace { output: String },
359}
360
361// ── Tests ─────────────────────────────────────────────────────────────────────
362
363impl HookEvent {
364    /// Set the runtime name for tool-related events.
365    pub fn with_runtime_name(mut self, name: &str) -> Self {
366        self.tool_runtime_name = Some(name.to_string());
367        self
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use serde_json::json;
375
376    /// The extension wire format `{"action":"replace","output":"..."}`
377    /// deserializes to HookResult::Replace via the serde action tag — and
378    /// round-trips back. This locks the contract a transform extension speaks.
379    #[test]
380    fn replace_action_roundtrips_extension_wire_format() {
381        // Inbound: what a process extension sends on hook.handle.
382        let wire = json!({"action": "replace", "output": "compressed"});
383        let parsed: HookResult = serde_json::from_value(wire).expect("must deserialize");
384        match parsed {
385            HookResult::Replace { output } => assert_eq!(output, "compressed"),
386            other => panic!("expected Replace, got {other:?}"),
387        }
388
389        // Outbound: the action tag is exactly "replace".
390        let out = serde_json::to_value(HookResult::Replace { output: "x".into() }).unwrap();
391        assert_eq!(out["action"], "replace");
392        assert_eq!(out["output"], "x");
393    }
394
395    // ── HookKind ──────────────────────────────────────────────────────────────
396
397    /// after_tool_call may return Replace; other hooks may not, and
398    /// after_tool_call advertises "replace" in its action contract.
399    #[test]
400    fn after_tool_call_allows_replace_output() {
401        let replace = HookResult::Replace {
402            output: "compressed".into(),
403        };
404
405        // The transform seam: after_tool_call accepts Replace.
406        assert!(
407            HookKind::AfterToolCall.allows_result(&replace),
408            "after_tool_call must permit Replace to enable output transforms"
409        );
410
411        // Replace is scoped strictly to after_tool_call.
412        assert!(
413            !HookKind::BeforeToolCall.allows_result(&replace),
414            "before_tool_call must not permit Replace (input-only)"
415        );
416        assert!(
417            !HookKind::BeforeMessage.allows_result(&replace),
418            "before_message must not permit Replace"
419        );
420
421        // The extension-facing action contract advertises "replace".
422        assert!(
423            HookKind::AfterToolCall
424                .allowed_action_names()
425                .contains(&"replace"),
426            "after_tool_call must advertise the 'replace' action"
427        );
428    }
429
430    /// Every variant's as_str round-trips through from_str.
431    #[test]
432    fn hook_kind_as_str_roundtrip() {
433        let all = [
434            HookKind::BeforeToolCall,
435            HookKind::AfterToolCall,
436            HookKind::BeforeMessage,
437            HookKind::OnMessageComplete,
438            HookKind::OnCompaction,
439            HookKind::OnSessionStart,
440            HookKind::OnSessionEnd,
441        ];
442        for kind in all {
443            let s = kind.as_str();
444            assert_eq!(
445                HookKind::from_str(s),
446                Some(kind),
447                "round-trip failed for {s}"
448            );
449        }
450    }
451
452    /// Unknown strings return None, not a panic or a default.
453    #[test]
454    fn hook_kind_from_str_unknown_returns_none() {
455        assert_eq!(HookKind::from_str(""), None);
456        assert_eq!(HookKind::from_str("BeforeToolCall"), None); // wrong case
457        assert_eq!(HookKind::from_str("on_crash"), None);
458    }
459
460    /// Serde uses snake_case via the attribute — spot-check two variants.
461    #[test]
462    fn hook_kind_serde_snake_case() {
463        let serialized = serde_json::to_string(&HookKind::BeforeToolCall).unwrap();
464        assert_eq!(serialized, r#""before_tool_call""#);
465
466        let back: HookKind = serde_json::from_str(r#""on_session_end""#).unwrap();
467        assert_eq!(back, HookKind::OnSessionEnd);
468    }
469
470    /// Each kind maps to the expected permission.
471    #[test]
472    fn hook_kind_required_permission() {
473        assert_eq!(
474            HookKind::BeforeToolCall.required_permission(),
475            Permission::ToolsIntercept
476        );
477        assert_eq!(
478            HookKind::AfterToolCall.required_permission(),
479            Permission::ToolsIntercept
480        );
481        assert_eq!(
482            HookKind::BeforeMessage.required_permission(),
483            Permission::LlmContent
484        );
485        assert_eq!(
486            HookKind::OnMessageComplete.required_permission(),
487            Permission::LlmContent
488        );
489        assert_eq!(
490            HookKind::OnCompaction.required_permission(),
491            Permission::LlmContent
492        );
493        assert_eq!(
494            HookKind::OnSessionStart.required_permission(),
495            Permission::SessionLifecycle
496        );
497        assert_eq!(
498            HookKind::OnSessionEnd.required_permission(),
499            Permission::SessionLifecycle
500        );
501    }
502
503    // ── HookEvent constructors ────────────────────────────────────────────────
504
505    #[test]
506    fn hook_event_before_tool_call() {
507        let input = json!({"path": "/tmp/foo"});
508        let ev = HookEvent::before_tool_call("read_file", input.clone());
509
510        assert_eq!(ev.kind, HookKind::BeforeToolCall);
511        assert_eq!(ev.tool_name.as_deref(), Some("read_file"));
512        assert_eq!(ev.tool_input.as_ref(), Some(&input));
513        assert!(ev.tool_output.is_none());
514        assert!(ev.message.is_none());
515        assert!(ev.session_id.is_none());
516        assert_eq!(ev.data, Value::Null);
517    }
518
519    #[test]
520    fn hook_event_after_tool_call() {
521        let input = json!({"query": "select 1"});
522        let ev = HookEvent::after_tool_call("sql_query", input.clone(), "1 row".to_string());
523
524        assert_eq!(ev.kind, HookKind::AfterToolCall);
525        assert_eq!(ev.tool_name.as_deref(), Some("sql_query"));
526        assert_eq!(ev.tool_input.as_ref(), Some(&input));
527        assert_eq!(ev.tool_output.as_deref(), Some("1 row"));
528        assert!(ev.message.is_none());
529        assert!(ev.session_id.is_none());
530    }
531
532    #[test]
533    fn hook_event_before_message() {
534        let ev = HookEvent::before_message("Hello, LLM");
535
536        assert_eq!(ev.kind, HookKind::BeforeMessage);
537        assert!(ev.tool_name.is_none());
538        assert!(ev.tool_input.is_none());
539        assert!(ev.tool_output.is_none());
540        assert_eq!(ev.message.as_deref(), Some("Hello, LLM"));
541        assert!(ev.session_id.is_none());
542    }
543
544    #[test]
545    fn hook_event_on_message_complete() {
546        let ev = HookEvent::on_message_complete("Done", json!({"content_block_count": 1}));
547
548        assert_eq!(ev.kind, HookKind::OnMessageComplete);
549        assert!(ev.tool_name.is_none());
550        assert!(ev.tool_input.is_none());
551        assert!(ev.tool_output.is_none());
552        assert_eq!(ev.message.as_deref(), Some("Done"));
553        assert_eq!(ev.data["content_block_count"], 1);
554        assert!(ev.session_id.is_none());
555    }
556
557    #[test]
558    fn hook_event_on_compaction() {
559        let ev = HookEvent::on_compaction(
560            "old-session",
561            "new-session",
562            "Summary",
563            7,
564            json!({"source": "manual"}),
565        );
566
567        assert_eq!(ev.kind, HookKind::OnCompaction);
568        assert_eq!(ev.message.as_deref(), Some("Summary"));
569        assert_eq!(ev.session_id.as_deref(), Some("new-session"));
570        assert_eq!(ev.data["old_session_id"], "old-session");
571        assert_eq!(ev.data["new_session_id"], "new-session");
572        assert_eq!(ev.data["message_count"], 7);
573        assert_eq!(ev.data["source"], "manual");
574        assert!(ev.transcript.is_none());
575    }
576
577    #[test]
578    fn hook_event_on_session_start() {
579        let ev = HookEvent::on_session_start("sess-abc-123");
580
581        assert_eq!(ev.kind, HookKind::OnSessionStart);
582        assert_eq!(ev.session_id.as_deref(), Some("sess-abc-123"));
583        assert!(ev.tool_name.is_none());
584        assert!(ev.message.is_none());
585    }
586
587    #[test]
588    fn hook_event_on_session_end() {
589        let ev = HookEvent::on_session_end("sess-abc-123", None);
590
591        assert_eq!(ev.kind, HookKind::OnSessionEnd);
592        assert_eq!(ev.session_id.as_deref(), Some("sess-abc-123"));
593        assert!(ev.tool_name.is_none());
594        assert!(ev.message.is_none());
595    }
596
597    /// HookEvent is round-trippable through JSON without loss.
598    #[test]
599    fn hook_event_serde_roundtrip() {
600        let ev = HookEvent::before_tool_call("bash", json!({"cmd": "ls"}));
601        let json = serde_json::to_string(&ev).unwrap();
602        let back: HookEvent = serde_json::from_str(&json).unwrap();
603
604        assert_eq!(back.kind, ev.kind);
605        assert_eq!(back.tool_name, ev.tool_name);
606        assert_eq!(back.tool_input, ev.tool_input);
607    }
608
609    // ── HookResult ────────────────────────────────────────────────────────────
610
611    /// Default is Continue.
612    #[test]
613    fn hook_result_default_is_continue() {
614        assert!(matches!(HookResult::default(), HookResult::Continue));
615    }
616
617    /// Block carries its reason through serialization.
618    #[test]
619    fn hook_result_block_serde() {
620        let r = HookResult::Block {
621            reason: "denied by policy".to_string(),
622        };
623        let json = serde_json::to_string(&r).unwrap();
624        // `tag = "action"` means the JSON object has {"action":"block","reason":"..."}
625        assert!(json.contains(r#""action":"block""#));
626        assert!(json.contains("denied by policy"));
627
628        let back: HookResult = serde_json::from_str(&json).unwrap();
629        assert!(matches!(back, HookResult::Block { reason } if reason == "denied by policy"));
630    }
631
632    /// Confirm carries its message through serialization.
633    #[test]
634    fn hook_result_confirm_serde() {
635        let r = HookResult::Confirm {
636            message: "Run this command?".to_string(),
637        };
638        let json = serde_json::to_string(&r).unwrap();
639        assert_eq!(
640            json,
641            r#"{"action":"confirm","message":"Run this command?"}"#
642        );
643
644        let back: HookResult = serde_json::from_str(&json).unwrap();
645        assert!(matches!(back, HookResult::Confirm { message } if message == "Run this command?"));
646    }
647
648    /// Modify carries replacement input through serialization.
649    #[test]
650    fn hook_result_modify_serde() {
651        let r = HookResult::Modify {
652            input: json!({"command": "echo safe"}),
653        };
654        let json = serde_json::to_string(&r).unwrap();
655        assert_eq!(
656            json,
657            r#"{"action":"modify","input":{"command":"echo safe"}}"#
658        );
659
660        let back: HookResult = serde_json::from_str(&json).unwrap();
661        assert!(
662            matches!(back, HookResult::Modify { input } if input == json!({"command": "echo safe"}))
663        );
664    }
665
666    /// Continue serialises as {"action":"continue"}.
667    #[test]
668    fn hook_result_continue_serde() {
669        let json = serde_json::to_string(&HookResult::Continue).unwrap();
670        assert_eq!(json, r#"{"action":"continue"}"#);
671    }
672}