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/// A manifest hook handler that has not been resolved to a [`VmClosure`]
444/// yet. Resolving a handler requires loading its module's whole import
445/// graph (for an IDE host that is ~1s of instantiation), so eager
446/// resolution at registration time made every test — even pure-logic
447/// unit tests that never fire a hook — pay that cost. A lazy handler
448/// defers the module load until the hook actually fires, against the
449/// firing child VM (whose `module_cache` already holds the graph if the
450/// test imported it, making the fire-time load a cache hit and keeping
451/// per-test module-state isolation intact).
452#[derive(Clone, Debug)]
453pub struct LazyVmHookHandler {
454    /// Directory of the manifest that declared the hook.
455    pub manifest_dir: std::path::PathBuf,
456    /// Source path of the module the handler lives in.
457    pub module_path: std::path::PathBuf,
458    /// Exported function name to resolve from that module.
459    pub function_name: String,
460}
461
462#[derive(Clone)]
463enum RuntimeHookHandler {
464    NativePreTool(PreToolHookFn),
465    NativePostTool(PostToolHookFn),
466    Vm {
467        handler_name: String,
468        closure: Arc<VmClosure>,
469    },
470    /// Manifest hook whose closure is resolved on first fire. See
471    /// [`LazyVmHookHandler`].
472    LazyVm {
473        handler_name: String,
474        lazy: LazyVmHookHandler,
475    },
476}
477
478impl std::fmt::Debug for RuntimeHookHandler {
479    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
480        match self {
481            Self::NativePreTool(_) => f.write_str("NativePreTool(..)"),
482            Self::NativePostTool(_) => f.write_str("NativePostTool(..)"),
483            Self::Vm { handler_name, .. } => f
484                .debug_struct("Vm")
485                .field("handler_name", handler_name)
486                .finish(),
487            Self::LazyVm { handler_name, .. } => f
488                .debug_struct("LazyVm")
489                .field("handler_name", handler_name)
490                .finish(),
491        }
492    }
493}
494
495#[derive(Clone, Debug)]
496struct RuntimeHook {
497    event: HookEvent,
498    matcher: PatternMatcher,
499    handler: RuntimeHookHandler,
500}
501
502#[derive(Clone, Debug)]
503pub struct VmLifecycleHookInvocation {
504    /// Eagerly-resolved closure, or `None` for a lazy manifest hook that
505    /// must be resolved with [`VmLifecycleHookInvocation::resolve`].
506    closure: Option<Arc<VmClosure>>,
507    lazy: Option<LazyVmHookHandler>,
508    pub handler_name: String,
509}
510
511impl VmLifecycleHookInvocation {
512    /// Resolve this invocation's handler closure against `vm`, loading the
513    /// handler's module on demand for a lazy manifest hook (a cache hit
514    /// when `vm` already imported the graph).
515    pub async fn resolve(&self, vm: &mut crate::vm::Vm) -> Result<Arc<VmClosure>, VmError> {
516        match (&self.closure, &self.lazy) {
517            (Some(closure), _) => Ok(Arc::clone(closure)),
518            (None, Some(lazy)) => resolve_lazy_hook_closure(vm, lazy).await,
519            (None, None) => Err(VmError::Runtime(format!(
520                "lifecycle hook '{}' has no handler",
521                self.handler_name
522            ))),
523        }
524    }
525}
526
527#[derive(Clone, Debug)]
528enum VmLifecycleHandlerRef {
529    Eager(Arc<VmClosure>),
530    Lazy(LazyVmHookHandler),
531}
532
533#[derive(Clone, Debug)]
534struct VmLifecycleHookRegistration {
535    handler_name: String,
536    handler: VmLifecycleHandlerRef,
537}
538
539thread_local! {
540    static RUNTIME_HOOKS: RefCell<Vec<RuntimeHook>> = const { RefCell::new(Vec::new()) };
541    /// Pending `FileEdited` notifications queued from sync builtins
542    /// (e.g. `write_file`). Drained at safe async boundaries — typically
543    /// at the start of each agent-loop turn — so VM closure handlers
544    /// can run inside an async builtin context.
545    static FILE_EDIT_QUEUE: RefCell<Vec<FileEditedNotification>> = const { RefCell::new(Vec::new()) };
546    /// Optional singleton PreToolUse hook owned by stdlib opt-in surfaces
547    /// (currently the `path_scope_guard` from #2221). Kept separate from
548    /// `RUNTIME_HOOKS` so the runtime can swap or clear it without
549    /// touching user-registered hooks.
550    static SINGLETON_PRE_TOOL_HOOK: RefCell<Option<PreToolHookFn>> = const { RefCell::new(None) };
551}
552
553/// Install (or replace, with `None`) the singleton runtime pre-tool
554/// hook. The singleton runs ahead of user-registered hooks so a tagged
555/// deny lands in the reminder path before any other hook fires.
556pub fn set_singleton_pre_tool_hook(hook: Option<PreToolHookFn>) {
557    SINGLETON_PRE_TOOL_HOOK.with(|slot| *slot.borrow_mut() = hook);
558}
559
560pub fn singleton_pre_tool_hook() -> Option<PreToolHookFn> {
561    SINGLETON_PRE_TOOL_HOOK.with(|slot| slot.borrow().clone())
562}
563
564#[derive(Clone, Debug)]
565pub struct FileEditedNotification {
566    pub path: String,
567    pub metadata: serde_json::Value,
568}
569
570/// Queue a file-edited notification. Safe to call from sync contexts.
571pub fn queue_file_edited(path: &str, metadata: serde_json::Value) {
572    FILE_EDIT_QUEUE.with(|queue| {
573        queue.borrow_mut().push(FileEditedNotification {
574            path: path.to_string(),
575            metadata,
576        });
577    });
578}
579
580/// Drain queued file-edited notifications. Returns them in the order
581/// they were queued; the caller is responsible for invoking matching
582/// `FileEdited` hooks (async context required).
583pub fn drain_file_edits() -> Vec<FileEditedNotification> {
584    FILE_EDIT_QUEUE.with(|queue| std::mem::take(&mut *queue.borrow_mut()))
585}
586
587pub fn clear_file_edit_queue() {
588    FILE_EDIT_QUEUE.with(|queue| queue.borrow_mut().clear());
589}
590
591// The workspace-wide name matcher (re-exported as
592// `crate::orchestration::glob_match` for the tool-surface, permission, and
593// step-runtime call sites). Semantics live in `harn-glob`.
594pub(crate) use harn_glob::match_name as glob_match;
595
596pub fn register_tool_hook(hook: ToolHook) {
597    if let Some(pre) = hook.pre {
598        RUNTIME_HOOKS.with(|hooks| {
599            hooks.borrow_mut().push(RuntimeHook {
600                event: HookEvent::PreToolUse,
601                matcher: PatternMatcher::ToolNameGlob(hook.pattern.clone()),
602                handler: RuntimeHookHandler::NativePreTool(pre),
603            });
604        });
605    }
606    if let Some(post) = hook.post {
607        RUNTIME_HOOKS.with(|hooks| {
608            hooks.borrow_mut().push(RuntimeHook {
609                event: HookEvent::PostToolUse,
610                matcher: PatternMatcher::ToolNameGlob(hook.pattern),
611                handler: RuntimeHookHandler::NativePostTool(post),
612            });
613        });
614    }
615}
616
617pub fn register_vm_hook(
618    event: HookEvent,
619    pattern: impl Into<String>,
620    handler_name: impl Into<String>,
621    closure: Arc<VmClosure>,
622) {
623    RUNTIME_HOOKS.with(|hooks| {
624        hooks.borrow_mut().push(RuntimeHook {
625            event,
626            matcher: compile_event_pattern(pattern.into()),
627            handler: RuntimeHookHandler::Vm {
628                handler_name: handler_name.into(),
629                closure,
630            },
631        });
632    });
633}
634
635/// Register a manifest hook whose handler closure is resolved on first
636/// fire instead of at registration time. See [`LazyVmHookHandler`].
637pub fn register_vm_hook_lazy(
638    event: HookEvent,
639    pattern: impl Into<String>,
640    handler_name: impl Into<String>,
641    lazy: LazyVmHookHandler,
642) {
643    RUNTIME_HOOKS.with(|hooks| {
644        hooks.borrow_mut().push(RuntimeHook {
645            event,
646            matcher: compile_event_pattern(pattern.into()),
647            handler: RuntimeHookHandler::LazyVm {
648                handler_name: handler_name.into(),
649                lazy,
650            },
651        });
652    });
653}
654
655/// Resolve a lazy hook handler to its closure against `vm`, loading the
656/// handler's module (a cache hit when the firing VM already imported the
657/// graph). The resolved closure is memoized on the firing VM's module
658/// cache via `load_module_exports`, so repeated fires within one VM stay
659/// cheap, while a fresh VM (next test) re-resolves against its own state.
660async fn resolve_lazy_hook_closure(
661    vm: &mut crate::vm::Vm,
662    lazy: &LazyVmHookHandler,
663) -> Result<Arc<VmClosure>, VmError> {
664    let exports = vm
665        .load_module_exports(&lazy.module_path)
666        .await
667        .map_err(|error| {
668            VmError::Runtime(format!(
669                "failed to load manifest hook module '{}': {error}",
670                lazy.module_path.display()
671            ))
672        })?;
673    exports.get(&lazy.function_name).cloned().ok_or_else(|| {
674        VmError::Runtime(format!(
675            "manifest hook handler '{}' is not exported by module '{}'",
676            lazy.function_name,
677            lazy.module_path.display()
678        ))
679    })
680}
681
682async fn resolve_lifecycle_handler(
683    vm: &mut crate::vm::Vm,
684    handler: &VmLifecycleHandlerRef,
685) -> Result<Arc<VmClosure>, VmError> {
686    match handler {
687        VmLifecycleHandlerRef::Eager(closure) => Ok(Arc::clone(closure)),
688        VmLifecycleHandlerRef::Lazy(lazy) => resolve_lazy_hook_closure(vm, lazy).await,
689    }
690}
691
692pub fn clear_tool_hooks() {
693    RUNTIME_HOOKS.with(|hooks| {
694        hooks
695            .borrow_mut()
696            .retain(|hook| !matches!(hook.event, HookEvent::PreToolUse | HookEvent::PostToolUse));
697    });
698    set_singleton_pre_tool_hook(None);
699}
700
701pub fn clear_runtime_hooks() {
702    RUNTIME_HOOKS.with(|hooks| hooks.borrow_mut().clear());
703    set_singleton_pre_tool_hook(None);
704    super::clear_command_policies();
705}
706
707/// Clear only session-level lifecycle hooks (session_start, session_end,
708/// user_prompt_submit, etc.). Leaves tool, persona, step, worker, and
709/// agent-turn hooks installed. Mirrors `clear_tool_hooks()` /
710/// `clear_persona_hooks()` for the new surface.
711pub fn clear_session_hooks() {
712    RUNTIME_HOOKS.with(|hooks| {
713        hooks
714            .borrow_mut()
715            .retain(|hook| !hook.event.is_session_lifecycle());
716    });
717}
718
719fn value_at_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
720    let mut current = value;
721    for segment in path.split('.') {
722        let serde_json::Value::Object(map) = current else {
723            return None;
724        };
725        current = map.get(segment)?;
726    }
727    Some(current)
728}
729
730fn value_truthy(value: &serde_json::Value) -> bool {
731    match value {
732        serde_json::Value::Null => false,
733        serde_json::Value::Bool(value) => *value,
734        serde_json::Value::Number(value) => value
735            .as_i64()
736            .map(|number| number != 0)
737            .or_else(|| value.as_u64().map(|number| number != 0))
738            .or_else(|| value.as_f64().map(|number| number != 0.0))
739            .unwrap_or(false),
740        serde_json::Value::String(value) => !value.is_empty(),
741        serde_json::Value::Array(values) => !values.is_empty(),
742        serde_json::Value::Object(values) => !values.is_empty(),
743    }
744}
745
746fn value_to_pattern_string(value: Option<&serde_json::Value>) -> String {
747    match value {
748        Some(serde_json::Value::String(text)) => text.clone(),
749        Some(other) => other.to_string(),
750        None => String::new(),
751    }
752}
753
754fn strip_quoted(value: &str) -> &str {
755    value
756        .trim()
757        .strip_prefix('"')
758        .and_then(|text| text.strip_suffix('"'))
759        .or_else(|| {
760            value
761                .trim()
762                .strip_prefix('\'')
763                .and_then(|text| text.strip_suffix('\''))
764        })
765        .unwrap_or(value.trim())
766}
767
768fn compile_event_pattern(pattern: String) -> PatternMatcher {
769    let trimmed = pattern.trim();
770    let expression = if trimmed.is_empty() || trimmed == "*" {
771        EventPatternExpression::MatchAll
772    } else if let Some((lhs, rhs)) = trimmed.split_once("=~") {
773        match Regex::new(strip_quoted(rhs)) {
774            Ok(regex) => EventPatternExpression::Regex {
775                path: lhs.trim().to_string(),
776                regex,
777            },
778            Err(_) => EventPatternExpression::NeverMatch,
779        }
780    } else if let Some((lhs, rhs)) = trimmed.split_once("==") {
781        EventPatternExpression::Equals {
782            path: lhs.trim().to_string(),
783            value: strip_quoted(rhs).to_string(),
784        }
785    } else if let Some((lhs, rhs)) = trimmed.split_once("!=") {
786        EventPatternExpression::NotEquals {
787            path: lhs.trim().to_string(),
788            value: strip_quoted(rhs).to_string(),
789        }
790    } else if trimmed.contains('.') {
791        EventPatternExpression::PathTruthy(trimmed.to_string())
792    } else {
793        EventPatternExpression::ToolNameGlob(trimmed.to_string())
794    };
795    PatternMatcher::EventExpression {
796        source: pattern,
797        expression,
798    }
799}
800
801fn expression_matches(
802    source: &str,
803    expression: &EventPatternExpression,
804    payload: &serde_json::Value,
805) -> bool {
806    let pattern = source.trim();
807    if pattern.is_empty() || pattern == "*" {
808        return true;
809    }
810    if let Some(target) = value_at_path(payload, "target").and_then(serde_json::Value::as_str) {
811        if glob_match(pattern, target) {
812            return true;
813        }
814    }
815    match expression {
816        EventPatternExpression::MatchAll => true,
817        EventPatternExpression::NeverMatch => false,
818        EventPatternExpression::Regex { path, regex } => {
819            let value = value_to_pattern_string(value_at_path(payload, path));
820            regex.is_match(&value)
821        }
822        EventPatternExpression::Equals { path, value } => {
823            value_to_pattern_string(value_at_path(payload, path)) == *value
824        }
825        EventPatternExpression::NotEquals { path, value } => {
826            value_to_pattern_string(value_at_path(payload, path)) != *value
827        }
828        EventPatternExpression::PathTruthy(path) => {
829            value_at_path(payload, path).is_some_and(value_truthy)
830        }
831        EventPatternExpression::ToolNameGlob(pattern) => glob_match(
832            pattern,
833            &value_to_pattern_string(value_at_path(payload, "tool.name")),
834        ),
835    }
836}
837
838fn hook_matches(hook: &RuntimeHook, tool_name: Option<&str>, payload: &serde_json::Value) -> bool {
839    match &hook.matcher {
840        PatternMatcher::ToolNameGlob(pattern) => {
841            tool_name.is_some_and(|candidate| glob_match(pattern, candidate))
842        }
843        PatternMatcher::EventExpression { source, expression } => {
844            expression_matches(source, expression, payload)
845        }
846    }
847}
848
849fn runtime_hooks_for_event(event: HookEvent) -> Vec<RuntimeHook> {
850    RUNTIME_HOOKS.with(|hooks| {
851        hooks
852            .borrow()
853            .iter()
854            .filter(|hook| hook.event == event)
855            .cloned()
856            .collect()
857    })
858}
859
860/// Invoke a VM-backed hook handler (eager [`RuntimeHookHandler::Vm`] or
861/// lazily-resolved [`RuntimeHookHandler::LazyVm`]) against a child of the
862/// firing VM. Returns `None` for handlers that are not VM-backed.
863async fn invoke_vm_hook_handler(
864    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
865    handler: &RuntimeHookHandler,
866    payload: &serde_json::Value,
867) -> Result<Option<VmValue>, VmError> {
868    let Some(mut vm) = ctx.map(crate::vm::AsyncBuiltinCtx::child_vm) else {
869        return Err(VmError::Runtime(
870            "runtime hook requires an async builtin VM context".to_string(),
871        ));
872    };
873    let closure = match handler {
874        RuntimeHookHandler::Vm { closure, .. } => Arc::clone(closure),
875        RuntimeHookHandler::LazyVm { lazy, .. } => resolve_lazy_hook_closure(&mut vm, lazy).await?,
876        _ => return Ok(None),
877    };
878    let arg = crate::stdlib::json_to_vm_value(payload);
879    let result = vm.call_closure_pub(&closure, &[arg]).await;
880    if let Some(ctx) = ctx {
881        ctx.forward_output(&vm.take_output());
882    }
883    Ok(Some(result?))
884}
885
886async fn invoke_vm_lifecycle_hooks(
887    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
888    event: HookEvent,
889    registrations: Vec<VmLifecycleHookRegistration>,
890    payload: &serde_json::Value,
891) -> Result<(), VmError> {
892    let Some(mut vm) = ctx.map(crate::vm::AsyncBuiltinCtx::child_vm) else {
893        return Err(VmError::Runtime(
894            "runtime hook requires an async builtin VM context".to_string(),
895        ));
896    };
897    let arg = crate::stdlib::json_to_vm_value(payload);
898    let session_id = payload
899        .get("session")
900        .and_then(|v| v.get("id"))
901        .and_then(|v| v.as_str())
902        .unwrap_or("")
903        .to_string();
904    for registration in registrations {
905        record_hook_call(&session_id, event, &registration.handler_name, payload);
906        let closure = resolve_lifecycle_handler(&mut vm, &registration.handler).await?;
907        let raw = vm.call_closure_pub(&closure, &[arg.clone()]).await?;
908        if let Some(ctx) = ctx {
909            ctx.forward_output(&vm.take_output());
910        }
911        let effects = parse_hook_effects(event, &raw)?;
912        record_hook_returned(
913            &session_id,
914            event,
915            &registration.handler_name,
916            &HookControl::Allow,
917            &raw,
918        );
919        inject_hook_effects(session_id.as_str(), effects, Some(event))?;
920    }
921    Ok(())
922}
923
924fn reminder_error(context: &str, message: impl Into<String>) -> VmError {
925    VmError::Runtime(format!("{context}: {}", message.into()))
926}
927
928fn reminder_code_error(context: &str, code: Code, message: impl Into<String>) -> VmError {
929    reminder_error(context, format!("{}: {}", code.as_str(), message.into()))
930}
931
932fn unsupported_reminder_event_error(event: HookEvent, context: &str) -> VmError {
933    reminder_code_error(
934        context,
935        Code::ReminderUnsupportedHookEvent,
936        format!(
937            "{} does not support reminder effects; use a session, tool, step, or persona hook",
938            event.as_str()
939        ),
940    )
941}
942
943fn required_reminder_spec_string(
944    options: &crate::value::DictMap,
945    key: &str,
946    context: &str,
947) -> Result<String, VmError> {
948    match options.get(key) {
949        Some(VmValue::String(value)) if !value.trim().is_empty() => Ok(value.to_string()),
950        Some(VmValue::String(_)) | None | Some(VmValue::Nil) => Err(reminder_error(
951            context,
952            format!("`{key}` must be a non-empty string"),
953        )),
954        Some(other) => Err(reminder_error(
955            context,
956            format!("`{key}` must be a string, got {}", other.type_name()),
957        )),
958    }
959}
960
961fn optional_reminder_spec_string(
962    options: &crate::value::DictMap,
963    key: &str,
964    context: &str,
965) -> Result<Option<String>, VmError> {
966    match options.get(key) {
967        None | Some(VmValue::Nil) => Ok(None),
968        Some(VmValue::String(value)) => {
969            let trimmed = value.trim();
970            if trimmed.is_empty() {
971                Ok(None)
972            } else {
973                Ok(Some(trimmed.to_string()))
974            }
975        }
976        Some(other) => Err(reminder_error(
977            context,
978            format!("`{key}` must be a string or nil, got {}", other.type_name()),
979        )),
980    }
981}
982
983fn optional_reminder_spec_bool(
984    options: &crate::value::DictMap,
985    key: &str,
986    context: &str,
987) -> Result<Option<bool>, VmError> {
988    match options.get(key) {
989        None | Some(VmValue::Nil) => Ok(None),
990        Some(VmValue::Bool(value)) => Ok(Some(*value)),
991        Some(other) => Err(reminder_error(
992            context,
993            format!("`{key}` must be a bool or nil, got {}", other.type_name()),
994        )),
995    }
996}
997
998fn reminder_spec_tags(
999    options: &crate::value::DictMap,
1000    context: &str,
1001) -> Result<Vec<String>, VmError> {
1002    match options.get("tags") {
1003        None | Some(VmValue::Nil) => Ok(Vec::new()),
1004        Some(VmValue::List(values)) => {
1005            let mut tags = Vec::new();
1006            for value in values.iter() {
1007                let VmValue::String(tag) = value else {
1008                    return Err(reminder_error(
1009                        context,
1010                        format!("`tags` entries must be strings, got {}", value.type_name()),
1011                    ));
1012                };
1013                let trimmed = tag.trim();
1014                if trimmed.is_empty() {
1015                    return Err(reminder_error(
1016                        context,
1017                        "`tags` entries must be non-empty strings",
1018                    ));
1019                }
1020                if !tags.iter().any(|existing| existing == trimmed) {
1021                    tags.push(trimmed.to_string());
1022                }
1023            }
1024            Ok(tags)
1025        }
1026        Some(other) => Err(reminder_error(
1027            context,
1028            format!("`tags` must be a list or nil, got {}", other.type_name()),
1029        )),
1030    }
1031}
1032
1033fn optional_reminder_spec_ttl(
1034    options: &crate::value::DictMap,
1035    context: &str,
1036) -> Result<Option<i64>, VmError> {
1037    match options.get("ttl_turns") {
1038        None | Some(VmValue::Nil) => Ok(None),
1039        Some(VmValue::Int(value)) if *value > 0 => Ok(Some(*value)),
1040        Some(VmValue::Int(_)) => Err(reminder_error(context, "`ttl_turns` must be > 0")),
1041        Some(other) => Err(reminder_error(
1042            context,
1043            format!(
1044                "`ttl_turns` must be an int or nil, got {}",
1045                other.type_name()
1046            ),
1047        )),
1048    }
1049}
1050
1051fn optional_reminder_spec_propagate(
1052    options: &crate::value::DictMap,
1053    context: &str,
1054) -> Result<Option<ReminderPropagate>, VmError> {
1055    optional_reminder_spec_string(options, "propagate", context)?
1056        .map(|value| match value.as_str() {
1057            "all" => Ok(ReminderPropagate::All),
1058            "session" => Ok(ReminderPropagate::Session),
1059            "none" => Ok(ReminderPropagate::None),
1060            _ => Err(reminder_code_error(
1061                context,
1062                Code::ReminderUnknownPropagate,
1063                "`propagate` must be one of all, session, or none",
1064            )),
1065        })
1066        .transpose()
1067}
1068
1069fn optional_reminder_spec_role_hint(
1070    options: &crate::value::DictMap,
1071    context: &str,
1072) -> Result<Option<ReminderRoleHint>, VmError> {
1073    optional_reminder_spec_string(options, "role_hint", context)?
1074        .map(|value| match value.as_str() {
1075            "system" => Ok(ReminderRoleHint::System),
1076            "developer" => Ok(ReminderRoleHint::Developer),
1077            "user_block" => Ok(ReminderRoleHint::UserBlock),
1078            "ephemeral_cache" => Ok(ReminderRoleHint::EphemeralCache),
1079            _ => Err(reminder_error(
1080                context,
1081                "`role_hint` must be one of system, developer, user_block, or ephemeral_cache",
1082            )),
1083        })
1084        .transpose()
1085}
1086
1087fn parse_reminder_spec(value: &VmValue, context: &str) -> Result<ReminderSpec, VmError> {
1088    let Some(options) = value.as_dict() else {
1089        return Err(reminder_error(
1090            context,
1091            format!("reminder spec must be a dict, got {}", value.type_name()),
1092        ));
1093    };
1094    const ALLOWED: &[&str] = &[
1095        "body",
1096        "tags",
1097        "dedupe_key",
1098        "ttl_turns",
1099        "preserve_on_compact",
1100        "propagate",
1101        "role_hint",
1102    ];
1103    let unknown = options
1104        .keys()
1105        .filter(|key| !ALLOWED.contains(&key.as_str()))
1106        .map(|key| key.as_str())
1107        .collect::<Vec<_>>();
1108    if !unknown.is_empty() {
1109        return Err(reminder_code_error(
1110            context,
1111            Code::ReminderUnknownOption,
1112            format!("unknown reminder option(s): {}", unknown.join(", ")),
1113        ));
1114    }
1115    Ok(SystemReminder {
1116        id: uuid::Uuid::now_v7().to_string(),
1117        tags: reminder_spec_tags(options, context)?,
1118        dedupe_key: optional_reminder_spec_string(options, "dedupe_key", context)?,
1119        ttl_turns: optional_reminder_spec_ttl(options, context)?,
1120        preserve_on_compact: optional_reminder_spec_bool(options, "preserve_on_compact", context)?
1121            .unwrap_or(false),
1122        propagate: optional_reminder_spec_propagate(options, context)?
1123            .unwrap_or(ReminderPropagate::Session),
1124        role_hint: optional_reminder_spec_role_hint(options, context)?
1125            .unwrap_or(ReminderRoleHint::System),
1126        source: ReminderSource::Hook,
1127        body: required_reminder_spec_string(options, "body", context)?,
1128        fired_at_turn: 0,
1129        originating_agent_id: None,
1130    })
1131}
1132
1133fn looks_like_reminder_spec(map: &crate::value::DictMap) -> bool {
1134    map.contains_key("body")
1135        && !map.contains_key("deny")
1136        && !map.contains_key("args")
1137        && !map.contains_key("result")
1138        && !map.contains_key("output")
1139        && !map.contains_key("modify")
1140        && !map.contains_key("block")
1141        && !map.contains_key("decision")
1142        && !map.contains_key("action")
1143        && !map.contains_key("control")
1144}
1145
1146fn parse_hook_effect_item(event: HookEvent, value: &VmValue) -> Result<HookEffect, VmError> {
1147    let context = format!("{} hook reminder", event.as_str());
1148    if let Some(map) = value.as_dict() {
1149        if let Some(reminder) = map.get("reminder") {
1150            if !event.supports_reminder_effects() {
1151                return Err(unsupported_reminder_event_error(event, &context));
1152            }
1153            return Ok(HookEffect::Reminder(parse_reminder_spec(
1154                reminder, &context,
1155            )?));
1156        }
1157        if matches!(
1158            map.get("type")
1159                .or_else(|| map.get("kind"))
1160                .map(|value| value.display())
1161                .as_deref(),
1162            Some("reminder" | "Reminder")
1163        ) {
1164            if !event.supports_reminder_effects() {
1165                return Err(unsupported_reminder_event_error(event, &context));
1166            }
1167            let spec = map
1168                .get("spec")
1169                .or_else(|| map.get("reminder"))
1170                .ok_or_else(|| reminder_error(&context, "reminder effect missing `spec`"))?;
1171            return Ok(HookEffect::Reminder(parse_reminder_spec(spec, &context)?));
1172        }
1173        if looks_like_reminder_spec(map) {
1174            if !event.supports_reminder_effects() {
1175                return Err(unsupported_reminder_event_error(event, &context));
1176            }
1177            return Ok(HookEffect::Reminder(parse_reminder_spec(value, &context)?));
1178        }
1179    }
1180    Err(reminder_error(
1181        &context,
1182        "hook effect must be {reminder: {...}} or a reminder spec",
1183    ))
1184}
1185
1186pub fn parse_hook_effects(event: HookEvent, value: &VmValue) -> Result<Vec<HookEffect>, VmError> {
1187    let Some(map) = value.as_dict() else {
1188        if let VmValue::List(items) = value {
1189            return items
1190                .iter()
1191                .map(|item| parse_hook_effect_item(event, item))
1192                .collect();
1193        }
1194        return Ok(Vec::new());
1195    };
1196
1197    let mut effects = Vec::new();
1198    if let Some(items) = map.get("effects") {
1199        match items {
1200            VmValue::List(list) => {
1201                for item in list.iter() {
1202                    effects.push(parse_hook_effect_item(event, item)?);
1203                }
1204            }
1205            other => effects.push(parse_hook_effect_item(event, other)?),
1206        }
1207    }
1208    if let Some(reminder) = map.get("reminder") {
1209        let context = format!("{} hook reminder", event.as_str());
1210        if !event.supports_reminder_effects() {
1211            return Err(unsupported_reminder_event_error(event, &context));
1212        }
1213        effects.push(HookEffect::Reminder(parse_reminder_spec(
1214            reminder, &context,
1215        )?));
1216    } else if effects.is_empty() && looks_like_reminder_spec(map) {
1217        let context = format!("{} hook reminder", event.as_str());
1218        if !event.supports_reminder_effects() {
1219            return Err(unsupported_reminder_event_error(event, &context));
1220        }
1221        effects.push(HookEffect::Reminder(parse_reminder_spec(value, &context)?));
1222    }
1223    Ok(effects)
1224}
1225
1226fn action_value_after_effects(value: VmValue, default_action: VmValue) -> VmValue {
1227    let VmValue::Dict(map) = value else {
1228        return value;
1229    };
1230    if let Some(then) = map.get("then") {
1231        return then.clone();
1232    }
1233    let has_effects = map.contains_key("effects")
1234        || map.contains_key("reminder")
1235        || looks_like_reminder_spec(map.as_ref());
1236    if !has_effects {
1237        return VmValue::Dict(map);
1238    }
1239    let mut action = map.as_ref().clone();
1240    action.remove("effects");
1241    action.remove("reminder");
1242    action.remove("then");
1243    if action.keys().any(|key| {
1244        matches!(
1245            key.as_str(),
1246            "deny" | "args" | "result" | "output" | "modify" | "block" | "decision" | "action"
1247        )
1248    }) {
1249        VmValue::dict(action)
1250    } else {
1251        default_action
1252    }
1253}
1254
1255pub fn collect_hook_effects_and_action(
1256    event: HookEvent,
1257    value: VmValue,
1258    default_action: VmValue,
1259) -> Result<(VmValue, Vec<HookEffect>), VmError> {
1260    let mut current = value;
1261    let mut effects = Vec::new();
1262    for _ in 0..32 {
1263        let current_effects = parse_hook_effects(event, &current)?;
1264        if current_effects.is_empty() {
1265            return Ok((current, effects));
1266        }
1267        effects.extend(current_effects);
1268        current = action_value_after_effects(current, default_action.clone());
1269    }
1270    Err(VmError::Runtime(format!(
1271        "{} hook reminder return nested too deeply",
1272        event.as_str()
1273    )))
1274}
1275
1276fn inject_hook_effects(
1277    session_id: &str,
1278    effects: Vec<HookEffect>,
1279    event: Option<HookEvent>,
1280) -> Result<(), VmError> {
1281    if effects.is_empty() {
1282        return Ok(());
1283    }
1284    let target_session = if session_id.is_empty() {
1285        crate::agent_sessions::current_session_id().unwrap_or_default()
1286    } else {
1287        session_id.to_string()
1288    };
1289    if target_session.is_empty() {
1290        return Ok(());
1291    }
1292    for effect in effects {
1293        match effect {
1294            HookEffect::Reminder(spec) => {
1295                let reminder_id = spec.id.clone();
1296                let tags = spec.tags.clone();
1297                let dedupe_key = spec.dedupe_key.clone();
1298                let role_hint = spec.role_hint.as_str();
1299                let source = spec.source.as_str();
1300                let ttl_turns = spec.ttl_turns;
1301                let report = crate::agent_sessions::inject_reminder(&target_session, spec)
1302                    .map_err(VmError::Runtime)?;
1303                record_hook_reminder_report(serde_json::json!({
1304                    "hook_event": event.map(|event| event.as_str()),
1305                    "session_id": &target_session,
1306                    "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1307                    "reminder_id": reminder_id,
1308                    "tags": tags,
1309                    "dedupe_key": dedupe_key,
1310                    "role_hint": role_hint,
1311                    "source": source,
1312                    "ttl_turns": ttl_turns,
1313                    "deduped_count": report.deduped_count,
1314                }));
1315            }
1316        }
1317    }
1318    Ok(())
1319}
1320
1321pub fn inject_hook_effects_into_current_session(effects: Vec<HookEffect>) -> Result<(), VmError> {
1322    inject_hook_effects("", effects, None)
1323}
1324
1325fn wrap_pre_tool_effects(effects: Vec<HookEffect>, mut action: PreToolAction) -> PreToolAction {
1326    for effect in effects.into_iter().rev() {
1327        match effect {
1328            HookEffect::Reminder(spec) => {
1329                action = PreToolAction::Reminder {
1330                    spec,
1331                    then: Box::new(action),
1332                };
1333            }
1334        }
1335    }
1336    action
1337}
1338
1339fn wrap_post_tool_effects(effects: Vec<HookEffect>, mut action: PostToolAction) -> PostToolAction {
1340    for effect in effects.into_iter().rev() {
1341        match effect {
1342            HookEffect::Reminder(spec) => {
1343                action = PostToolAction::Reminder {
1344                    spec,
1345                    then: Box::new(action),
1346                };
1347            }
1348        }
1349    }
1350    action
1351}
1352
1353fn parse_pre_tool_result(value: VmValue) -> Result<PreToolAction, VmError> {
1354    let (value, effects) =
1355        collect_hook_effects_and_action(HookEvent::PreToolUse, value, VmValue::Nil)?;
1356    match value {
1357        VmValue::Nil => Ok(wrap_pre_tool_effects(effects, PreToolAction::Allow)),
1358        VmValue::Dict(map) => {
1359            if let Some(reason) = map.get("deny") {
1360                return Ok(wrap_pre_tool_effects(
1361                    effects,
1362                    PreToolAction::Deny(reason.display()),
1363                ));
1364            }
1365            if let Some(args) = map.get("args") {
1366                return Ok(wrap_pre_tool_effects(
1367                    effects,
1368                    PreToolAction::Modify(crate::llm::vm_value_to_json(args)),
1369                ));
1370            }
1371            Ok(wrap_pre_tool_effects(effects, PreToolAction::Allow))
1372        }
1373        other => Err(VmError::Runtime(format!(
1374            "PreToolUse hook must return nil or {{deny, args}}, got {}",
1375            other.type_name()
1376        ))),
1377    }
1378}
1379
1380fn parse_post_tool_result(value: VmValue) -> Result<PostToolAction, VmError> {
1381    let (value, effects) =
1382        collect_hook_effects_and_action(HookEvent::PostToolUse, value, VmValue::Nil)?;
1383    match value {
1384        VmValue::Nil => Ok(wrap_post_tool_effects(effects, PostToolAction::Pass)),
1385        VmValue::String(text) => Ok(wrap_post_tool_effects(
1386            effects,
1387            PostToolAction::Modify(text.to_string()),
1388        )),
1389        VmValue::Dict(map) => {
1390            if let Some(result) = map.get("result") {
1391                return Ok(wrap_post_tool_effects(
1392                    effects,
1393                    PostToolAction::Modify(result.display()),
1394                ));
1395            }
1396            Ok(wrap_post_tool_effects(effects, PostToolAction::Pass))
1397        }
1398        other => Err(VmError::Runtime(format!(
1399            "PostToolUse hook must return nil, string, or {{result}}, got {}",
1400            other.type_name()
1401        ))),
1402    }
1403}
1404
1405pub fn apply_pre_tool_action(
1406    action: PreToolAction,
1407    current_args: &mut serde_json::Value,
1408) -> Result<Option<String>, VmError> {
1409    match action {
1410        PreToolAction::Allow => Ok(None),
1411        PreToolAction::Deny(reason) => Ok(Some(reason)),
1412        PreToolAction::Modify(new_args) => {
1413            *current_args = new_args;
1414            Ok(None)
1415        }
1416        PreToolAction::Reminder { spec, then } => {
1417            inject_hook_effects(
1418                "",
1419                vec![HookEffect::Reminder(spec)],
1420                Some(HookEvent::PreToolUse),
1421            )?;
1422            apply_pre_tool_action(*then, current_args)
1423        }
1424    }
1425}
1426
1427fn apply_post_tool_action(action: PostToolAction, current: String) -> Result<String, VmError> {
1428    match action {
1429        PostToolAction::Pass => Ok(current),
1430        PostToolAction::Modify(new_result) => Ok(new_result),
1431        PostToolAction::Reminder { spec, then } => {
1432            inject_hook_effects(
1433                "",
1434                vec![HookEffect::Reminder(spec)],
1435                Some(HookEvent::PostToolUse),
1436            )?;
1437            apply_post_tool_action(*then, current)
1438        }
1439    }
1440}
1441
1442/// Run all matching PreToolUse hooks. Returns the final action.
1443pub async fn run_pre_tool_hooks(
1444    tool_name: &str,
1445    args: &serde_json::Value,
1446) -> Result<PreToolAction, VmError> {
1447    run_pre_tool_hooks_with_ctx(None, tool_name, args).await
1448}
1449
1450pub async fn run_pre_tool_hooks_with_ctx(
1451    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
1452    tool_name: &str,
1453    args: &serde_json::Value,
1454) -> Result<PreToolAction, VmError> {
1455    let hooks = runtime_hooks_for_event(HookEvent::PreToolUse);
1456    let mut current_args = args.clone();
1457    // Singleton runtime hook (currently the stdlib path_scope_guard) runs
1458    // before user-registered hooks so a tagged deny lands in the
1459    // PostToolUse / reminder path before any other hook fires.
1460    if let Some(singleton) = singleton_pre_tool_hook() {
1461        let action = singleton(tool_name, &current_args);
1462        if let Some(reason) = apply_pre_tool_action(action, &mut current_args)? {
1463            return Ok(PreToolAction::Deny(reason));
1464        }
1465    }
1466    for hook in &hooks {
1467        let payload = if matches!(hook.matcher, PatternMatcher::EventExpression { .. }) {
1468            Some(serde_json::json!({
1469                "event": HookEvent::PreToolUse.as_str(),
1470                "tool": {
1471                    "name": tool_name,
1472                    "args": current_args.clone(),
1473                    "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1474                },
1475                "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1476            }))
1477        } else {
1478            None
1479        };
1480        if !hook_matches(
1481            hook,
1482            Some(tool_name),
1483            payload.as_ref().unwrap_or(&serde_json::Value::Null),
1484        ) {
1485            continue;
1486        }
1487        let action = match &hook.handler {
1488            RuntimeHookHandler::NativePreTool(pre) => pre(tool_name, &current_args),
1489            RuntimeHookHandler::Vm { .. } | RuntimeHookHandler::LazyVm { .. } => {
1490                let payload = payload.as_ref().ok_or_else(|| {
1491                    VmError::Runtime("VM PreToolUse hook requires an event payload".to_string())
1492                })?;
1493                let Some(value) = invoke_vm_hook_handler(ctx, &hook.handler, payload).await? else {
1494                    continue;
1495                };
1496                parse_pre_tool_result(value)?
1497            }
1498            RuntimeHookHandler::NativePostTool(_) => continue,
1499        };
1500        if let Some(reason) = apply_pre_tool_action(action, &mut current_args)? {
1501            return Ok(PreToolAction::Deny(reason));
1502        }
1503    }
1504    if current_args != *args {
1505        Ok(PreToolAction::Modify(current_args))
1506    } else {
1507        Ok(PreToolAction::Allow)
1508    }
1509}
1510
1511/// Run all matching PostToolUse hooks. Returns the (possibly modified) result.
1512pub async fn run_post_tool_hooks(
1513    tool_name: &str,
1514    args: &serde_json::Value,
1515    result: &str,
1516) -> Result<String, VmError> {
1517    run_post_tool_hooks_with_ctx(None, tool_name, args, result).await
1518}
1519
1520pub async fn run_post_tool_hooks_with_ctx(
1521    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
1522    tool_name: &str,
1523    args: &serde_json::Value,
1524    result: &str,
1525) -> Result<String, VmError> {
1526    let hooks = runtime_hooks_for_event(HookEvent::PostToolUse);
1527    let mut current = result.to_string();
1528    for hook in &hooks {
1529        let payload = if matches!(hook.matcher, PatternMatcher::EventExpression { .. }) {
1530            Some(serde_json::json!({
1531                "event": HookEvent::PostToolUse.as_str(),
1532                "tool": {
1533                    "name": tool_name,
1534                    "args": args,
1535                    "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1536                },
1537                "tool_call_id": crate::agent_sessions::current_tool_call_id(),
1538                "result": {
1539                    "text": current.clone(),
1540                },
1541            }))
1542        } else {
1543            None
1544        };
1545        if !hook_matches(
1546            hook,
1547            Some(tool_name),
1548            payload.as_ref().unwrap_or(&serde_json::Value::Null),
1549        ) {
1550            continue;
1551        }
1552        let action = match &hook.handler {
1553            RuntimeHookHandler::NativePostTool(post) => post(tool_name, &current),
1554            RuntimeHookHandler::Vm { .. } | RuntimeHookHandler::LazyVm { .. } => {
1555                let payload = payload.as_ref().ok_or_else(|| {
1556                    VmError::Runtime("VM PostToolUse hook requires an event payload".to_string())
1557                })?;
1558                let Some(value) = invoke_vm_hook_handler(ctx, &hook.handler, payload).await? else {
1559                    continue;
1560                };
1561                parse_post_tool_result(value)?
1562            }
1563            RuntimeHookHandler::NativePreTool(_) => continue,
1564        };
1565        match action {
1566            PostToolAction::Pass => {}
1567            PostToolAction::Modify(new_result) => {
1568                current = new_result;
1569            }
1570            PostToolAction::Reminder { spec, then } => {
1571                inject_hook_effects(
1572                    "",
1573                    vec![HookEffect::Reminder(spec)],
1574                    Some(HookEvent::PostToolUse),
1575                )?;
1576                current = apply_post_tool_action(*then, current)?;
1577            }
1578        }
1579    }
1580    Ok(current)
1581}
1582
1583pub async fn run_lifecycle_hooks(
1584    event: HookEvent,
1585    payload: &serde_json::Value,
1586) -> Result<(), VmError> {
1587    run_lifecycle_hooks_with_ctx(None, event, payload).await
1588}
1589
1590pub async fn run_lifecycle_hooks_with_ctx(
1591    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
1592    event: HookEvent,
1593    payload: &serde_json::Value,
1594) -> Result<(), VmError> {
1595    let registrations = matching_vm_lifecycle_registrations(event, payload);
1596    if registrations.is_empty() {
1597        return Ok(());
1598    }
1599    invoke_vm_lifecycle_hooks(ctx, event, registrations, payload).await
1600}
1601
1602/// Run veto-capable session-level lifecycle hooks. Successive hooks see
1603/// `Allow`; the first non-`Allow` return short-circuits and is returned
1604/// to the caller. Hook invocations and decisions are captured on the
1605/// active session's transcript under `hook_call`, `hook_returned`, and
1606/// `hook_vetoed` so a replay reproduces the same control flow.
1607///
1608/// `Modify` does not short-circuit: subsequent hooks see the rewritten
1609/// payload, and the final `HookControl::Modify` returned by the chain
1610/// carries the merged payload back to the dispatcher so the recording
1611/// layer captures the post-modify shape (replay determinism). If a
1612/// later hook in the same chain returns `Allow`, the merged
1613/// `Modify { payload }` from earlier hooks is still surfaced.
1614pub async fn run_lifecycle_hooks_with_control(
1615    event: HookEvent,
1616    payload: &serde_json::Value,
1617) -> Result<HookControl, VmError> {
1618    run_lifecycle_hooks_with_control_with_ctx(None, event, payload).await
1619}
1620
1621pub async fn run_lifecycle_hooks_with_control_with_ctx(
1622    ctx: Option<&crate::vm::AsyncBuiltinCtx>,
1623    event: HookEvent,
1624    payload: &serde_json::Value,
1625) -> Result<HookControl, VmError> {
1626    let registrations = matching_vm_lifecycle_registrations(event, payload);
1627    if registrations.is_empty() {
1628        return Ok(HookControl::Allow);
1629    }
1630    let Some(mut vm) = ctx.map(crate::vm::AsyncBuiltinCtx::child_vm) else {
1631        return Err(VmError::Runtime(
1632            "session lifecycle hook requires an async builtin VM context".to_string(),
1633        ));
1634    };
1635    let session_id = payload
1636        .get("session")
1637        .and_then(|v| v.get("id"))
1638        .and_then(|v| v.as_str())
1639        .unwrap_or("")
1640        .to_string();
1641    let mut current_payload = payload.clone();
1642    let mut accumulated_modify: Option<serde_json::Value> = None;
1643    for registration in registrations {
1644        let arg = crate::stdlib::json_to_vm_value(&current_payload);
1645        record_hook_call(
1646            &session_id,
1647            event,
1648            &registration.handler_name,
1649            &current_payload,
1650        );
1651        let closure = resolve_lifecycle_handler(&mut vm, &registration.handler).await?;
1652        let raw = vm.call_closure_pub(&closure, &[arg]).await?;
1653        if let Some(ctx) = ctx {
1654            ctx.forward_output(&vm.take_output());
1655        }
1656        let outcome = parse_hook_outcome(event, &raw)?;
1657        record_hook_returned(
1658            &session_id,
1659            event,
1660            &registration.handler_name,
1661            &outcome.control,
1662            &raw,
1663        );
1664        inject_hook_effects(session_id.as_str(), outcome.effects, Some(event))?;
1665        match outcome.control {
1666            HookControl::Allow => continue,
1667            HookControl::Modify { payload: modified } => {
1668                current_payload = modified.clone();
1669                accumulated_modify = Some(modified);
1670            }
1671            other @ (HookControl::Block { .. } | HookControl::Decision { .. }) => {
1672                record_hook_vetoed(&session_id, event, &registration.handler_name, &other);
1673                return Ok(other);
1674            }
1675        }
1676    }
1677    if let Some(payload) = accumulated_modify {
1678        Ok(HookControl::Modify { payload })
1679    } else {
1680        Ok(HookControl::Allow)
1681    }
1682}
1683
1684fn parse_hook_outcome(event: HookEvent, value: &VmValue) -> Result<HookOutcome, VmError> {
1685    let effects = parse_hook_effects(event, value)?;
1686    let action_value = if matches!(value, VmValue::List(_)) {
1687        VmValue::Nil
1688    } else {
1689        action_value_after_effects(value.clone(), VmValue::Nil)
1690    };
1691    let control = parse_hook_control(event, &action_value)?;
1692    Ok(HookOutcome { control, effects })
1693}
1694
1695/// Public alias for the internal `parse_hook_control`. Used by the
1696/// pipeline-finish dispatcher (`fire_finish_lifecycle_event`) to
1697/// translate the action half of a hook return value into a control
1698/// signal so it can honor the lifecycle table (PreFinish rejects
1699/// Block, OnUnsettledDetected respects Block, etc.).
1700pub fn parse_hook_control_for_finish(
1701    event: HookEvent,
1702    value: &VmValue,
1703) -> Result<HookControl, VmError> {
1704    parse_hook_control(event, value)
1705}
1706
1707fn parse_hook_control(event: HookEvent, value: &VmValue) -> Result<HookControl, VmError> {
1708    match value {
1709        VmValue::Nil | VmValue::Bool(true) => Ok(HookControl::Allow),
1710        VmValue::Bool(false) => Ok(HookControl::Block {
1711            reason: format!("{} hook returned false", event.as_str()),
1712        }),
1713        VmValue::Dict(map) => {
1714            if let Some(decision) = map.get("decision") {
1715                let kind = decision.display();
1716                let kind_norm = kind.trim().to_ascii_lowercase();
1717                if !matches!(kind_norm.as_str(), "allow" | "deny" | "ask") {
1718                    return Err(VmError::Runtime(format!(
1719                        "{} hook `decision` must be \"allow\", \"deny\", or \"ask\"; got \"{kind}\"",
1720                        event.as_str()
1721                    )));
1722                }
1723                let reason = map.get("reason").and_then(|v| match v {
1724                    VmValue::Nil => None,
1725                    other => Some(other.display()),
1726                });
1727                return Ok(HookControl::Decision {
1728                    kind: kind_norm,
1729                    reason,
1730                });
1731            }
1732            let block = map.get("block").map(vm_value_truthy).unwrap_or(false);
1733            if block {
1734                let reason = map
1735                    .get("reason")
1736                    .map(|v| v.display())
1737                    .unwrap_or_else(|| format!("{} hook blocked the operation", event.as_str()));
1738                return Ok(HookControl::Block { reason });
1739            }
1740            if let Some(modify) = map.get("modify") {
1741                return Ok(HookControl::Modify {
1742                    payload: crate::llm::vm_value_to_json(modify),
1743                });
1744            }
1745            Ok(HookControl::Allow)
1746        }
1747        other => Err(VmError::Runtime(format!(
1748            "{} hook must return nil, bool, or a control dict; got {}",
1749            event.as_str(),
1750            other.type_name()
1751        ))),
1752    }
1753}
1754
1755fn vm_value_truthy(value: &VmValue) -> bool {
1756    match value {
1757        VmValue::Nil => false,
1758        VmValue::Bool(value) => *value,
1759        VmValue::Int(value) => *value != 0,
1760        VmValue::Float(value) => *value != 0.0,
1761        VmValue::String(value) => !value.is_empty(),
1762        VmValue::List(value) => !value.is_empty(),
1763        VmValue::Dict(value) => !value.is_empty(),
1764        _ => true,
1765    }
1766}
1767
1768fn record_hook_call(
1769    session_id: &str,
1770    event: HookEvent,
1771    handler: &str,
1772    payload: &serde_json::Value,
1773) {
1774    if session_id.is_empty() {
1775        return;
1776    }
1777    let metadata = serde_json::json!({
1778        "event": event.as_str(),
1779        "handler": handler,
1780        "payload": payload,
1781    });
1782    let entry = crate::llm::helpers::transcript_event(
1783        "hook_call",
1784        "system",
1785        "internal",
1786        &format!("hook {} invoked: {}", event.as_str(), handler),
1787        Some(metadata),
1788    );
1789    let _ = crate::agent_sessions::append_event(session_id, entry);
1790}
1791
1792fn record_hook_returned(
1793    session_id: &str,
1794    event: HookEvent,
1795    handler: &str,
1796    control: &HookControl,
1797    raw: &VmValue,
1798) {
1799    if session_id.is_empty() {
1800        return;
1801    }
1802    let metadata = serde_json::json!({
1803        "event": event.as_str(),
1804        "handler": handler,
1805        "result": control.as_str(),
1806        "raw": crate::llm::vm_value_to_json(raw),
1807    });
1808    let entry = crate::llm::helpers::transcript_event(
1809        "hook_returned",
1810        "system",
1811        "internal",
1812        &format!(
1813            "hook {} returned {} from {}",
1814            event.as_str(),
1815            control.as_str(),
1816            handler
1817        ),
1818        Some(metadata),
1819    );
1820    let _ = crate::agent_sessions::append_event(session_id, entry);
1821}
1822
1823fn record_hook_vetoed(session_id: &str, event: HookEvent, handler: &str, control: &HookControl) {
1824    if session_id.is_empty() {
1825        return;
1826    }
1827    let (reason, decision) = match control {
1828        HookControl::Allow => return,
1829        HookControl::Block { reason } => (reason.clone(), None),
1830        HookControl::Decision { kind, reason } => (
1831            reason.clone().unwrap_or_else(|| format!("decision={kind}")),
1832            Some(kind.clone()),
1833        ),
1834        HookControl::Modify { .. } => return,
1835    };
1836    let metadata = serde_json::json!({
1837        "event": event.as_str(),
1838        "handler": handler,
1839        "reason": reason,
1840        "decision": decision,
1841    });
1842    let entry = crate::llm::helpers::transcript_event(
1843        "hook_vetoed",
1844        "system",
1845        "internal",
1846        &format!("hook {} vetoed by {}: {reason}", event.as_str(), handler),
1847        Some(metadata),
1848    );
1849    let _ = crate::agent_sessions::append_event(session_id, entry);
1850}
1851
1852pub fn matching_vm_lifecycle_hooks(
1853    event: HookEvent,
1854    payload: &serde_json::Value,
1855) -> Vec<VmLifecycleHookInvocation> {
1856    matching_vm_lifecycle_registrations(event, payload)
1857        .into_iter()
1858        .map(|registration| match registration.handler {
1859            VmLifecycleHandlerRef::Eager(closure) => VmLifecycleHookInvocation {
1860                closure: Some(closure),
1861                lazy: None,
1862                handler_name: registration.handler_name,
1863            },
1864            VmLifecycleHandlerRef::Lazy(lazy) => VmLifecycleHookInvocation {
1865                closure: None,
1866                lazy: Some(lazy),
1867                handler_name: registration.handler_name,
1868            },
1869        })
1870        .collect()
1871}
1872
1873fn matching_vm_lifecycle_registrations(
1874    event: HookEvent,
1875    payload: &serde_json::Value,
1876) -> Vec<VmLifecycleHookRegistration> {
1877    RUNTIME_HOOKS.with(|hooks| {
1878        hooks
1879            .borrow()
1880            .iter()
1881            .filter(|hook| hook.event == event)
1882            .filter(|hook| hook_matches(hook, None, payload))
1883            .filter_map(|hook| match &hook.handler {
1884                RuntimeHookHandler::Vm {
1885                    closure,
1886                    handler_name,
1887                } => Some(VmLifecycleHookRegistration {
1888                    handler_name: handler_name.clone(),
1889                    handler: VmLifecycleHandlerRef::Eager(Arc::clone(closure)),
1890                }),
1891                RuntimeHookHandler::LazyVm { lazy, handler_name } => {
1892                    Some(VmLifecycleHookRegistration {
1893                        handler_name: handler_name.clone(),
1894                        handler: VmLifecycleHandlerRef::Lazy(lazy.clone()),
1895                    })
1896                }
1897                RuntimeHookHandler::NativePreTool(_) | RuntimeHookHandler::NativePostTool(_) => {
1898                    None
1899                }
1900            })
1901            .collect()
1902    })
1903}
1904
1905#[cfg(test)]
1906mod tests {
1907    use super::*;
1908
1909    fn vm_string(value: &str) -> VmValue {
1910        VmValue::String(arcstr::ArcStr::from(value))
1911    }
1912
1913    fn dict(entries: Vec<(&str, VmValue)>) -> VmValue {
1914        VmValue::dict(
1915            entries
1916                .into_iter()
1917                .map(|(key, value)| (crate::value::intern_key(key), value))
1918                .collect::<crate::value::DictMap>(),
1919        )
1920    }
1921
1922    fn error_message(result: Result<Vec<HookEffect>, VmError>) -> String {
1923        match result.expect_err("expected hook reminder parse error") {
1924            VmError::Runtime(message) => message,
1925            other => panic!("expected runtime error, got {other:?}"),
1926        }
1927    }
1928
1929    #[test]
1930    fn unknown_reminder_option_reports_code() {
1931        let value = dict(vec![(
1932            "reminder",
1933            dict(vec![
1934                ("body", vm_string("remember this")),
1935                ("typo_key", VmValue::Bool(true)),
1936            ]),
1937        )]);
1938        let message = error_message(parse_hook_effects(HookEvent::PostTurn, &value));
1939        assert!(message.contains(Code::ReminderUnknownOption.as_str()));
1940        assert!(message.contains("typo_key"), "{message}");
1941    }
1942
1943    #[test]
1944    fn unknown_reminder_propagate_reports_specific_code() {
1945        let value = dict(vec![(
1946            "reminder",
1947            dict(vec![
1948                ("body", vm_string("remember this")),
1949                ("propagate", vm_string("workspace")),
1950            ]),
1951        )]);
1952        let message = error_message(parse_hook_effects(HookEvent::PostTurn, &value));
1953        assert!(message.contains(Code::ReminderUnknownPropagate.as_str()));
1954        assert!(message.contains("propagate"), "{message}");
1955    }
1956
1957    #[test]
1958    fn worker_events_reject_reminder_effects_with_specific_code() {
1959        let value = dict(vec![(
1960            "reminder",
1961            dict(vec![("body", vm_string("worker lifecycle"))]),
1962        )]);
1963        let message = error_message(parse_hook_effects(HookEvent::WorkerSpawned, &value));
1964        assert!(message.contains(Code::ReminderUnsupportedHookEvent.as_str()));
1965        assert!(message.contains("WorkerSpawned"), "{message}");
1966    }
1967
1968    #[test]
1969    fn as_str_round_trips_through_serde() {
1970        // The macro relies on serde's default unit-variant encoding
1971        // (identifier = wire name) instead of a per-variant
1972        // `#[serde(rename)]`. Lock that contract so a future variant
1973        // can't drift by accident.
1974        for &event in HookEvent::ALL {
1975            let json = serde_json::to_string(&event).unwrap();
1976            assert_eq!(json, format!("\"{}\"", event.as_str()));
1977            let parsed: HookEvent = serde_json::from_str(&json).unwrap();
1978            assert_eq!(parsed, event);
1979        }
1980    }
1981
1982    #[test]
1983    fn parse_session_event_accepts_both_spellings_for_every_session_variant() {
1984        // The macro auto-derives snake_case from the PascalCase
1985        // identifier; this test guards against a future variant whose
1986        // name doesn't round-trip cleanly (e.g. unexpected punctuation).
1987        for &event in HookEvent::ALL.iter().filter(|e| e.is_session_lifecycle()) {
1988            let pascal = event.as_str();
1989            let mut snake = String::new();
1990            pascal_to_snake_buf(pascal, &mut snake);
1991            assert_eq!(
1992                HookEvent::parse_session_event(pascal).unwrap(),
1993                event,
1994                "PascalCase `{pascal}`",
1995            );
1996            assert_eq!(
1997                HookEvent::parse_session_event(&snake).unwrap(),
1998                event,
1999                "snake_case `{snake}`",
2000            );
2001        }
2002    }
2003
2004    #[test]
2005    fn parse_session_event_rejects_non_session_variants() {
2006        // Tool, agent-turn, worker, step, and notification events must
2007        // not be accepted by the session parser — each surface owns
2008        // its own event set.
2009        for &event in HookEvent::ALL.iter().filter(|e| !e.is_session_lifecycle()) {
2010            let err = HookEvent::parse_session_event(event.as_str())
2011                .expect_err("non-session event slipped through");
2012            assert!(err.contains("unknown session hook event"), "{err}");
2013        }
2014    }
2015
2016    #[test]
2017    fn parse_provider_event_accepts_worker_and_session_and_flagged_variants() {
2018        // Worker variants are accepted by kind, session variants by
2019        // the fallback, and explicitly-flagged variants
2020        // (`provider_parse: true`) by the first-pass loop. The whole
2021        // set should round-trip.
2022        for &event in HookEvent::ALL.iter().filter(|e| {
2023            matches!(e.kind(), HookEventKind::Worker | HookEventKind::Session)
2024                || e.in_provider_parse()
2025        }) {
2026            assert_eq!(
2027                HookEvent::parse_provider_event(event.as_str()).unwrap(),
2028                event,
2029                "{event:?}",
2030            );
2031        }
2032    }
2033
2034    #[test]
2035    fn session_error_accepts_legacy_short_alias() {
2036        // `SessionError` carries an explicit `"error"` alias for
2037        // backward compat with the original event name.
2038        assert_eq!(
2039            HookEvent::parse_session_event("error").unwrap(),
2040            HookEvent::SessionError,
2041        );
2042        assert_eq!(
2043            HookEvent::parse_session_event("SessionError").unwrap(),
2044            HookEvent::SessionError,
2045        );
2046        assert_eq!(
2047            HookEvent::parse_session_event("session_error").unwrap(),
2048            HookEvent::SessionError,
2049        );
2050    }
2051
2052    #[test]
2053    fn supports_reminder_effects_excludes_only_worker_kind() {
2054        for &event in HookEvent::ALL {
2055            let supports = event.supports_reminder_effects();
2056            let expected = !matches!(event.kind(), HookEventKind::Worker);
2057            assert_eq!(
2058                supports,
2059                expected,
2060                "{event:?} ({:?}) reminder support disagrees with kind",
2061                event.kind(),
2062            );
2063        }
2064    }
2065
2066    #[test]
2067    fn from_worker_event_covers_every_worker_variant() {
2068        for worker in WorkerEvent::ALL {
2069            let event = HookEvent::from_worker_event(worker);
2070            assert!(
2071                matches!(event.kind(), HookEventKind::Worker),
2072                "WorkerEvent::{worker:?} mapped to non-Worker kind {:?}",
2073                event.kind(),
2074            );
2075            assert_eq!(event.as_str(), worker.as_str());
2076        }
2077    }
2078}