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