Skip to main content

xz_agent_hooks/
contract.rs

1//! Hook events, outcomes, and merge semantics.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6/// Lifecycle kind a handler may subscribe to.
7///
8/// Names align with common coding-agent ecosystems (Claude Code / Codex aliases
9/// accepted by [`HookEventKind::parse`]).
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(rename_all = "PascalCase")]
12pub enum HookEventKind {
13    /// Before the model sees the user turn / system is finalized for the turn.
14    PrePrompt,
15    /// Before a tool executes — may deny or mutate arguments.
16    PreTool,
17    /// After a tool finishes (success or error).
18    PostTool,
19    /// When a permission dialog would be shown.
20    PermissionRequest,
21    /// Session began or was resumed.
22    SessionStart,
23    /// Session is ending.
24    SessionEnd,
25    /// Context compaction is about to run or just finished (product chooses phase).
26    Compact,
27    /// Agent mode changed.
28    ModeSwitch,
29    /// Model changed.
30    ModelSwitch,
31    /// A recoverable or fatal error occurred.
32    Error,
33    /// Generic / product-defined extension point.
34    Other,
35}
36
37impl HookEventKind {
38    /// Parse a kind from configuration strings (Claude aliases included).
39    pub fn parse(s: &str) -> Option<Self> {
40        match s.trim() {
41            "PrePrompt" | "pre_prompt" | "UserPromptSubmit" => Some(Self::PrePrompt),
42            "PreTool" | "PreToolUse" | "pre_tool" | "preToolUse" => Some(Self::PreTool),
43            "PostTool" | "PostToolUse" | "post_tool" | "postToolUse" => Some(Self::PostTool),
44            "PermissionRequest" | "permission_request" => Some(Self::PermissionRequest),
45            "SessionStart" | "OnSessionStart" | "session_start" | "startup" => {
46                Some(Self::SessionStart)
47            }
48            "SessionEnd" | "OnSessionEnd" | "session_end" => Some(Self::SessionEnd),
49            "Compact" | "OnCompact" | "PreCompact" | "PostCompact" | "compact" => {
50                Some(Self::Compact)
51            }
52            "ModeSwitch" | "OnModeSwitch" | "mode_switch" => Some(Self::ModeSwitch),
53            "ModelSwitch" | "OnModelSwitch" | "model_switch" => Some(Self::ModelSwitch),
54            "Error" | "OnError" | "error" => Some(Self::Error),
55            "Other" | "other" => Some(Self::Other),
56            _ => None,
57        }
58    }
59
60    /// Default merge mode for this event kind.
61    pub fn default_merge_mode(self) -> MergeMode {
62        match self {
63            Self::PreTool => MergeMode::PreTool,
64            Self::PostTool => MergeMode::PostTool,
65            Self::PermissionRequest => MergeMode::PermissionRequest,
66            _ => MergeMode::InjectOnly,
67        }
68    }
69}
70
71/// A single lifecycle event payload (product-agnostic).
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73pub struct HookEvent {
74    /// Event kind.
75    pub kind: HookEventKind,
76    /// Tool name when kind is PreTool / PostTool / PermissionRequest.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub tool: Option<String>,
79    /// Tool arguments as JSON (PreTool/PermissionRequest). Mutate replaces this object.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub args: Option<Value>,
82    /// Tool result text or JSON (PostTool).
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub result: Option<String>,
85    /// Free-form extra fields (session id, mode, model, paths, …).
86    #[serde(default)]
87    pub meta: Value,
88}
89
90impl Default for HookEvent {
91    fn default() -> Self {
92        Self::unit(HookEventKind::Other)
93    }
94}
95
96impl HookEvent {
97    /// Build a PreTool event.
98    pub fn pre_tool(tool: impl Into<String>, args: Value) -> Self {
99        Self {
100            kind: HookEventKind::PreTool,
101            tool: Some(tool.into()),
102            args: Some(args),
103            result: None,
104            meta: Value::Null,
105        }
106    }
107
108    /// Build a PostTool event.
109    pub fn post_tool(tool: impl Into<String>, result: impl Into<String>) -> Self {
110        Self {
111            kind: HookEventKind::PostTool,
112            tool: Some(tool.into()),
113            args: None,
114            result: Some(result.into()),
115            meta: Value::Null,
116        }
117    }
118
119    /// Build a PermissionRequest event.
120    pub fn permission_request(tool: impl Into<String>, args: Value) -> Self {
121        Self {
122            kind: HookEventKind::PermissionRequest,
123            tool: Some(tool.into()),
124            args: Some(args),
125            result: None,
126            meta: Value::Null,
127        }
128    }
129
130    /// Build a unit-ish lifecycle event.
131    pub fn unit(kind: HookEventKind) -> Self {
132        Self {
133            kind,
134            tool: None,
135            args: None,
136            result: None,
137            meta: Value::Null,
138        }
139    }
140
141    /// Attach meta object (builder style).
142    pub fn with_meta(mut self, meta: Value) -> Self {
143        self.meta = meta;
144        self
145    }
146}
147
148/// Where additional context should be applied by the product host.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub enum ContextChannel {
152    /// Inject before the next LLM call.
153    PrePrompt,
154    /// Attach near a tool result.
155    ToolPreface,
156    /// UI / observer only — must not enter the model context.
157    UiNotice,
158}
159
160/// Result of running one hook handler.
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162#[serde(tag = "type", rename_all = "snake_case")]
163pub enum HookOutcome {
164    /// No decision; continue.
165    Continue,
166    /// Model-visible (or UI) context injection.
167    AdditionalContext {
168        /// Text to inject.
169        text: String,
170        /// Target channel.
171        #[serde(default = "default_pre_prompt_channel")]
172        channel: ContextChannel,
173    },
174    /// Block the tool (or prompt, when product maps it).
175    Deny {
176        /// Human-readable reason for the model / UI.
177        reason: String,
178    },
179    /// Replace tool arguments (PreTool). Full object replacement.
180    MutateArgs {
181        /// New arguments JSON object.
182        args: Value,
183    },
184    /// Auto-allow permission prompt (PermissionRequest).
185    Allow,
186    /// Force interactive permission (PermissionRequest).
187    Ask,
188    /// Replace model-visible tool result (PostTool). Does **not** undo side effects.
189    ReplaceResult {
190        /// Replacement text.
191        text: String,
192    },
193}
194
195fn default_pre_prompt_channel() -> ContextChannel {
196    ContextChannel::PrePrompt
197}
198
199impl HookOutcome {
200    /// Convenience: additional context on the default PrePrompt channel.
201    pub fn context(text: impl Into<String>) -> Self {
202        Self::AdditionalContext {
203            text: text.into(),
204            channel: ContextChannel::PrePrompt,
205        }
206    }
207
208    /// Convenience: UI-only notice.
209    pub fn ui_notice(text: impl Into<String>) -> Self {
210        Self::AdditionalContext {
211            text: text.into(),
212            channel: ContextChannel::UiNotice,
213        }
214    }
215
216    /// Convenience: deny with reason.
217    pub fn deny(reason: impl Into<String>) -> Self {
218        Self::Deny {
219            reason: reason.into(),
220        }
221    }
222
223    /// Convenience: mutate args.
224    pub fn mutate_args(args: Value) -> Self {
225        Self::MutateArgs { args }
226    }
227
228    /// Convenience: replace tool result text.
229    pub fn replace_result(text: impl Into<String>) -> Self {
230        Self::ReplaceResult { text: text.into() }
231    }
232}
233
234/// How to combine a sequence of [`HookOutcome`] values.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
236pub enum MergeMode {
237    /// PreTool: deny short-circuits; mutate chains; contexts accumulate.
238    #[default]
239    PreTool,
240    /// PostTool: replace-result last-wins; deny → model-visible feedback; contexts accumulate.
241    PostTool,
242    /// PermissionRequest: deny wins; else allow if any Allow; else ask if any Ask.
243    PermissionRequest,
244    /// Inject-only events: only contexts / continue.
245    InjectOnly,
246}
247
248/// Aggregated effect after merging PreTool outcomes.
249#[derive(Debug, Clone, Default, PartialEq)]
250pub struct PreToolEffect {
251    /// If set, the tool must not run.
252    pub deny: Option<String>,
253    /// Final tool arguments after chained mutations (`None` = leave original).
254    pub args: Option<Value>,
255    /// Context injections in order.
256    pub contexts: Vec<(ContextChannel, String)>,
257}
258
259impl PreToolEffect {
260    /// Whether the tool is denied.
261    pub fn is_denied(&self) -> bool {
262        self.deny.is_some()
263    }
264
265    /// Final args to use, or `original` if no mutation.
266    pub fn final_args<'a>(&'a self, original: &'a Value) -> &'a Value {
267        self.args.as_ref().unwrap_or(original)
268    }
269
270    /// Owned final args.
271    pub fn into_final_args(self, original: Value) -> Result<Value, String> {
272        if let Some(reason) = self.deny {
273            return Err(reason);
274        }
275        Ok(self.args.unwrap_or(original))
276    }
277}
278
279/// Aggregated effect after merging PostTool outcomes.
280#[derive(Debug, Clone, Default, PartialEq)]
281pub struct PostToolEffect {
282    /// Last [`HookOutcome::ReplaceResult`] wins (model-visible replacement).
283    pub replace_result: Option<String>,
284    /// Deny-as-feedback reason (Codex-like); product may replace result with this text.
285    pub block_feedback: Option<String>,
286    /// Context injections in order.
287    pub contexts: Vec<(ContextChannel, String)>,
288}
289
290impl PostToolEffect {
291    /// Effective model-visible result given the original tool output.
292    ///
293    /// Priority: `replace_result` > `block_feedback` (as full replace) > `original`.
294    pub fn effective_result<'a>(&'a self, original: &'a str) -> &'a str {
295        if let Some(r) = self.replace_result.as_deref() {
296            return r;
297        }
298        if let Some(b) = self.block_feedback.as_deref() {
299            return b;
300        }
301        original
302    }
303}
304
305/// Permission hook decision after merge.
306#[derive(Debug, Clone, PartialEq, Eq, Default)]
307pub enum PermissionDecision {
308    /// No hook decided — product uses normal UI / policy.
309    #[default]
310    Unspecified,
311    /// Auto-approve.
312    Allow,
313    /// Auto-deny with reason.
314    Deny(String),
315    /// Force interactive ask.
316    Ask,
317}
318
319/// Aggregated effect after merging PermissionRequest outcomes.
320#[derive(Debug, Clone, Default, PartialEq)]
321pub struct PermissionEffect {
322    /// Merged decision.
323    pub decision: PermissionDecision,
324    /// Context injections in order.
325    pub contexts: Vec<(ContextChannel, String)>,
326}
327
328/// Aggregated inject-only effect.
329#[derive(Debug, Clone, Default, PartialEq)]
330pub struct InjectEffect {
331    /// Context injections in order.
332    pub contexts: Vec<(ContextChannel, String)>,
333}
334
335/// Mode-specific merged result.
336#[derive(Debug, Clone, PartialEq)]
337pub enum MergedEffect {
338    /// PreTool merge.
339    PreTool(PreToolEffect),
340    /// PostTool merge.
341    PostTool(PostToolEffect),
342    /// PermissionRequest merge.
343    Permission(PermissionEffect),
344    /// Inject-only merge.
345    Inject(InjectEffect),
346}
347
348impl MergedEffect {
349    /// Borrow contexts from any variant.
350    pub fn contexts(&self) -> &[(ContextChannel, String)] {
351        match self {
352            Self::PreTool(e) => &e.contexts,
353            Self::PostTool(e) => &e.contexts,
354            Self::Permission(e) => &e.contexts,
355            Self::Inject(e) => &e.contexts,
356        }
357    }
358
359    /// PreTool effect if this is PreTool mode.
360    pub fn as_pre_tool(&self) -> Option<&PreToolEffect> {
361        match self {
362            Self::PreTool(e) => Some(e),
363            _ => None,
364        }
365    }
366
367    /// PostTool effect if this is PostTool mode.
368    pub fn as_post_tool(&self) -> Option<&PostToolEffect> {
369        match self {
370            Self::PostTool(e) => Some(e),
371            _ => None,
372        }
373    }
374
375    /// Permission effect if this is PermissionRequest mode.
376    pub fn as_permission(&self) -> Option<&PermissionEffect> {
377        match self {
378            Self::Permission(e) => Some(e),
379            _ => None,
380        }
381    }
382}
383
384/// Merge a list of outcomes for the given mode into a typed [`MergedEffect`].
385///
386/// # PreTool
387/// - First [`HookOutcome::Deny`] wins and **stops** further outcomes in this list.
388/// - [`HookOutcome::MutateArgs`] applies left-to-right.
389/// - Contexts append in order.
390///
391/// # PostTool
392/// - Last [`HookOutcome::ReplaceResult`] wins.
393/// - [`HookOutcome::Deny`] becomes `block_feedback` (does not undo side effects).
394///
395/// # PermissionRequest
396/// - Any Deny wins (first).
397/// - Else any Allow → Allow.
398/// - Else any Ask → Ask.
399/// - Else Unspecified.
400pub fn merge_outcomes(mode: MergeMode, outcomes: &[HookOutcome]) -> MergedEffect {
401    match mode {
402        MergeMode::PreTool => MergedEffect::PreTool(merge_pre_tool(outcomes)),
403        MergeMode::PostTool => MergedEffect::PostTool(merge_post_tool(outcomes)),
404        MergeMode::PermissionRequest => MergedEffect::Permission(merge_permission(outcomes)),
405        MergeMode::InjectOnly => MergedEffect::Inject(merge_inject(outcomes)),
406    }
407}
408
409/// Merge PreTool outcomes only.
410pub fn merge_pre_tool(outcomes: &[HookOutcome]) -> PreToolEffect {
411    let mut effect = PreToolEffect::default();
412    for o in outcomes {
413        match o {
414            HookOutcome::Deny { reason } => {
415                effect.deny = Some(reason.clone());
416                break;
417            }
418            HookOutcome::MutateArgs { args } => {
419                effect.args = Some(args.clone());
420            }
421            HookOutcome::AdditionalContext { text, channel } => {
422                effect.contexts.push((*channel, text.clone()));
423            }
424            HookOutcome::Continue
425            | HookOutcome::Allow
426            | HookOutcome::Ask
427            | HookOutcome::ReplaceResult { .. } => {}
428        }
429    }
430    effect
431}
432
433/// Merge PostTool outcomes only.
434pub fn merge_post_tool(outcomes: &[HookOutcome]) -> PostToolEffect {
435    let mut effect = PostToolEffect::default();
436    for o in outcomes {
437        match o {
438            HookOutcome::Deny { reason } => {
439                effect.block_feedback = Some(reason.clone());
440            }
441            HookOutcome::ReplaceResult { text } => {
442                effect.replace_result = Some(text.clone());
443            }
444            HookOutcome::AdditionalContext { text, channel } => {
445                effect.contexts.push((*channel, text.clone()));
446            }
447            HookOutcome::Continue
448            | HookOutcome::MutateArgs { .. }
449            | HookOutcome::Allow
450            | HookOutcome::Ask => {}
451        }
452    }
453    effect
454}
455
456/// Merge PermissionRequest outcomes only.
457///
458/// Priority: Deny > Allow > Ask > Unspecified.
459pub fn merge_permission(outcomes: &[HookOutcome]) -> PermissionEffect {
460    let mut effect = PermissionEffect::default();
461    let mut saw_allow = false;
462    let mut saw_ask = false;
463    for o in outcomes {
464        match o {
465            HookOutcome::Deny { reason } => {
466                effect.decision = PermissionDecision::Deny(reason.clone());
467                // still collect remaining contexts after? stop like PreTool
468                break;
469            }
470            HookOutcome::Allow => saw_allow = true,
471            HookOutcome::Ask => saw_ask = true,
472            HookOutcome::AdditionalContext { text, channel } => {
473                effect.contexts.push((*channel, text.clone()));
474            }
475            _ => {}
476        }
477    }
478    if matches!(effect.decision, PermissionDecision::Unspecified) {
479        if saw_allow {
480            effect.decision = PermissionDecision::Allow;
481        } else if saw_ask {
482            effect.decision = PermissionDecision::Ask;
483        }
484    }
485    effect
486}
487
488/// Merge inject-only outcomes.
489pub fn merge_inject(outcomes: &[HookOutcome]) -> InjectEffect {
490    let mut effect = InjectEffect::default();
491    for o in outcomes {
492        if let HookOutcome::AdditionalContext { text, channel } = o {
493            effect.contexts.push((*channel, text.clone()));
494        }
495    }
496    effect
497}
498
499/// Apply chained PreTool mutations to a starting args object.
500pub fn apply_pre_tool_args(original: &Value, effect: &PreToolEffect) -> Value {
501    effect
502        .args
503        .clone()
504        .unwrap_or_else(|| original.clone())
505}
506
507/// Collect context texts for a channel.
508pub fn contexts_for_channel(
509    contexts: &[(ContextChannel, String)],
510    channel: ContextChannel,
511) -> Vec<&str> {
512    contexts
513        .iter()
514        .filter(|(c, _)| *c == channel)
515        .map(|(_, t)| t.as_str())
516        .collect()
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use serde_json::json;
523
524    #[test]
525    fn parse_claude_aliases() {
526        assert_eq!(
527            HookEventKind::parse("PreToolUse"),
528            Some(HookEventKind::PreTool)
529        );
530        assert_eq!(
531            HookEventKind::parse("PostToolUse"),
532            Some(HookEventKind::PostTool)
533        );
534        assert_eq!(
535            HookEventKind::parse("SessionStart"),
536            Some(HookEventKind::SessionStart)
537        );
538        assert_eq!(HookEventKind::parse("UserPromptSubmit"), Some(HookEventKind::PrePrompt));
539        assert_eq!(HookEventKind::parse("nope"), None);
540    }
541
542    #[test]
543    fn default_merge_mode_mapping() {
544        assert_eq!(
545            HookEventKind::PreTool.default_merge_mode(),
546            MergeMode::PreTool
547        );
548        assert_eq!(
549            HookEventKind::SessionStart.default_merge_mode(),
550            MergeMode::InjectOnly
551        );
552    }
553
554    #[test]
555    fn pre_tool_deny_short_circuits() {
556        let outcomes = [
557            HookOutcome::mutate_args(json!({"command": "echo a"})),
558            HookOutcome::deny("nope"),
559            HookOutcome::mutate_args(json!({"command": "echo b"})),
560        ];
561        let e = merge_pre_tool(&outcomes);
562        assert_eq!(e.deny.as_deref(), Some("nope"));
563        assert_eq!(e.args, Some(json!({"command": "echo a"})));
564        assert!(e.is_denied());
565        assert!(e.into_final_args(json!({})).is_err());
566    }
567
568    #[test]
569    fn pre_tool_mutate_chains() {
570        let outcomes = [
571            HookOutcome::mutate_args(json!({"command": "git status"})),
572            HookOutcome::mutate_args(json!({"command": "rtk git status"})),
573            HookOutcome::context("note"),
574        ];
575        let e = merge_pre_tool(&outcomes);
576        assert!(!e.is_denied());
577        assert_eq!(e.args, Some(json!({"command": "rtk git status"})));
578        assert_eq!(e.contexts.len(), 1);
579        assert_eq!(
580            apply_pre_tool_args(&json!({"command": "raw"}), &e),
581            json!({"command": "rtk git status"})
582        );
583        let owned = e
584            .clone()
585            .into_final_args(json!({"command": "raw"}));
586        assert!(matches!(owned, Ok(v) if v == json!({"command": "rtk git status"})));
587    }
588
589    #[test]
590    fn pre_tool_no_mutate_keeps_original_ref() {
591        let e = PreToolEffect::default();
592        let original = json!({"a": 1});
593        assert_eq!(e.final_args(&original), &original);
594    }
595
596    #[test]
597    fn post_tool_replace_last_wins() {
598        let outcomes = [
599            HookOutcome::replace_result("first"),
600            HookOutcome::replace_result("second"),
601            HookOutcome::context("ctx"),
602        ];
603        let e = merge_post_tool(&outcomes);
604        assert_eq!(e.replace_result.as_deref(), Some("second"));
605        assert_eq!(e.effective_result("orig"), "second");
606        assert_eq!(e.contexts.len(), 1);
607    }
608
609    #[test]
610    fn post_tool_deny_becomes_block_feedback() {
611        let outcomes = [HookOutcome::deny("needs review")];
612        let e = merge_post_tool(&outcomes);
613        assert_eq!(e.block_feedback.as_deref(), Some("needs review"));
614        assert_eq!(e.effective_result("orig"), "needs review");
615    }
616
617    #[test]
618    fn post_tool_replace_beats_block_feedback() {
619        let outcomes = [
620            HookOutcome::deny("block"),
621            HookOutcome::replace_result("replaced"),
622        ];
623        let e = merge_post_tool(&outcomes);
624        assert_eq!(e.effective_result("orig"), "replaced");
625    }
626
627    #[test]
628    fn permission_deny_beats_allow() {
629        let outcomes = [
630            HookOutcome::Allow,
631            HookOutcome::deny("policy"),
632            HookOutcome::Ask,
633        ];
634        let e = merge_permission(&outcomes);
635        assert_eq!(e.decision, PermissionDecision::Deny("policy".into()));
636    }
637
638    #[test]
639    fn permission_allow_over_ask() {
640        let outcomes = [HookOutcome::Ask, HookOutcome::Allow];
641        let e = merge_permission(&outcomes);
642        assert_eq!(e.decision, PermissionDecision::Allow);
643    }
644
645    #[test]
646    fn permission_ask_only() {
647        let e = merge_permission(&[HookOutcome::Ask]);
648        assert_eq!(e.decision, PermissionDecision::Ask);
649    }
650
651    #[test]
652    fn permission_unspecified() {
653        let e = merge_permission(&[HookOutcome::Continue]);
654        assert_eq!(e.decision, PermissionDecision::Unspecified);
655    }
656
657    #[test]
658    fn inject_filters_non_context() {
659        let e = merge_inject(&[
660            HookOutcome::Continue,
661            HookOutcome::deny("x"),
662            HookOutcome::ui_notice("ui"),
663            HookOutcome::context("model"),
664        ]);
665        assert_eq!(e.contexts.len(), 2);
666        assert_eq!(
667            contexts_for_channel(&e.contexts, ContextChannel::UiNotice),
668            vec!["ui"]
669        );
670        assert_eq!(
671            contexts_for_channel(&e.contexts, ContextChannel::PrePrompt),
672            vec!["model"]
673        );
674    }
675
676    #[test]
677    fn merge_outcomes_dispatch() {
678        let m = merge_outcomes(MergeMode::PreTool, &[HookOutcome::deny("d")]);
679        assert!(m.as_pre_tool().is_some_and(|e| e.is_denied()));
680        let m = merge_outcomes(MergeMode::PostTool, &[HookOutcome::replace_result("r")]);
681        assert!(m.as_post_tool().is_some_and(|e| e.replace_result.as_deref() == Some("r")));
682        let m = merge_outcomes(MergeMode::PermissionRequest, &[HookOutcome::Allow]);
683        assert!(m
684            .as_permission()
685            .is_some_and(|e| e.decision == PermissionDecision::Allow));
686    }
687
688    #[test]
689    fn event_builders_and_serde() {
690        let ev = HookEvent::pre_tool("shell", json!({"command": "ls"}))
691            .with_meta(json!({"session": "s1"}));
692        let Ok(s) = serde_json::to_string(&ev) else {
693            panic!("serialize failed");
694        };
695        let Ok(back) = serde_json::from_str::<HookEvent>(&s) else {
696            panic!("deserialize failed");
697        };
698        assert_eq!(back.kind, HookEventKind::PreTool);
699        assert_eq!(back.tool.as_deref(), Some("shell"));
700        assert_eq!(back.meta.get("session").and_then(|v| v.as_str()), Some("s1"));
701
702        let p = HookEvent::permission_request("shell", json!({}));
703        assert_eq!(p.kind, HookEventKind::PermissionRequest);
704
705        let post = HookEvent::post_tool("shell", "ok");
706        assert_eq!(post.result.as_deref(), Some("ok"));
707    }
708
709    #[test]
710    fn outcome_serde_roundtrip() {
711        let outcomes = [
712            HookOutcome::Continue,
713            HookOutcome::context("c"),
714            HookOutcome::deny("d"),
715            HookOutcome::mutate_args(json!({"x": 1})),
716            HookOutcome::Allow,
717            HookOutcome::Ask,
718            HookOutcome::replace_result("r"),
719        ];
720        for o in &outcomes {
721            let Ok(s) = serde_json::to_string(o) else {
722                panic!("ser");
723            };
724            let Ok(back) = serde_json::from_str::<HookOutcome>(&s) else {
725                panic!("de {s}");
726            };
727            assert_eq!(&back, o);
728        }
729    }
730}