Skip to main content

harn_vm/orchestration/
hooks.rs

1//! Runtime lifecycle hooks — tool, agent-turn, and worker interception.
2
3use std::cell::RefCell;
4use std::future::Future;
5use std::sync::Arc;
6
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9
10use harn_parser::diagnostic_codes::Code;
11
12use crate::agent_events::WorkerEvent;
13use crate::llm::helpers::{ReminderPropagate, ReminderRoleHint, ReminderSource, SystemReminder};
14use crate::value::{VmClosure, VmError, VmValue};
15
16tokio::task_local! {
17    static HOOK_REMINDER_REPORTS_TASK: Arc<parking_lot::Mutex<Vec<serde_json::Value>>>;
18}
19
20fn record_hook_reminder_report(report: serde_json::Value) {
21    let _ = HOOK_REMINDER_REPORTS_TASK.try_with(|reports| reports.lock().push(report));
22}
23
24pub async fn scope_hook_reminder_reports<F, T>(future: F) -> (T, Vec<serde_json::Value>)
25where
26    F: Future<Output = T>,
27{
28    let reports = Arc::new(parking_lot::Mutex::new(Vec::new()));
29    let output = HOOK_REMINDER_REPORTS_TASK
30        .scope(reports.clone(), future)
31        .await;
32    let reports = std::mem::take(&mut *reports.lock());
33    (output, reports)
34}
35
36/// High-level grouping for a hook event. Drives `parse_session_event` /
37/// `parse_provider_event` routing, reminder support, and the
38/// `clear_session_hooks` filter, so each behavior derives from the
39/// variant's declared kind rather than a hand-maintained match arm.
40#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
41pub enum HookEventKind {
42    /// Tool-call lifecycle (PreToolUse / PostToolUse).
43    Tool,
44    /// Agent-turn lifecycle (PreAgentTurn / PostAgentTurn).
45    AgentTurn,
46    /// Worker lifecycle — the only kind that rejects reminder effects.
47    Worker,
48    /// Step lifecycle (PreStep / PostStep).
49    Step,
50    /// Notification surfaces (budget / approval / handoff / persona).
51    Notification,
52    /// Session-level lifecycle. Eligible for `parse_session_event` and
53    /// scoped clearing via `clear_session_hooks`.
54    Session,
55}
56
57/// `hook_events!` — single source of truth for `HookEvent`. Emits the
58/// enum, `as_str`, `kind`, `supports_reminder_effects`,
59/// `is_session_lifecycle`, `parse_session_event`, `parse_provider_event`,
60/// `from_worker_event`, and the canonical `ALL` slice. Adding a variant
61/// requires only one new line — every dispatch table is derived.
62///
63/// Each entry has the form
64/// `Variant { kind: Kind [, provider_parse: true] [, aliases: [..]] }`:
65/// `provider_parse` flags variants accepted directly by
66/// `parse_provider_event` (Worker variants are accepted by virtue of
67/// `kind: Worker`); `aliases` lists explicit extra wire names beyond
68/// the auto-derived `snake_case` of the variant identifier.
69macro_rules! hook_events {
70    (
71        $(
72            $(#[$attr:meta])*
73            $variant:ident {
74                kind: $kind:ident
75                $(, provider_parse: $provider_parse:literal)?
76                $(, aliases: [$($alias:literal),* $(,)?])?
77                $(,)?
78            }
79        ),* $(,)?
80    ) => {
81        #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
82        pub enum HookEvent {
83            $(
84                $(#[$attr])*
85                $variant,
86            )*
87        }
88
89        impl HookEvent {
90            /// Canonical PascalCase wire name.
91            pub const fn as_str(self) -> &'static str {
92                match self {
93                    $(Self::$variant => stringify!($variant),)*
94                }
95            }
96
97            /// High-level grouping — drives every other routing predicate.
98            pub const fn kind(self) -> HookEventKind {
99                match self {
100                    $(Self::$variant => HookEventKind::$kind,)*
101                }
102            }
103
104            /// Reminder effects are rejected by Worker events because
105            /// they fire from contexts without a pending tool-call /
106            /// transcript slot for the reminder to attach to.
107            pub const fn supports_reminder_effects(self) -> bool {
108                !matches!(self.kind(), HookEventKind::Worker)
109            }
110
111            /// Whether `clear_session_hooks` and `parse_session_event`
112            /// own this variant.
113            pub const fn is_session_lifecycle(self) -> bool {
114                matches!(self.kind(), HookEventKind::Session)
115            }
116
117            /// All variants in declaration order. Stable enough that
118            /// `parse_*` functions can iterate it.
119            pub const ALL: &'static [Self] = &[$(Self::$variant,)*];
120
121            /// Whether this variant is accepted directly by
122            /// `parse_provider_event` (independent of the
123            /// session-parser fallback). Worker variants are accepted
124            /// implicitly by their kind.
125            const fn in_provider_parse(self) -> bool {
126                match self {
127                    $(Self::$variant => hook_events!(@or_false $($provider_parse)?),)*
128                }
129            }
130
131            /// Explicit non-snake-case aliases declared on the variant.
132            const fn extra_aliases(self) -> &'static [&'static str] {
133                match self {
134                    $(Self::$variant => &[$($($alias),*)?],)*
135                }
136            }
137
138            /// Parse a session-level hook event name. Returns `Err` for
139            /// unknown or non-session events; persona/tool/worker events
140            /// are intentionally rejected so each registration surface
141            /// owns its own event set. Accepts the canonical PascalCase
142            /// spelling, its auto-derived snake_case, and any explicit
143            /// `aliases: [...]` declared in `hook_events!`.
144            pub fn parse_session_event(name: &str) -> Result<Self, String> {
145                let trimmed = name.trim();
146                for &event in Self::ALL.iter().filter(|e| e.is_session_lifecycle()) {
147                    if event_matches_name(event, trimmed) {
148                        return Ok(event);
149                    }
150                }
151                Err(format!("unknown session hook event `{trimmed}`"))
152            }
153
154            /// Parse a reminder-provider event name. Accepts Worker
155            /// events, any variant flagged `provider_parse: true` in
156            /// `hook_events!`, and (by fallback) every session event.
157            pub fn parse_provider_event(name: &str) -> Result<Self, String> {
158                let trimmed = name.trim();
159                for &event in Self::ALL.iter().filter(|e| {
160                    matches!(e.kind(), HookEventKind::Worker) || e.in_provider_parse()
161                }) {
162                    if event_matches_name(event, trimmed) {
163                        return Ok(event);
164                    }
165                }
166                Self::parse_session_event(trimmed)
167                    .map_err(|_| format!("unknown reminder provider event `{trimmed}`"))
168            }
169        }
170    };
171    (@or_false $val:literal) => { $val };
172    (@or_false) => { false };
173}
174
175hook_events! {
176    PreToolUse              { kind: Tool },
177    PostToolUse             { kind: Tool, provider_parse: true },
178    PreAgentTurn            { kind: AgentTurn },
179    PostAgentTurn           { kind: AgentTurn, provider_parse: true },
180    WorkerSpawned           { kind: Worker },
181    WorkerProgressed        { kind: Worker },
182    WorkerWaitingForInput   { kind: Worker },
183    WorkerSuspended         { kind: Worker },
184    WorkerResumed           { kind: Worker },
185    WorkerCompleted         { kind: Worker },
186    WorkerFailed            { kind: Worker },
187    WorkerStopped           { kind: Worker },
188    WorkerCancelled         { kind: Worker },
189    PreStep                 { kind: Step },
190    PostStep                { kind: Step, provider_parse: true },
191    OnBudgetThreshold       { kind: Notification, provider_parse: true },
192    OnApprovalRequested     { kind: Notification },
193    OnHandoffEmitted        { kind: Notification },
194    OnPersonaPaused         { kind: Notification },
195    OnPersonaResumed        { kind: Notification },
196    SessionStart            { kind: Session },
197    SessionEnd              { kind: Session },
198    UserPromptSubmit        { kind: Session },
199    PreCompact              { kind: Session },
200    PostCompact             { kind: Session },
201    PostTurn                { kind: Session },
202    PermissionAsked         { kind: Session },
203    PermissionReplied       { kind: Session },
204    FileEdited              { kind: Session },
205    SessionError            { kind: Session, aliases: ["error"] },
206    SessionIdle             { kind: Session },
207    PreFinish               { kind: Session },
208    PostFinish              { kind: Session },
209    OnUnsettledDetected     { kind: Session },
210    PreSuspend              { kind: Session },
211    PostSuspend             { kind: Session },
212    PreResume               { kind: Session },
213    PostResume              { kind: Session },
214    PreDrain                { kind: Session },
215    PostDrain               { kind: Session },
216    OnDrainDecision         { kind: Session },
217    /// Fired by `__agent_loop_checkpoint(kind, ...)` at every safe
218    /// injection seam in the agent loop. Pattern-match on `payload.kind`
219    /// to subscribe to specific seams (e.g. `kind=="pre_tool_dispatch"`)
220    /// or use `*` to observe every checkpoint pass.
221    LoopCheckpoint          { kind: Session },
222}
223
224impl HookEvent {
225    pub fn from_worker_event(event: WorkerEvent) -> Self {
226        match event {
227            WorkerEvent::WorkerSpawned => Self::WorkerSpawned,
228            WorkerEvent::WorkerProgressed => Self::WorkerProgressed,
229            WorkerEvent::WorkerWaitingForInput => Self::WorkerWaitingForInput,
230            WorkerEvent::WorkerSuspended => Self::WorkerSuspended,
231            WorkerEvent::WorkerResumed => Self::WorkerResumed,
232            WorkerEvent::WorkerCompleted => Self::WorkerCompleted,
233            WorkerEvent::WorkerFailed => Self::WorkerFailed,
234            WorkerEvent::WorkerStopped => Self::WorkerStopped,
235            WorkerEvent::WorkerCancelled => Self::WorkerCancelled,
236        }
237    }
238}
239
240fn pascal_to_snake_buf(pascal: &str, buf: &mut String) {
241    buf.clear();
242    buf.reserve(pascal.len() + 4);
243    for (i, c) in pascal.char_indices() {
244        if c.is_ascii_uppercase() {
245            if i > 0 {
246                buf.push('_');
247            }
248            buf.push(c.to_ascii_lowercase());
249        } else {
250            buf.push(c);
251        }
252    }
253}
254
255fn event_matches_name(event: HookEvent, candidate: &str) -> bool {
256    let pascal = event.as_str();
257    if candidate == pascal {
258        return true;
259    }
260    if event.extra_aliases().contains(&candidate) {
261        return true;
262    }
263    let mut snake = String::new();
264    pascal_to_snake_buf(pascal, &mut snake);
265    candidate == snake
266}
267
268/// Control flow returned by a session-level lifecycle hook.
269///
270/// Most session events are advisory (`Allow`). Veto-capable events —
271/// `UserPromptSubmit`, `PreCompact`, plus the lifecycle gates
272/// `PreSuspend` / `PreResume` / `PreDrain` / `OnDrainDecision` /
273/// `OnUnsettledDetected` — accept `Block`. `PermissionAsked` accepts a
274/// `Decision` short-circuit so hooks can override the dynamic
275/// permission policy entirely. Lifecycle gates that support payload
276/// rewriting (PreSuspend / PreResume / PreDrain / OnDrainDecision /
277/// OnUnsettledDetected) accept `Modify { payload }` to amend the
278/// dispatched event — the dispatcher applies the modified payload
279/// before resuming the lifecycle step. `PreFinish` rejects `Block`
280/// explicitly; the runtime surfaces a dedicated error pointing at
281/// `OnFinish.block_until_settled`.
282#[derive(Clone, Debug)]
283pub enum HookControl {
284    Allow,
285    Block {
286        reason: String,
287    },
288    Decision {
289        kind: String,
290        reason: Option<String>,
291    },
292    Modify {
293        payload: serde_json::Value,
294    },
295}
296
297impl HookControl {
298    pub fn as_str(&self) -> &'static str {
299        match self {
300            Self::Allow => "allow",
301            Self::Block { .. } => "block",
302            Self::Modify { .. } => "modify",
303            Self::Decision { kind, .. } => match kind.as_str() {
304                "allow" => "decision_allow",
305                "deny" => "decision_deny",
306                "ask" => "decision_ask",
307                _ => "decision_unknown",
308            },
309        }
310    }
311}
312
313pub type ReminderSpec = SystemReminder;
314
315/// Side effect emitted by a hook in addition to any control/action
316/// result. Reminder effects are appended to the active session
317/// transcript's pending reminder event set.
318#[derive(Clone, Debug)]
319pub enum HookEffect {
320    Reminder(ReminderSpec),
321}
322
323#[derive(Clone, Debug)]
324struct HookOutcome {
325    control: HookControl,
326    effects: Vec<HookEffect>,
327}
328
329/// Action returned by a PreToolUse hook.
330#[derive(Clone, Debug)]
331pub enum PreToolAction {
332    /// Allow the tool call to proceed unchanged.
333    Allow,
334    /// Deny the tool call with an explanation.
335    Deny(String),
336    /// Allow but replace the arguments.
337    Modify(serde_json::Value),
338    /// Inject a reminder, then continue with the inner pre-tool action.
339    Reminder {
340        spec: ReminderSpec,
341        then: Box<PreToolAction>,
342    },
343}
344
345/// Action returned by a PostToolUse hook.
346#[derive(Clone, Debug)]
347pub enum PostToolAction {
348    /// Pass the result through unchanged.
349    Pass,
350    /// Replace the result text.
351    Modify(String),
352    /// Inject a reminder, then continue with the inner post-tool action.
353    Reminder {
354        spec: ReminderSpec,
355        then: Box<PostToolAction>,
356    },
357}
358
359/// Callback types for legacy tool lifecycle hooks.
360pub type PreToolHookFn = Arc<dyn Fn(&str, &serde_json::Value) -> PreToolAction + Send + Sync>;
361pub type PostToolHookFn = Arc<dyn Fn(&str, &str) -> PostToolAction + Send + Sync>;
362
363/// A registered tool hook with a name pattern and callbacks.
364#[derive(Clone)]
365pub struct ToolHook {
366    /// Glob-style pattern matched against tool names (e.g. `"*"`, `"exec*"`, `"read_file"`).
367    pub pattern: String,
368    /// Called before tool execution. Return `Deny` to reject, `Modify` to rewrite args.
369    pub pre: Option<PreToolHookFn>,
370    /// Called after tool execution with the result text. Return `Modify` to rewrite.
371    pub post: Option<PostToolHookFn>,
372}
373
374impl std::fmt::Debug for ToolHook {
375    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
376        f.debug_struct("ToolHook")
377            .field("pattern", &self.pattern)
378            .field("has_pre", &self.pre.is_some())
379            .field("has_post", &self.post.is_some())
380            .finish()
381    }
382}
383
384#[derive(Clone)]
385enum PatternMatcher {
386    ToolNameGlob(String),
387    EventExpression {
388        source: String,
389        expression: EventPatternExpression,
390    },
391}
392
393#[derive(Clone)]
394enum EventPatternExpression {
395    MatchAll,
396    NeverMatch,
397    Regex { path: String, regex: Regex },
398    Equals { path: String, value: String },
399    NotEquals { path: String, value: String },
400    PathTruthy(String),
401    ToolNameGlob(String),
402}
403
404impl std::fmt::Debug for PatternMatcher {
405    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
406        match self {
407            Self::ToolNameGlob(pattern) => f.debug_tuple("ToolNameGlob").field(pattern).finish(),
408            Self::EventExpression { source, expression } => f
409                .debug_struct("EventExpression")
410                .field("source", source)
411                .field("expression", expression)
412                .finish(),
413        }
414    }
415}
416
417impl std::fmt::Debug for EventPatternExpression {
418    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
419        match self {
420            Self::MatchAll => f.write_str("MatchAll"),
421            Self::NeverMatch => f.write_str("NeverMatch"),
422            Self::Regex { path, regex } => f
423                .debug_struct("Regex")
424                .field("path", path)
425                .field("regex", &regex.as_str())
426                .finish(),
427            Self::Equals { path, value } => f
428                .debug_struct("Equals")
429                .field("path", path)
430                .field("value", value)
431                .finish(),
432            Self::NotEquals { path, value } => f
433                .debug_struct("NotEquals")
434                .field("path", path)
435                .field("value", value)
436                .finish(),
437            Self::PathTruthy(path) => f.debug_tuple("PathTruthy").field(path).finish(),
438            Self::ToolNameGlob(pattern) => f.debug_tuple("ToolNameGlob").field(pattern).finish(),
439        }
440    }
441}
442
443#[derive(Clone)]
444enum RuntimeHookHandler {
445    NativePreTool(PreToolHookFn),
446    NativePostTool(PostToolHookFn),
447    Vm {
448        handler_name: String,
449        callable: crate::value::VmCallable,
450    },
451}
452
453impl std::fmt::Debug for RuntimeHookHandler {
454    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
455        match self {
456            Self::NativePreTool(_) => f.write_str("NativePreTool(..)"),
457            Self::NativePostTool(_) => f.write_str("NativePostTool(..)"),
458            Self::Vm { handler_name, .. } => f
459                .debug_struct("Vm")
460                .field("handler_name", handler_name)
461                .finish(),
462        }
463    }
464}
465
466#[derive(Clone, Debug)]
467struct RuntimeHook {
468    event: HookEvent,
469    matcher: PatternMatcher,
470    handler: RuntimeHookHandler,
471}
472
473#[derive(Clone, Debug)]
474pub struct VmLifecycleHookInvocation {
475    callable: crate::value::VmCallable,
476    pub handler_name: String,
477}
478
479impl VmLifecycleHookInvocation {
480    /// Resolve this invocation's handler closure against `vm`, loading the
481    /// handler's module on demand for a lazy manifest hook (a cache hit
482    /// when `vm` already imported the graph).
483    pub async fn resolve(&self, vm: &mut crate::vm::Vm) -> Result<Arc<VmClosure>, VmError> {
484        vm.resolve_callable(&self.callable).await.map_err(|error| {
485            VmError::Runtime(format!(
486                "failed to resolve lifecycle hook '{}': {error}",
487                self.handler_name
488            ))
489        })
490    }
491}
492
493#[derive(Clone, Debug)]
494struct VmLifecycleHookRegistration {
495    handler_name: String,
496    callable: crate::value::VmCallable,
497}
498
499thread_local! {
500    static RUNTIME_HOOKS: RefCell<Vec<RuntimeHook>> = const { RefCell::new(Vec::new()) };
501    /// Pending `FileEdited` notifications queued from sync builtins
502    /// (e.g. `write_file`). Drained at safe async boundaries — typically
503    /// at the start of each agent-loop turn — so VM closure handlers
504    /// can run inside an async builtin context.
505    static FILE_EDIT_QUEUE: RefCell<Vec<FileEditedNotification>> = const { RefCell::new(Vec::new()) };
506    /// Optional singleton PreToolUse hook owned by stdlib opt-in surfaces
507    /// (currently the `path_scope_guard` from #2221). Kept separate from
508    /// `RUNTIME_HOOKS` so the runtime can swap or clear it without
509    /// touching user-registered hooks.
510    static SINGLETON_PRE_TOOL_HOOK: RefCell<Option<PreToolHookFn>> = const { RefCell::new(None) };
511}
512
513/// Install (or replace, with `None`) the singleton runtime pre-tool
514/// hook. The singleton runs ahead of user-registered hooks so a tagged
515/// deny lands in the reminder path before any other hook fires.
516pub fn set_singleton_pre_tool_hook(hook: Option<PreToolHookFn>) {
517    SINGLETON_PRE_TOOL_HOOK.with(|slot| *slot.borrow_mut() = hook);
518}
519
520pub fn singleton_pre_tool_hook() -> Option<PreToolHookFn> {
521    SINGLETON_PRE_TOOL_HOOK.with(|slot| slot.borrow().clone())
522}
523
524#[derive(Clone, Debug)]
525pub struct FileEditedNotification {
526    pub path: String,
527    pub metadata: serde_json::Value,
528}
529
530/// Queue a file-edited notification. Safe to call from sync contexts.
531pub fn queue_file_edited(path: &str, metadata: serde_json::Value) {
532    FILE_EDIT_QUEUE.with(|queue| {
533        queue.borrow_mut().push(FileEditedNotification {
534            path: path.to_string(),
535            metadata,
536        });
537    });
538}
539
540/// Drain queued file-edited notifications. Returns them in the order
541/// they were queued; the caller is responsible for invoking matching
542/// `FileEdited` hooks (async context required).
543pub fn drain_file_edits() -> Vec<FileEditedNotification> {
544    FILE_EDIT_QUEUE.with(|queue| std::mem::take(&mut *queue.borrow_mut()))
545}
546
547pub fn clear_file_edit_queue() {
548    FILE_EDIT_QUEUE.with(|queue| queue.borrow_mut().clear());
549}
550
551// The workspace-wide name matcher (re-exported as
552// `crate::orchestration::glob_match` for the tool-surface, permission, and
553// step-runtime call sites). Semantics live in `harn-glob`.
554pub(crate) use harn_glob::match_name as glob_match;
555
556pub fn register_tool_hook(hook: ToolHook) {
557    if let Some(pre) = hook.pre {
558        RUNTIME_HOOKS.with(|hooks| {
559            hooks.borrow_mut().push(RuntimeHook {
560                event: HookEvent::PreToolUse,
561                matcher: PatternMatcher::ToolNameGlob(hook.pattern.clone()),
562                handler: RuntimeHookHandler::NativePreTool(pre),
563            });
564        });
565    }
566    if let Some(post) = hook.post {
567        RUNTIME_HOOKS.with(|hooks| {
568            hooks.borrow_mut().push(RuntimeHook {
569                event: HookEvent::PostToolUse,
570                matcher: PatternMatcher::ToolNameGlob(hook.pattern),
571                handler: RuntimeHookHandler::NativePostTool(post),
572            });
573        });
574    }
575}
576
577pub fn register_vm_hook(
578    event: HookEvent,
579    pattern: impl Into<String>,
580    handler_name: impl Into<String>,
581    closure: Arc<VmClosure>,
582) {
583    // The handler closure is retained in this thread-local past the lifetime of
584    // the VM that registered it (it may fire from a later, unrelated VM), so
585    // pin its module scope — otherwise the sibling `pub fn`s its body calls
586    // become unresolvable once the registering VM's `module_cache` drops, and
587    // the call falls through to host-bridge dispatch. See
588    // `VmClosure::retained_for_host_registry`. The lazy handler path resolves
589    // and retains the module export set on first fire in the firing VM's
590    // execution-root cache instead.
591    let closure = closure.retained_for_host_registry();
592    RUNTIME_HOOKS.with(|hooks| {
593        hooks.borrow_mut().push(RuntimeHook {
594            event,
595            matcher: compile_event_pattern(pattern.into()),
596            handler: RuntimeHookHandler::Vm {
597                handler_name: handler_name.into(),
598                callable: crate::value::VmCallable::Eager(closure),
599            },
600        });
601    });
602}
603
604/// Register a manifest hook whose handler closure is resolved on first fire.
605pub fn register_vm_hook_lazy(
606    event: HookEvent,
607    pattern: impl Into<String>,
608    handler_name: impl Into<String>,
609    lazy: crate::value::LazyVmCallable,
610) {
611    RUNTIME_HOOKS.with(|hooks| {
612        hooks.borrow_mut().push(RuntimeHook {
613            event,
614            matcher: compile_event_pattern(pattern.into()),
615            handler: RuntimeHookHandler::Vm {
616                handler_name: handler_name.into(),
617                callable: crate::value::VmCallable::Lazy(lazy),
618            },
619        });
620    });
621}
622
623async fn resolve_lifecycle_handler(
624    vm: &mut crate::vm::Vm,
625    callable: &crate::value::VmCallable,
626) -> Result<Arc<VmClosure>, VmError> {
627    vm.resolve_callable(callable).await
628}
629
630pub fn clear_tool_hooks() {
631    RUNTIME_HOOKS.with(|hooks| {
632        hooks
633            .borrow_mut()
634            .retain(|hook| !matches!(hook.event, HookEvent::PreToolUse | HookEvent::PostToolUse));
635    });
636    set_singleton_pre_tool_hook(None);
637}
638
639pub fn clear_runtime_hooks() {
640    RUNTIME_HOOKS.with(|hooks| hooks.borrow_mut().clear());
641    set_singleton_pre_tool_hook(None);
642    super::clear_command_policies();
643}
644
645/// Clear only session-level lifecycle hooks (session_start, session_end,
646/// user_prompt_submit, etc.). Leaves tool, persona, step, worker, and
647/// agent-turn hooks installed. Mirrors `clear_tool_hooks()` /
648/// `clear_persona_hooks()` for the new surface.
649pub fn clear_session_hooks() {
650    RUNTIME_HOOKS.with(|hooks| {
651        hooks
652            .borrow_mut()
653            .retain(|hook| !hook.event.is_session_lifecycle());
654    });
655}
656
657fn value_at_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
658    let mut current = value;
659    for segment in path.split('.') {
660        let serde_json::Value::Object(map) = current else {
661            return None;
662        };
663        current = map.get(segment)?;
664    }
665    Some(current)
666}
667
668fn value_truthy(value: &serde_json::Value) -> bool {
669    match value {
670        serde_json::Value::Null => false,
671        serde_json::Value::Bool(value) => *value,
672        serde_json::Value::Number(value) => value
673            .as_i64()
674            .map(|number| number != 0)
675            .or_else(|| value.as_u64().map(|number| number != 0))
676            .or_else(|| value.as_f64().map(|number| number != 0.0))
677            .unwrap_or(false),
678        serde_json::Value::String(value) => !value.is_empty(),
679        serde_json::Value::Array(values) => !values.is_empty(),
680        serde_json::Value::Object(values) => !values.is_empty(),
681    }
682}
683
684fn value_to_pattern_string(value: Option<&serde_json::Value>) -> String {
685    match value {
686        Some(serde_json::Value::String(text)) => text.clone(),
687        Some(other) => other.to_string(),
688        None => String::new(),
689    }
690}
691
692fn strip_quoted(value: &str) -> &str {
693    value
694        .trim()
695        .strip_prefix('"')
696        .and_then(|text| text.strip_suffix('"'))
697        .or_else(|| {
698            value
699                .trim()
700                .strip_prefix('\'')
701                .and_then(|text| text.strip_suffix('\''))
702        })
703        .unwrap_or(value.trim())
704}
705
706fn compile_event_pattern(pattern: String) -> PatternMatcher {
707    let trimmed = pattern.trim();
708    let expression = if trimmed.is_empty() || trimmed == "*" {
709        EventPatternExpression::MatchAll
710    } else if let Some((lhs, rhs)) = trimmed.split_once("=~") {
711        match Regex::new(strip_quoted(rhs)) {
712            Ok(regex) => EventPatternExpression::Regex {
713                path: lhs.trim().to_string(),
714                regex,
715            },
716            Err(_) => EventPatternExpression::NeverMatch,
717        }
718    } else if let Some((lhs, rhs)) = trimmed.split_once("==") {
719        EventPatternExpression::Equals {
720            path: lhs.trim().to_string(),
721            value: strip_quoted(rhs).to_string(),
722        }
723    } else if let Some((lhs, rhs)) = trimmed.split_once("!=") {
724        EventPatternExpression::NotEquals {
725            path: lhs.trim().to_string(),
726            value: strip_quoted(rhs).to_string(),
727        }
728    } else if trimmed.contains('.') {
729        EventPatternExpression::PathTruthy(trimmed.to_string())
730    } else {
731        EventPatternExpression::ToolNameGlob(trimmed.to_string())
732    };
733    PatternMatcher::EventExpression {
734        source: pattern,
735        expression,
736    }
737}
738
739fn expression_matches(
740    source: &str,
741    expression: &EventPatternExpression,
742    payload: &serde_json::Value,
743) -> bool {
744    let pattern = source.trim();
745    if pattern.is_empty() || pattern == "*" {
746        return true;
747    }
748    if let Some(target) = value_at_path(payload, "target").and_then(serde_json::Value::as_str) {
749        if glob_match(pattern, target) {
750            return true;
751        }
752    }
753    match expression {
754        EventPatternExpression::MatchAll => true,
755        EventPatternExpression::NeverMatch => false,
756        EventPatternExpression::Regex { path, regex } => {
757            let value = value_to_pattern_string(value_at_path(payload, path));
758            regex.is_match(&value)
759        }
760        EventPatternExpression::Equals { path, value } => {
761            value_to_pattern_string(value_at_path(payload, path)) == *value
762        }
763        EventPatternExpression::NotEquals { path, value } => {
764            value_to_pattern_string(value_at_path(payload, path)) != *value
765        }
766        EventPatternExpression::PathTruthy(path) => {
767            value_at_path(payload, path).is_some_and(value_truthy)
768        }
769        EventPatternExpression::ToolNameGlob(pattern) => glob_match(
770            pattern,
771            &value_to_pattern_string(value_at_path(payload, "tool.name")),
772        ),
773    }
774}
775
776fn hook_matches(hook: &RuntimeHook, tool_name: Option<&str>, payload: &serde_json::Value) -> bool {
777    match &hook.matcher {
778        PatternMatcher::ToolNameGlob(pattern) => {
779            tool_name.is_some_and(|candidate| glob_match(pattern, candidate))
780        }
781        PatternMatcher::EventExpression { source, expression } => {
782            expression_matches(source, expression, payload)
783        }
784    }
785}
786
787fn runtime_hooks_for_event(event: HookEvent) -> Vec<RuntimeHook> {
788    RUNTIME_HOOKS.with(|hooks| {
789        hooks
790            .borrow()
791            .iter()
792            .filter(|hook| hook.event == event)
793            .cloned()
794            .collect()
795    })
796}
797
798/// Invoke a VM-backed hook handler against a child of the firing VM.
799async fn invoke_vm_hook_handler(
800    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
801    handler: &RuntimeHookHandler,
802    payload: &serde_json::Value,
803) -> Result<Option<VmValue>, VmError> {
804    let Some(mut vm) = ctx.map(crate::vm::AsyncBuiltinCtx::child_vm) else {
805        return Err(VmError::Runtime(
806            "runtime hook requires an async builtin VM context".to_string(),
807        ));
808    };
809    // First-party registered hook (`register_session_hook` /
810    // `register_checkpoint_hook`): the runtime chose to invoke this closure,
811    // so its body's bridge/builtin calls are a trusted bridge call and must
812    // not trip the agent loop's active execution policy. Held across the await.
813    let _trusted_bridge_guard = crate::orchestration::allow_trusted_bridge_calls();
814    let closure = match handler {
815        RuntimeHookHandler::Vm { callable, .. } => vm.resolve_callable(callable).await?,
816        _ => return Ok(None),
817    };
818    let arg = crate::stdlib::json_to_vm_value(payload);
819    let result = vm.call_closure_pub(&closure, &[arg]).await;
820    if let Some(ctx) = ctx {
821        ctx.forward_output(&vm.take_output());
822    }
823    Ok(Some(result?))
824}
825
826async fn invoke_vm_lifecycle_hooks(
827    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
828    event: HookEvent,
829    registrations: Vec<VmLifecycleHookRegistration>,
830    payload: &serde_json::Value,
831) -> Result<(), VmError> {
832    let Some(mut vm) = ctx.map(crate::vm::AsyncBuiltinCtx::child_vm) else {
833        return Err(VmError::Runtime(
834            "runtime hook requires an async builtin VM context".to_string(),
835        ));
836    };
837    let arg = crate::stdlib::json_to_vm_value(payload);
838    let session_id = payload
839        .get("session")
840        .and_then(|v| v.get("id"))
841        .and_then(|v| v.as_str())
842        .unwrap_or("")
843        .to_string();
844    for registration in registrations {
845        // First-party registered lifecycle hook: the runtime chose to invoke
846        // this closure, so its body's bridge/builtin calls are a trusted bridge
847        // call and must not trip the agent loop's active execution policy. Held
848        // across resolution and the invocation await for this registration.
849        let _trusted_bridge_guard = crate::orchestration::allow_trusted_bridge_calls();
850        record_hook_call(&session_id, event, &registration.handler_name, payload);
851        let closure = resolve_lifecycle_handler(&mut vm, &registration.callable).await?;
852        let raw = vm.call_closure_pub(&closure, &[arg.clone()]).await?;
853        if let Some(ctx) = ctx {
854            ctx.forward_output(&vm.take_output());
855        }
856        let effects = parse_hook_effects(event, &raw)?;
857        record_hook_returned(
858            &session_id,
859            event,
860            &registration.handler_name,
861            &HookControl::Allow,
862            &raw,
863        );
864        inject_hook_effects(session_id.as_str(), effects, Some(event))?;
865    }
866    Ok(())
867}
868
869fn reminder_error(context: &str, message: impl Into<String>) -> VmError {
870    VmError::Runtime(format!("{context}: {}", message.into()))
871}
872
873fn reminder_code_error(context: &str, code: Code, message: impl Into<String>) -> VmError {
874    reminder_error(context, format!("{}: {}", code.as_str(), message.into()))
875}
876
877fn unsupported_reminder_event_error(event: HookEvent, context: &str) -> VmError {
878    reminder_code_error(
879        context,
880        Code::ReminderUnsupportedHookEvent,
881        format!(
882            "{} does not support reminder effects; use a session, tool, step, or persona hook",
883            event.as_str()
884        ),
885    )
886}
887
888fn required_reminder_spec_string(
889    options: &crate::value::DictMap,
890    key: &str,
891    context: &str,
892) -> Result<String, VmError> {
893    match options.get(key) {
894        Some(VmValue::String(value)) if !value.trim().is_empty() => Ok(value.to_string()),
895        Some(VmValue::String(_)) | None | Some(VmValue::Nil) => Err(reminder_error(
896            context,
897            format!("`{key}` must be a non-empty string"),
898        )),
899        Some(other) => Err(reminder_error(
900            context,
901            format!("`{key}` must be a string, got {}", other.type_name()),
902        )),
903    }
904}
905
906fn optional_reminder_spec_string(
907    options: &crate::value::DictMap,
908    key: &str,
909    context: &str,
910) -> Result<Option<String>, VmError> {
911    match options.get(key) {
912        None | Some(VmValue::Nil) => Ok(None),
913        Some(VmValue::String(value)) => {
914            let trimmed = value.trim();
915            if trimmed.is_empty() {
916                Ok(None)
917            } else {
918                Ok(Some(trimmed.to_string()))
919            }
920        }
921        Some(other) => Err(reminder_error(
922            context,
923            format!("`{key}` must be a string or nil, got {}", other.type_name()),
924        )),
925    }
926}
927
928fn optional_reminder_spec_bool(
929    options: &crate::value::DictMap,
930    key: &str,
931    context: &str,
932) -> Result<Option<bool>, VmError> {
933    match options.get(key) {
934        None | Some(VmValue::Nil) => Ok(None),
935        Some(VmValue::Bool(value)) => Ok(Some(*value)),
936        Some(other) => Err(reminder_error(
937            context,
938            format!("`{key}` must be a bool or nil, got {}", other.type_name()),
939        )),
940    }
941}
942
943fn reminder_spec_tags(
944    options: &crate::value::DictMap,
945    context: &str,
946) -> Result<Vec<String>, VmError> {
947    match options.get("tags") {
948        None | Some(VmValue::Nil) => Ok(Vec::new()),
949        Some(VmValue::List(values)) => {
950            let mut tags = Vec::new();
951            for value in values.iter() {
952                let VmValue::String(tag) = value else {
953                    return Err(reminder_error(
954                        context,
955                        format!("`tags` entries must be strings, got {}", value.type_name()),
956                    ));
957                };
958                let trimmed = tag.trim();
959                if trimmed.is_empty() {
960                    return Err(reminder_error(
961                        context,
962                        "`tags` entries must be non-empty strings",
963                    ));
964                }
965                if !tags.iter().any(|existing| existing == trimmed) {
966                    tags.push(trimmed.to_string());
967                }
968            }
969            Ok(tags)
970        }
971        Some(other) => Err(reminder_error(
972            context,
973            format!("`tags` must be a list or nil, got {}", other.type_name()),
974        )),
975    }
976}
977
978fn optional_reminder_spec_ttl(
979    options: &crate::value::DictMap,
980    context: &str,
981) -> Result<Option<i64>, VmError> {
982    match options.get("ttl_turns") {
983        None | Some(VmValue::Nil) => Ok(None),
984        Some(VmValue::Int(value)) if *value > 0 => Ok(Some(*value)),
985        Some(VmValue::Int(_)) => Err(reminder_error(context, "`ttl_turns` must be > 0")),
986        Some(other) => Err(reminder_error(
987            context,
988            format!(
989                "`ttl_turns` must be an int or nil, got {}",
990                other.type_name()
991            ),
992        )),
993    }
994}
995
996fn optional_reminder_spec_propagate(
997    options: &crate::value::DictMap,
998    context: &str,
999) -> Result<Option<ReminderPropagate>, VmError> {
1000    optional_reminder_spec_string(options, "propagate", context)?
1001        .map(|value| match value.as_str() {
1002            "all" => Ok(ReminderPropagate::All),
1003            "session" => Ok(ReminderPropagate::Session),
1004            "none" => Ok(ReminderPropagate::None),
1005            _ => Err(reminder_code_error(
1006                context,
1007                Code::ReminderUnknownPropagate,
1008                "`propagate` must be one of all, session, or none",
1009            )),
1010        })
1011        .transpose()
1012}
1013
1014fn optional_reminder_spec_role_hint(
1015    options: &crate::value::DictMap,
1016    context: &str,
1017) -> Result<Option<ReminderRoleHint>, VmError> {
1018    optional_reminder_spec_string(options, "role_hint", context)?
1019        .map(|value| match value.as_str() {
1020            "system" => Ok(ReminderRoleHint::System),
1021            "developer" => Ok(ReminderRoleHint::Developer),
1022            "user_block" => Ok(ReminderRoleHint::UserBlock),
1023            "ephemeral_cache" => Ok(ReminderRoleHint::EphemeralCache),
1024            _ => Err(reminder_error(
1025                context,
1026                "`role_hint` must be one of system, developer, user_block, or ephemeral_cache",
1027            )),
1028        })
1029        .transpose()
1030}
1031
1032fn parse_reminder_spec(value: &VmValue, context: &str) -> Result<ReminderSpec, VmError> {
1033    let Some(options) = value.as_dict() else {
1034        return Err(reminder_error(
1035            context,
1036            format!("reminder spec must be a dict, got {}", value.type_name()),
1037        ));
1038    };
1039    const ALLOWED: &[&str] = &[
1040        "body",
1041        "tags",
1042        "dedupe_key",
1043        "ttl_turns",
1044        "preserve_on_compact",
1045        "propagate",
1046        "role_hint",
1047    ];
1048    let unknown = options
1049        .keys()
1050        .filter(|key| !ALLOWED.contains(&key.as_str()))
1051        .map(|key| key.as_str())
1052        .collect::<Vec<_>>();
1053    if !unknown.is_empty() {
1054        return Err(reminder_code_error(
1055            context,
1056            Code::ReminderUnknownOption,
1057            format!("unknown reminder option(s): {}", unknown.join(", ")),
1058        ));
1059    }
1060    Ok(SystemReminder {
1061        id: uuid::Uuid::now_v7().to_string(),
1062        tags: reminder_spec_tags(options, context)?,
1063        dedupe_key: optional_reminder_spec_string(options, "dedupe_key", context)?,
1064        ttl_turns: optional_reminder_spec_ttl(options, context)?,
1065        preserve_on_compact: optional_reminder_spec_bool(options, "preserve_on_compact", context)?
1066            .unwrap_or(false),
1067        propagate: optional_reminder_spec_propagate(options, context)?
1068            .unwrap_or(ReminderPropagate::Session),
1069        role_hint: optional_reminder_spec_role_hint(options, context)?
1070            .unwrap_or(ReminderRoleHint::System),
1071        source: ReminderSource::Hook,
1072        body: required_reminder_spec_string(options, "body", context)?,
1073        fired_at_turn: 0,
1074        originating_agent_id: None,
1075    })
1076}
1077
1078fn looks_like_reminder_spec(map: &crate::value::DictMap) -> bool {
1079    map.contains_key("body")
1080        && !map.contains_key("deny")
1081        && !map.contains_key("args")
1082        && !map.contains_key("result")
1083        && !map.contains_key("output")
1084        && !map.contains_key("modify")
1085        && !map.contains_key("block")
1086        && !map.contains_key("decision")
1087        && !map.contains_key("action")
1088        && !map.contains_key("control")
1089}
1090
1091fn parse_hook_effect_item(event: HookEvent, value: &VmValue) -> Result<HookEffect, VmError> {
1092    let context = format!("{} hook reminder", event.as_str());
1093    if let Some(map) = value.as_dict() {
1094        if let Some(reminder) = map.get("reminder") {
1095            if !event.supports_reminder_effects() {
1096                return Err(unsupported_reminder_event_error(event, &context));
1097            }
1098            return Ok(HookEffect::Reminder(parse_reminder_spec(
1099                reminder, &context,
1100            )?));
1101        }
1102        if matches!(
1103            map.get("type")
1104                .or_else(|| map.get("kind"))
1105                .map(|value| value.display())
1106                .as_deref(),
1107            Some("reminder" | "Reminder")
1108        ) {
1109            if !event.supports_reminder_effects() {
1110                return Err(unsupported_reminder_event_error(event, &context));
1111            }
1112            let spec = map
1113                .get("spec")
1114                .or_else(|| map.get("reminder"))
1115                .ok_or_else(|| reminder_error(&context, "reminder effect missing `spec`"))?;
1116            return Ok(HookEffect::Reminder(parse_reminder_spec(spec, &context)?));
1117        }
1118        if looks_like_reminder_spec(map) {
1119            if !event.supports_reminder_effects() {
1120                return Err(unsupported_reminder_event_error(event, &context));
1121            }
1122            return Ok(HookEffect::Reminder(parse_reminder_spec(value, &context)?));
1123        }
1124    }
1125    Err(reminder_error(
1126        &context,
1127        "hook effect must be {reminder: {...}} or a reminder spec",
1128    ))
1129}
1130
1131pub fn parse_hook_effects(event: HookEvent, value: &VmValue) -> Result<Vec<HookEffect>, VmError> {
1132    let Some(map) = value.as_dict() else {
1133        if let VmValue::List(items) = value {
1134            return items
1135                .iter()
1136                .map(|item| parse_hook_effect_item(event, item))
1137                .collect();
1138        }
1139        return Ok(Vec::new());
1140    };
1141
1142    let mut effects = Vec::new();
1143    if let Some(items) = map.get("effects") {
1144        match items {
1145            VmValue::List(list) => {
1146                for item in list.iter() {
1147                    effects.push(parse_hook_effect_item(event, item)?);
1148                }
1149            }
1150            other => effects.push(parse_hook_effect_item(event, other)?),
1151        }
1152    }
1153    if let Some(reminder) = map.get("reminder") {
1154        let context = format!("{} hook reminder", event.as_str());
1155        if !event.supports_reminder_effects() {
1156            return Err(unsupported_reminder_event_error(event, &context));
1157        }
1158        effects.push(HookEffect::Reminder(parse_reminder_spec(
1159            reminder, &context,
1160        )?));
1161    } else if effects.is_empty() && looks_like_reminder_spec(map) {
1162        let context = format!("{} hook reminder", event.as_str());
1163        if !event.supports_reminder_effects() {
1164            return Err(unsupported_reminder_event_error(event, &context));
1165        }
1166        effects.push(HookEffect::Reminder(parse_reminder_spec(value, &context)?));
1167    }
1168    Ok(effects)
1169}
1170
1171fn action_value_after_effects(value: VmValue, default_action: VmValue) -> VmValue {
1172    let VmValue::Dict(map) = value else {
1173        return value;
1174    };
1175    if let Some(then) = map.get("then") {
1176        return then.clone();
1177    }
1178    let has_effects = map.contains_key("effects")
1179        || map.contains_key("reminder")
1180        || looks_like_reminder_spec(map.as_ref());
1181    if !has_effects {
1182        return VmValue::Dict(map);
1183    }
1184    let mut action = map.as_ref().clone();
1185    action.remove("effects");
1186    action.remove("reminder");
1187    action.remove("then");
1188    if action.keys().any(|key| {
1189        matches!(
1190            key.as_str(),
1191            "deny" | "args" | "result" | "output" | "modify" | "block" | "decision" | "action"
1192        )
1193    }) {
1194        VmValue::dict(action)
1195    } else {
1196        default_action
1197    }
1198}
1199
1200pub fn collect_hook_effects_and_action(
1201    event: HookEvent,
1202    value: VmValue,
1203    default_action: VmValue,
1204) -> Result<(VmValue, Vec<HookEffect>), VmError> {
1205    let mut current = value;
1206    let mut effects = Vec::new();
1207    for _ in 0..32 {
1208        let current_effects = parse_hook_effects(event, &current)?;
1209        if current_effects.is_empty() {
1210            return Ok((current, effects));
1211        }
1212        effects.extend(current_effects);
1213        current = action_value_after_effects(current, default_action.clone());
1214    }
1215    Err(VmError::Runtime(format!(
1216        "{} hook reminder return nested too deeply",
1217        event.as_str()
1218    )))
1219}
1220
1221fn inject_hook_effects(
1222    session_id: &str,
1223    effects: Vec<HookEffect>,
1224    event: Option<HookEvent>,
1225) -> Result<(), VmError> {
1226    if effects.is_empty() {
1227        return Ok(());
1228    }
1229    let target_session = if session_id.is_empty() {
1230        crate::agent_sessions::current_session_id().unwrap_or_default()
1231    } else {
1232        session_id.to_string()
1233    };
1234    if target_session.is_empty() {
1235        return Ok(());
1236    }
1237    for effect in effects {
1238        match effect {
1239            HookEffect::Reminder(spec) => {
1240                let reminder_id = spec.id.clone();
1241                let tags = spec.tags.clone();
1242                let dedupe_key = spec.dedupe_key.clone();
1243                let role_hint = spec.role_hint.as_str();
1244                let source = spec.source.as_str();
1245                let ttl_turns = spec.ttl_turns;
1246                let report = crate::agent_sessions::inject_reminder(&target_session, spec)
1247                    .map_err(VmError::Runtime)?;
1248                record_hook_reminder_report(serde_json::json!({
1249                    "hook_event": event.map(|event| event.as_str()),
1250                    "session_id": &target_session,
1251                    "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1252                    "reminder_id": reminder_id,
1253                    "tags": tags,
1254                    "dedupe_key": dedupe_key,
1255                    "role_hint": role_hint,
1256                    "source": source,
1257                    "ttl_turns": ttl_turns,
1258                    "deduped_count": report.deduped_count,
1259                }));
1260            }
1261        }
1262    }
1263    Ok(())
1264}
1265
1266pub fn inject_hook_effects_into_current_session(effects: Vec<HookEffect>) -> Result<(), VmError> {
1267    inject_hook_effects("", effects, None)
1268}
1269
1270fn wrap_pre_tool_effects(effects: Vec<HookEffect>, mut action: PreToolAction) -> PreToolAction {
1271    for effect in effects.into_iter().rev() {
1272        match effect {
1273            HookEffect::Reminder(spec) => {
1274                action = PreToolAction::Reminder {
1275                    spec,
1276                    then: Box::new(action),
1277                };
1278            }
1279        }
1280    }
1281    action
1282}
1283
1284fn wrap_post_tool_effects(effects: Vec<HookEffect>, mut action: PostToolAction) -> PostToolAction {
1285    for effect in effects.into_iter().rev() {
1286        match effect {
1287            HookEffect::Reminder(spec) => {
1288                action = PostToolAction::Reminder {
1289                    spec,
1290                    then: Box::new(action),
1291                };
1292            }
1293        }
1294    }
1295    action
1296}
1297
1298fn parse_pre_tool_result(value: VmValue) -> Result<PreToolAction, VmError> {
1299    let (value, effects) =
1300        collect_hook_effects_and_action(HookEvent::PreToolUse, value, VmValue::Nil)?;
1301    match value {
1302        VmValue::Nil => Ok(wrap_pre_tool_effects(effects, PreToolAction::Allow)),
1303        VmValue::Dict(map) => {
1304            if let Some(reason) = map.get("deny") {
1305                return Ok(wrap_pre_tool_effects(
1306                    effects,
1307                    PreToolAction::Deny(reason.display()),
1308                ));
1309            }
1310            if let Some(args) = map.get("args") {
1311                return Ok(wrap_pre_tool_effects(
1312                    effects,
1313                    PreToolAction::Modify(crate::llm::vm_value_to_json(args)),
1314                ));
1315            }
1316            Ok(wrap_pre_tool_effects(effects, PreToolAction::Allow))
1317        }
1318        other => Err(VmError::Runtime(format!(
1319            "PreToolUse hook must return nil or {{deny, args}}, got {}",
1320            other.type_name()
1321        ))),
1322    }
1323}
1324
1325fn parse_post_tool_result(value: VmValue) -> Result<PostToolAction, VmError> {
1326    let (value, effects) =
1327        collect_hook_effects_and_action(HookEvent::PostToolUse, value, VmValue::Nil)?;
1328    match value {
1329        VmValue::Nil => Ok(wrap_post_tool_effects(effects, PostToolAction::Pass)),
1330        VmValue::String(text) => Ok(wrap_post_tool_effects(
1331            effects,
1332            PostToolAction::Modify(text.to_string()),
1333        )),
1334        VmValue::Dict(map) => {
1335            if let Some(result) = map.get("result") {
1336                return Ok(wrap_post_tool_effects(
1337                    effects,
1338                    PostToolAction::Modify(result.display()),
1339                ));
1340            }
1341            Ok(wrap_post_tool_effects(effects, PostToolAction::Pass))
1342        }
1343        other => Err(VmError::Runtime(format!(
1344            "PostToolUse hook must return nil, string, or {{result}}, got {}",
1345            other.type_name()
1346        ))),
1347    }
1348}
1349
1350pub fn apply_pre_tool_action(
1351    action: PreToolAction,
1352    current_args: &mut serde_json::Value,
1353) -> Result<Option<String>, VmError> {
1354    match action {
1355        PreToolAction::Allow => Ok(None),
1356        PreToolAction::Deny(reason) => Ok(Some(reason)),
1357        PreToolAction::Modify(new_args) => {
1358            *current_args = new_args;
1359            Ok(None)
1360        }
1361        PreToolAction::Reminder { spec, then } => {
1362            inject_hook_effects(
1363                "",
1364                vec![HookEffect::Reminder(spec)],
1365                Some(HookEvent::PreToolUse),
1366            )?;
1367            apply_pre_tool_action(*then, current_args)
1368        }
1369    }
1370}
1371
1372fn apply_post_tool_action(action: PostToolAction, current: String) -> Result<String, VmError> {
1373    match action {
1374        PostToolAction::Pass => Ok(current),
1375        PostToolAction::Modify(new_result) => Ok(new_result),
1376        PostToolAction::Reminder { spec, then } => {
1377            inject_hook_effects(
1378                "",
1379                vec![HookEffect::Reminder(spec)],
1380                Some(HookEvent::PostToolUse),
1381            )?;
1382            apply_post_tool_action(*then, current)
1383        }
1384    }
1385}
1386
1387/// Run all matching PreToolUse hooks. Returns the final action.
1388pub async fn run_pre_tool_hooks(
1389    tool_name: &str,
1390    args: &serde_json::Value,
1391) -> Result<PreToolAction, VmError> {
1392    run_pre_tool_hooks_with_ctx(None, tool_name, args).await
1393}
1394
1395pub async fn run_pre_tool_hooks_with_ctx(
1396    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
1397    tool_name: &str,
1398    args: &serde_json::Value,
1399) -> Result<PreToolAction, VmError> {
1400    let hooks = runtime_hooks_for_event(HookEvent::PreToolUse);
1401    let mut current_args = args.clone();
1402    // Singleton runtime hook (currently the stdlib path_scope_guard) runs
1403    // before user-registered hooks so a tagged deny lands in the
1404    // PostToolUse / reminder path before any other hook fires.
1405    if let Some(singleton) = singleton_pre_tool_hook() {
1406        let action = singleton(tool_name, &current_args);
1407        if let Some(reason) = apply_pre_tool_action(action, &mut current_args)? {
1408            return Ok(PreToolAction::Deny(reason));
1409        }
1410    }
1411    for hook in &hooks {
1412        let payload = if matches!(hook.matcher, PatternMatcher::EventExpression { .. }) {
1413            Some(serde_json::json!({
1414                "event": HookEvent::PreToolUse.as_str(),
1415                "tool": {
1416                    "name": tool_name,
1417                    "args": current_args.clone(),
1418                    "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1419                },
1420                "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1421            }))
1422        } else {
1423            None
1424        };
1425        if !hook_matches(
1426            hook,
1427            Some(tool_name),
1428            payload.as_ref().unwrap_or(&serde_json::Value::Null),
1429        ) {
1430            continue;
1431        }
1432        let action = match &hook.handler {
1433            RuntimeHookHandler::NativePreTool(pre) => pre(tool_name, &current_args),
1434            RuntimeHookHandler::Vm { .. } => {
1435                let payload = payload.as_ref().ok_or_else(|| {
1436                    VmError::Runtime("VM PreToolUse hook requires an event payload".to_string())
1437                })?;
1438                let Some(value) = invoke_vm_hook_handler(ctx, &hook.handler, payload).await? else {
1439                    continue;
1440                };
1441                parse_pre_tool_result(value)?
1442            }
1443            RuntimeHookHandler::NativePostTool(_) => continue,
1444        };
1445        if let Some(reason) = apply_pre_tool_action(action, &mut current_args)? {
1446            return Ok(PreToolAction::Deny(reason));
1447        }
1448    }
1449    if current_args != *args {
1450        Ok(PreToolAction::Modify(current_args))
1451    } else {
1452        Ok(PreToolAction::Allow)
1453    }
1454}
1455
1456/// Run all matching PostToolUse hooks. Returns the (possibly modified) result.
1457pub async fn run_post_tool_hooks(
1458    tool_name: &str,
1459    args: &serde_json::Value,
1460    result: &str,
1461) -> Result<String, VmError> {
1462    run_post_tool_hooks_with_ctx(None, tool_name, args, result).await
1463}
1464
1465pub async fn run_post_tool_hooks_with_ctx(
1466    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
1467    tool_name: &str,
1468    args: &serde_json::Value,
1469    result: &str,
1470) -> Result<String, VmError> {
1471    let hooks = runtime_hooks_for_event(HookEvent::PostToolUse);
1472    let mut current = result.to_string();
1473    for hook in &hooks {
1474        let payload = if matches!(hook.matcher, PatternMatcher::EventExpression { .. }) {
1475            Some(serde_json::json!({
1476                "event": HookEvent::PostToolUse.as_str(),
1477                "tool": {
1478                    "name": tool_name,
1479                    "args": args,
1480                    "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1481                },
1482                "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1483                "result": {
1484                    "text": current.clone(),
1485                },
1486            }))
1487        } else {
1488            None
1489        };
1490        if !hook_matches(
1491            hook,
1492            Some(tool_name),
1493            payload.as_ref().unwrap_or(&serde_json::Value::Null),
1494        ) {
1495            continue;
1496        }
1497        let action = match &hook.handler {
1498            RuntimeHookHandler::NativePostTool(post) => post(tool_name, &current),
1499            RuntimeHookHandler::Vm { .. } => {
1500                let payload = payload.as_ref().ok_or_else(|| {
1501                    VmError::Runtime("VM PostToolUse hook requires an event payload".to_string())
1502                })?;
1503                let Some(value) = invoke_vm_hook_handler(ctx, &hook.handler, payload).await? else {
1504                    continue;
1505                };
1506                parse_post_tool_result(value)?
1507            }
1508            RuntimeHookHandler::NativePreTool(_) => continue,
1509        };
1510        match action {
1511            PostToolAction::Pass => {}
1512            PostToolAction::Modify(new_result) => {
1513                current = new_result;
1514            }
1515            PostToolAction::Reminder { spec, then } => {
1516                inject_hook_effects(
1517                    "",
1518                    vec![HookEffect::Reminder(spec)],
1519                    Some(HookEvent::PostToolUse),
1520                )?;
1521                current = apply_post_tool_action(*then, current)?;
1522            }
1523        }
1524    }
1525    Ok(current)
1526}
1527
1528pub async fn run_lifecycle_hooks(
1529    event: HookEvent,
1530    payload: &serde_json::Value,
1531) -> Result<(), VmError> {
1532    run_lifecycle_hooks_with_ctx(None, event, payload).await
1533}
1534
1535pub async fn run_lifecycle_hooks_with_ctx(
1536    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
1537    event: HookEvent,
1538    payload: &serde_json::Value,
1539) -> Result<(), VmError> {
1540    let registrations = matching_vm_lifecycle_registrations(event, payload);
1541    if registrations.is_empty() {
1542        return Ok(());
1543    }
1544    invoke_vm_lifecycle_hooks(ctx, event, registrations, payload).await
1545}
1546
1547/// Run veto-capable session-level lifecycle hooks. Successive hooks see
1548/// `Allow`; the first non-`Allow` return short-circuits and is returned
1549/// to the caller. Hook invocations and decisions are captured on the
1550/// active session's transcript under `hook_call`, `hook_returned`, and
1551/// `hook_vetoed` so a replay reproduces the same control flow.
1552///
1553/// `Modify` does not short-circuit: subsequent hooks see the rewritten
1554/// payload, and the final `HookControl::Modify` returned by the chain
1555/// carries the merged payload back to the dispatcher so the recording
1556/// layer captures the post-modify shape (replay determinism). If a
1557/// later hook in the same chain returns `Allow`, the merged
1558/// `Modify { payload }` from earlier hooks is still surfaced.
1559pub async fn run_lifecycle_hooks_with_control(
1560    event: HookEvent,
1561    payload: &serde_json::Value,
1562) -> Result<HookControl, VmError> {
1563    run_lifecycle_hooks_with_control_with_ctx(None, event, payload).await
1564}
1565
1566pub async fn run_lifecycle_hooks_with_control_with_ctx(
1567    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
1568    event: HookEvent,
1569    payload: &serde_json::Value,
1570) -> Result<HookControl, VmError> {
1571    let registrations = matching_vm_lifecycle_registrations(event, payload);
1572    if registrations.is_empty() {
1573        return Ok(HookControl::Allow);
1574    }
1575    let Some(mut vm) = ctx.map(crate::vm::AsyncBuiltinCtx::child_vm) else {
1576        return Err(VmError::Runtime(
1577            "session lifecycle hook requires an async builtin VM context".to_string(),
1578        ));
1579    };
1580    let session_id = payload
1581        .get("session")
1582        .and_then(|v| v.get("id"))
1583        .and_then(|v| v.as_str())
1584        .unwrap_or("")
1585        .to_string();
1586    let mut current_payload = payload.clone();
1587    let mut accumulated_modify: Option<serde_json::Value> = None;
1588    for registration in registrations {
1589        // First-party registered lifecycle hook (see `invoke_vm_lifecycle_hooks`):
1590        // the runtime chose to invoke this closure, so its body's bridge/builtin
1591        // calls are a trusted bridge call and must not trip the agent loop's
1592        // active execution policy. Held across the invocation await.
1593        let _trusted_bridge_guard = crate::orchestration::allow_trusted_bridge_calls();
1594        let arg = crate::stdlib::json_to_vm_value(&current_payload);
1595        record_hook_call(
1596            &session_id,
1597            event,
1598            &registration.handler_name,
1599            &current_payload,
1600        );
1601        let closure = resolve_lifecycle_handler(&mut vm, &registration.callable).await?;
1602        let raw = vm.call_closure_pub(&closure, &[arg]).await?;
1603        if let Some(ctx) = ctx {
1604            ctx.forward_output(&vm.take_output());
1605        }
1606        let outcome = parse_hook_outcome(event, &raw)?;
1607        record_hook_returned(
1608            &session_id,
1609            event,
1610            &registration.handler_name,
1611            &outcome.control,
1612            &raw,
1613        );
1614        inject_hook_effects(session_id.as_str(), outcome.effects, Some(event))?;
1615        match outcome.control {
1616            HookControl::Allow => continue,
1617            HookControl::Modify { payload: modified } => {
1618                current_payload = modified.clone();
1619                accumulated_modify = Some(modified);
1620            }
1621            other @ (HookControl::Block { .. } | HookControl::Decision { .. }) => {
1622                record_hook_vetoed(&session_id, event, &registration.handler_name, &other);
1623                return Ok(other);
1624            }
1625        }
1626    }
1627    if let Some(payload) = accumulated_modify {
1628        Ok(HookControl::Modify { payload })
1629    } else {
1630        Ok(HookControl::Allow)
1631    }
1632}
1633
1634fn parse_hook_outcome(event: HookEvent, value: &VmValue) -> Result<HookOutcome, VmError> {
1635    let effects = parse_hook_effects(event, value)?;
1636    let action_value = if matches!(value, VmValue::List(_)) {
1637        VmValue::Nil
1638    } else {
1639        action_value_after_effects(value.clone(), VmValue::Nil)
1640    };
1641    let control = parse_hook_control(event, &action_value)?;
1642    Ok(HookOutcome { control, effects })
1643}
1644
1645/// Public alias for the internal `parse_hook_control`. Used by the
1646/// pipeline-finish dispatcher (`fire_finish_lifecycle_event`) to
1647/// translate the action half of a hook return value into a control
1648/// signal so it can honor the lifecycle table (PreFinish rejects
1649/// Block, OnUnsettledDetected respects Block, etc.).
1650pub fn parse_hook_control_for_finish(
1651    event: HookEvent,
1652    value: &VmValue,
1653) -> Result<HookControl, VmError> {
1654    parse_hook_control(event, value)
1655}
1656
1657fn parse_hook_control(event: HookEvent, value: &VmValue) -> Result<HookControl, VmError> {
1658    match value {
1659        VmValue::Nil | VmValue::Bool(true) => Ok(HookControl::Allow),
1660        VmValue::Bool(false) => Ok(HookControl::Block {
1661            reason: format!("{} hook returned false", event.as_str()),
1662        }),
1663        VmValue::Dict(map) => {
1664            if let Some(decision) = map.get("decision") {
1665                let kind = decision.display();
1666                let kind_norm = kind.trim().to_ascii_lowercase();
1667                if !matches!(kind_norm.as_str(), "allow" | "deny" | "ask") {
1668                    return Err(VmError::Runtime(format!(
1669                        "{} hook `decision` must be \"allow\", \"deny\", or \"ask\"; got \"{kind}\"",
1670                        event.as_str()
1671                    )));
1672                }
1673                let reason = map.get("reason").and_then(|v| match v {
1674                    VmValue::Nil => None,
1675                    other => Some(other.display()),
1676                });
1677                return Ok(HookControl::Decision {
1678                    kind: kind_norm,
1679                    reason,
1680                });
1681            }
1682            let block = map.get("block").map(vm_value_truthy).unwrap_or(false);
1683            if block {
1684                let reason = map
1685                    .get("reason")
1686                    .map(|v| v.display())
1687                    .unwrap_or_else(|| format!("{} hook blocked the operation", event.as_str()));
1688                return Ok(HookControl::Block { reason });
1689            }
1690            if let Some(modify) = map.get("modify") {
1691                return Ok(HookControl::Modify {
1692                    payload: crate::llm::vm_value_to_json(modify),
1693                });
1694            }
1695            Ok(HookControl::Allow)
1696        }
1697        other => Err(VmError::Runtime(format!(
1698            "{} hook must return nil, bool, or a control dict; got {}",
1699            event.as_str(),
1700            other.type_name()
1701        ))),
1702    }
1703}
1704
1705fn vm_value_truthy(value: &VmValue) -> bool {
1706    match value {
1707        VmValue::Nil => false,
1708        VmValue::Bool(value) => *value,
1709        VmValue::Int(value) => *value != 0,
1710        VmValue::Float(value) => *value != 0.0,
1711        VmValue::String(value) => !value.is_empty(),
1712        VmValue::List(value) => !value.is_empty(),
1713        VmValue::Dict(value) => !value.is_empty(),
1714        _ => true,
1715    }
1716}
1717
1718fn record_hook_call(
1719    session_id: &str,
1720    event: HookEvent,
1721    handler: &str,
1722    payload: &serde_json::Value,
1723) {
1724    if session_id.is_empty() {
1725        return;
1726    }
1727    let metadata = serde_json::json!({
1728        "event": event.as_str(),
1729        "handler": handler,
1730        "payload": payload,
1731    });
1732    let entry = crate::llm::helpers::transcript_event(
1733        "hook_call",
1734        "system",
1735        "internal",
1736        &format!("hook {} invoked: {}", event.as_str(), handler),
1737        Some(metadata),
1738    );
1739    let _ = crate::agent_sessions::append_event(session_id, entry);
1740}
1741
1742fn record_hook_returned(
1743    session_id: &str,
1744    event: HookEvent,
1745    handler: &str,
1746    control: &HookControl,
1747    raw: &VmValue,
1748) {
1749    if session_id.is_empty() {
1750        return;
1751    }
1752    let metadata = serde_json::json!({
1753        "event": event.as_str(),
1754        "handler": handler,
1755        "result": control.as_str(),
1756        "raw": crate::llm::vm_value_to_json(raw),
1757    });
1758    let entry = crate::llm::helpers::transcript_event(
1759        "hook_returned",
1760        "system",
1761        "internal",
1762        &format!(
1763            "hook {} returned {} from {}",
1764            event.as_str(),
1765            control.as_str(),
1766            handler
1767        ),
1768        Some(metadata),
1769    );
1770    let _ = crate::agent_sessions::append_event(session_id, entry);
1771}
1772
1773fn record_hook_vetoed(session_id: &str, event: HookEvent, handler: &str, control: &HookControl) {
1774    if session_id.is_empty() {
1775        return;
1776    }
1777    let (reason, decision) = match control {
1778        HookControl::Allow => return,
1779        HookControl::Block { reason } => (reason.clone(), None),
1780        HookControl::Decision { kind, reason } => (
1781            reason.clone().unwrap_or_else(|| format!("decision={kind}")),
1782            Some(kind.clone()),
1783        ),
1784        HookControl::Modify { .. } => return,
1785    };
1786    let metadata = serde_json::json!({
1787        "event": event.as_str(),
1788        "handler": handler,
1789        "reason": reason,
1790        "decision": decision,
1791    });
1792    let entry = crate::llm::helpers::transcript_event(
1793        "hook_vetoed",
1794        "system",
1795        "internal",
1796        &format!("hook {} vetoed by {}: {reason}", event.as_str(), handler),
1797        Some(metadata),
1798    );
1799    let _ = crate::agent_sessions::append_event(session_id, entry);
1800}
1801
1802pub fn matching_vm_lifecycle_hooks(
1803    event: HookEvent,
1804    payload: &serde_json::Value,
1805) -> Vec<VmLifecycleHookInvocation> {
1806    matching_vm_lifecycle_registrations(event, payload)
1807        .into_iter()
1808        .map(|registration| VmLifecycleHookInvocation {
1809            callable: registration.callable,
1810            handler_name: registration.handler_name,
1811        })
1812        .collect()
1813}
1814
1815fn matching_vm_lifecycle_registrations(
1816    event: HookEvent,
1817    payload: &serde_json::Value,
1818) -> Vec<VmLifecycleHookRegistration> {
1819    RUNTIME_HOOKS.with(|hooks| {
1820        hooks
1821            .borrow()
1822            .iter()
1823            .filter(|hook| hook.event == event)
1824            .filter(|hook| hook_matches(hook, None, payload))
1825            .filter_map(|hook| match &hook.handler {
1826                RuntimeHookHandler::Vm {
1827                    callable,
1828                    handler_name,
1829                } => Some(VmLifecycleHookRegistration {
1830                    handler_name: handler_name.clone(),
1831                    callable: callable.clone(),
1832                }),
1833                RuntimeHookHandler::NativePreTool(_) | RuntimeHookHandler::NativePostTool(_) => {
1834                    None
1835                }
1836            })
1837            .collect()
1838    })
1839}
1840
1841#[cfg(test)]
1842mod tests {
1843    use super::*;
1844
1845    fn vm_string(value: &str) -> VmValue {
1846        VmValue::String(arcstr::ArcStr::from(value))
1847    }
1848
1849    fn dict(entries: Vec<(&str, VmValue)>) -> VmValue {
1850        VmValue::dict(
1851            entries
1852                .into_iter()
1853                .map(|(key, value)| (crate::value::intern_key(key), value))
1854                .collect::<crate::value::DictMap>(),
1855        )
1856    }
1857
1858    fn error_message(result: Result<Vec<HookEffect>, VmError>) -> String {
1859        match result.expect_err("expected hook reminder parse error") {
1860            VmError::Runtime(message) => message,
1861            other => panic!("expected runtime error, got {other:?}"),
1862        }
1863    }
1864
1865    #[test]
1866    fn unknown_reminder_option_reports_code() {
1867        let value = dict(vec![(
1868            "reminder",
1869            dict(vec![
1870                ("body", vm_string("remember this")),
1871                ("typo_key", VmValue::Bool(true)),
1872            ]),
1873        )]);
1874        let message = error_message(parse_hook_effects(HookEvent::PostTurn, &value));
1875        assert!(message.contains(Code::ReminderUnknownOption.as_str()));
1876        assert!(message.contains("typo_key"), "{message}");
1877    }
1878
1879    #[test]
1880    fn unknown_reminder_propagate_reports_specific_code() {
1881        let value = dict(vec![(
1882            "reminder",
1883            dict(vec![
1884                ("body", vm_string("remember this")),
1885                ("propagate", vm_string("workspace")),
1886            ]),
1887        )]);
1888        let message = error_message(parse_hook_effects(HookEvent::PostTurn, &value));
1889        assert!(message.contains(Code::ReminderUnknownPropagate.as_str()));
1890        assert!(message.contains("propagate"), "{message}");
1891    }
1892
1893    #[test]
1894    fn worker_events_reject_reminder_effects_with_specific_code() {
1895        let value = dict(vec![(
1896            "reminder",
1897            dict(vec![("body", vm_string("worker lifecycle"))]),
1898        )]);
1899        let message = error_message(parse_hook_effects(HookEvent::WorkerSpawned, &value));
1900        assert!(message.contains(Code::ReminderUnsupportedHookEvent.as_str()));
1901        assert!(message.contains("WorkerSpawned"), "{message}");
1902    }
1903
1904    #[test]
1905    fn as_str_round_trips_through_serde() {
1906        // The macro relies on serde's default unit-variant encoding
1907        // (identifier = wire name) instead of a per-variant
1908        // `#[serde(rename)]`. Lock that contract so a future variant
1909        // can't drift by accident.
1910        for &event in HookEvent::ALL {
1911            let json = serde_json::to_string(&event).unwrap();
1912            assert_eq!(json, format!("\"{}\"", event.as_str()));
1913            let parsed: HookEvent = serde_json::from_str(&json).unwrap();
1914            assert_eq!(parsed, event);
1915        }
1916    }
1917
1918    #[test]
1919    fn parse_session_event_accepts_both_spellings_for_every_session_variant() {
1920        // The macro auto-derives snake_case from the PascalCase
1921        // identifier; this test guards against a future variant whose
1922        // name doesn't round-trip cleanly (e.g. unexpected punctuation).
1923        for &event in HookEvent::ALL.iter().filter(|e| e.is_session_lifecycle()) {
1924            let pascal = event.as_str();
1925            let mut snake = String::new();
1926            pascal_to_snake_buf(pascal, &mut snake);
1927            assert_eq!(
1928                HookEvent::parse_session_event(pascal).unwrap(),
1929                event,
1930                "PascalCase `{pascal}`",
1931            );
1932            assert_eq!(
1933                HookEvent::parse_session_event(&snake).unwrap(),
1934                event,
1935                "snake_case `{snake}`",
1936            );
1937        }
1938    }
1939
1940    #[test]
1941    fn parse_session_event_rejects_non_session_variants() {
1942        // Tool, agent-turn, worker, step, and notification events must
1943        // not be accepted by the session parser — each surface owns
1944        // its own event set.
1945        for &event in HookEvent::ALL.iter().filter(|e| !e.is_session_lifecycle()) {
1946            let err = HookEvent::parse_session_event(event.as_str())
1947                .expect_err("non-session event slipped through");
1948            assert!(err.contains("unknown session hook event"), "{err}");
1949        }
1950    }
1951
1952    #[test]
1953    fn parse_provider_event_accepts_worker_and_session_and_flagged_variants() {
1954        // Worker variants are accepted by kind, session variants by
1955        // the fallback, and explicitly-flagged variants
1956        // (`provider_parse: true`) by the first-pass loop. The whole
1957        // set should round-trip.
1958        for &event in HookEvent::ALL.iter().filter(|e| {
1959            matches!(e.kind(), HookEventKind::Worker | HookEventKind::Session)
1960                || e.in_provider_parse()
1961        }) {
1962            assert_eq!(
1963                HookEvent::parse_provider_event(event.as_str()).unwrap(),
1964                event,
1965                "{event:?}",
1966            );
1967        }
1968    }
1969
1970    #[test]
1971    fn session_error_accepts_legacy_short_alias() {
1972        // `SessionError` carries an explicit `"error"` alias for
1973        // backward compat with the original event name.
1974        assert_eq!(
1975            HookEvent::parse_session_event("error").unwrap(),
1976            HookEvent::SessionError,
1977        );
1978        assert_eq!(
1979            HookEvent::parse_session_event("SessionError").unwrap(),
1980            HookEvent::SessionError,
1981        );
1982        assert_eq!(
1983            HookEvent::parse_session_event("session_error").unwrap(),
1984            HookEvent::SessionError,
1985        );
1986    }
1987
1988    #[test]
1989    fn supports_reminder_effects_excludes_only_worker_kind() {
1990        for &event in HookEvent::ALL {
1991            let supports = event.supports_reminder_effects();
1992            let expected = !matches!(event.kind(), HookEventKind::Worker);
1993            assert_eq!(
1994                supports,
1995                expected,
1996                "{event:?} ({:?}) reminder support disagrees with kind",
1997                event.kind(),
1998            );
1999        }
2000    }
2001
2002    #[test]
2003    fn from_worker_event_covers_every_worker_variant() {
2004        for worker in WorkerEvent::ALL {
2005            let event = HookEvent::from_worker_event(worker);
2006            assert!(
2007                matches!(event.kind(), HookEventKind::Worker),
2008                "WorkerEvent::{worker:?} mapped to non-Worker kind {:?}",
2009                event.kind(),
2010            );
2011            assert_eq!(event.as_str(), worker.as_str());
2012        }
2013    }
2014}