use std::cell::RefCell;
use std::collections::BTreeMap;
use std::future::Future;
use std::rc::Rc;
use regex::Regex;
use serde::{Deserialize, Serialize};
use harn_parser::diagnostic_codes::Code;
use crate::agent_events::WorkerEvent;
use crate::llm::helpers::{ReminderPropagate, ReminderRoleHint, ReminderSource, SystemReminder};
use crate::value::{VmClosure, VmError, VmValue};
tokio::task_local! {
static HOOK_REMINDER_REPORTS_TASK: Rc<RefCell<Vec<serde_json::Value>>>;
}
fn record_hook_reminder_report(report: serde_json::Value) {
let _ = HOOK_REMINDER_REPORTS_TASK.try_with(|reports| reports.borrow_mut().push(report));
}
pub async fn scope_hook_reminder_reports<F, T>(future: F) -> (T, Vec<serde_json::Value>)
where
F: Future<Output = T>,
{
let reports = Rc::new(RefCell::new(Vec::new()));
let output = HOOK_REMINDER_REPORTS_TASK
.scope(reports.clone(), future)
.await;
let reports = std::mem::take(&mut *reports.borrow_mut());
(output, reports)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub enum HookEvent {
#[serde(rename = "PreToolUse")]
PreToolUse,
#[serde(rename = "PostToolUse")]
PostToolUse,
#[serde(rename = "PreAgentTurn")]
PreAgentTurn,
#[serde(rename = "PostAgentTurn")]
PostAgentTurn,
#[serde(rename = "WorkerSpawned")]
WorkerSpawned,
#[serde(rename = "WorkerProgressed")]
WorkerProgressed,
#[serde(rename = "WorkerWaitingForInput")]
WorkerWaitingForInput,
#[serde(rename = "WorkerSuspended")]
WorkerSuspended,
#[serde(rename = "WorkerResumed")]
WorkerResumed,
#[serde(rename = "WorkerCompleted")]
WorkerCompleted,
#[serde(rename = "WorkerFailed")]
WorkerFailed,
#[serde(rename = "WorkerCancelled")]
WorkerCancelled,
#[serde(rename = "PreStep")]
PreStep,
#[serde(rename = "PostStep")]
PostStep,
#[serde(rename = "OnBudgetThreshold")]
OnBudgetThreshold,
#[serde(rename = "OnApprovalRequested")]
OnApprovalRequested,
#[serde(rename = "OnHandoffEmitted")]
OnHandoffEmitted,
#[serde(rename = "OnPersonaPaused")]
OnPersonaPaused,
#[serde(rename = "OnPersonaResumed")]
OnPersonaResumed,
#[serde(rename = "SessionStart")]
SessionStart,
#[serde(rename = "SessionEnd")]
SessionEnd,
#[serde(rename = "UserPromptSubmit")]
UserPromptSubmit,
#[serde(rename = "PreCompact")]
PreCompact,
#[serde(rename = "PostCompact")]
PostCompact,
#[serde(rename = "PostTurn")]
PostTurn,
#[serde(rename = "PermissionAsked")]
PermissionAsked,
#[serde(rename = "PermissionReplied")]
PermissionReplied,
#[serde(rename = "FileEdited")]
FileEdited,
#[serde(rename = "SessionError")]
SessionError,
#[serde(rename = "SessionIdle")]
SessionIdle,
#[serde(rename = "PreFinish")]
PreFinish,
#[serde(rename = "PostFinish")]
PostFinish,
#[serde(rename = "OnUnsettledDetected")]
OnUnsettledDetected,
#[serde(rename = "PreSuspend")]
PreSuspend,
#[serde(rename = "PostSuspend")]
PostSuspend,
#[serde(rename = "PreResume")]
PreResume,
#[serde(rename = "PostResume")]
PostResume,
#[serde(rename = "PreDrain")]
PreDrain,
#[serde(rename = "PostDrain")]
PostDrain,
#[serde(rename = "OnDrainDecision")]
OnDrainDecision,
}
impl HookEvent {
pub fn as_str(self) -> &'static str {
match self {
Self::PreToolUse => "PreToolUse",
Self::PostToolUse => "PostToolUse",
Self::PreAgentTurn => "PreAgentTurn",
Self::PostAgentTurn => "PostAgentTurn",
Self::WorkerSpawned => "WorkerSpawned",
Self::WorkerProgressed => "WorkerProgressed",
Self::WorkerWaitingForInput => "WorkerWaitingForInput",
Self::WorkerSuspended => "WorkerSuspended",
Self::WorkerResumed => "WorkerResumed",
Self::WorkerCompleted => "WorkerCompleted",
Self::WorkerFailed => "WorkerFailed",
Self::WorkerCancelled => "WorkerCancelled",
Self::PreStep => "PreStep",
Self::PostStep => "PostStep",
Self::OnBudgetThreshold => "OnBudgetThreshold",
Self::OnApprovalRequested => "OnApprovalRequested",
Self::OnHandoffEmitted => "OnHandoffEmitted",
Self::OnPersonaPaused => "OnPersonaPaused",
Self::OnPersonaResumed => "OnPersonaResumed",
Self::SessionStart => "SessionStart",
Self::SessionEnd => "SessionEnd",
Self::UserPromptSubmit => "UserPromptSubmit",
Self::PreCompact => "PreCompact",
Self::PostCompact => "PostCompact",
Self::PostTurn => "PostTurn",
Self::PermissionAsked => "PermissionAsked",
Self::PermissionReplied => "PermissionReplied",
Self::FileEdited => "FileEdited",
Self::SessionError => "SessionError",
Self::SessionIdle => "SessionIdle",
Self::PreFinish => "PreFinish",
Self::PostFinish => "PostFinish",
Self::OnUnsettledDetected => "OnUnsettledDetected",
Self::PreSuspend => "PreSuspend",
Self::PostSuspend => "PostSuspend",
Self::PreResume => "PreResume",
Self::PostResume => "PostResume",
Self::PreDrain => "PreDrain",
Self::PostDrain => "PostDrain",
Self::OnDrainDecision => "OnDrainDecision",
}
}
pub fn parse_session_event(name: &str) -> Result<Self, String> {
match name.trim() {
"SessionStart" | "session_start" => Ok(Self::SessionStart),
"SessionEnd" | "session_end" => Ok(Self::SessionEnd),
"UserPromptSubmit" | "user_prompt_submit" => Ok(Self::UserPromptSubmit),
"PreCompact" | "pre_compact" => Ok(Self::PreCompact),
"PostCompact" | "post_compact" => Ok(Self::PostCompact),
"PostTurn" | "post_turn" => Ok(Self::PostTurn),
"PermissionAsked" | "permission_asked" => Ok(Self::PermissionAsked),
"PermissionReplied" | "permission_replied" => Ok(Self::PermissionReplied),
"FileEdited" | "file_edited" => Ok(Self::FileEdited),
"SessionError" | "session_error" | "error" => Ok(Self::SessionError),
"SessionIdle" | "session_idle" => Ok(Self::SessionIdle),
"PreFinish" | "pre_finish" => Ok(Self::PreFinish),
"PostFinish" | "post_finish" => Ok(Self::PostFinish),
"OnUnsettledDetected" | "on_unsettled_detected" => Ok(Self::OnUnsettledDetected),
"PreSuspend" | "pre_suspend" => Ok(Self::PreSuspend),
"PostSuspend" | "post_suspend" => Ok(Self::PostSuspend),
"PreResume" | "pre_resume" => Ok(Self::PreResume),
"PostResume" | "post_resume" => Ok(Self::PostResume),
"PreDrain" | "pre_drain" => Ok(Self::PreDrain),
"PostDrain" | "post_drain" => Ok(Self::PostDrain),
"OnDrainDecision" | "on_drain_decision" => Ok(Self::OnDrainDecision),
other => Err(format!("unknown session hook event `{other}`")),
}
}
pub fn from_worker_event(event: WorkerEvent) -> Self {
match event {
WorkerEvent::WorkerSpawned => Self::WorkerSpawned,
WorkerEvent::WorkerProgressed => Self::WorkerProgressed,
WorkerEvent::WorkerWaitingForInput => Self::WorkerWaitingForInput,
WorkerEvent::WorkerSuspended => Self::WorkerSuspended,
WorkerEvent::WorkerResumed => Self::WorkerResumed,
WorkerEvent::WorkerCompleted => Self::WorkerCompleted,
WorkerEvent::WorkerFailed => Self::WorkerFailed,
WorkerEvent::WorkerCancelled => Self::WorkerCancelled,
}
}
pub fn supports_reminder_effects(self) -> bool {
!matches!(
self,
Self::WorkerSpawned
| Self::WorkerProgressed
| Self::WorkerWaitingForInput
| Self::WorkerSuspended
| Self::WorkerResumed
| Self::WorkerCompleted
| Self::WorkerFailed
| Self::WorkerCancelled
)
}
}
#[derive(Clone, Debug)]
pub enum HookControl {
Allow,
Block {
reason: String,
},
Decision {
kind: String,
reason: Option<String>,
},
Modify {
payload: serde_json::Value,
},
}
impl HookControl {
pub fn as_str(&self) -> &'static str {
match self {
Self::Allow => "allow",
Self::Block { .. } => "block",
Self::Modify { .. } => "modify",
Self::Decision { kind, .. } => match kind.as_str() {
"allow" => "decision_allow",
"deny" => "decision_deny",
"ask" => "decision_ask",
_ => "decision_unknown",
},
}
}
}
pub type ReminderSpec = SystemReminder;
#[derive(Clone, Debug)]
pub enum HookEffect {
Reminder(ReminderSpec),
}
#[derive(Clone, Debug)]
struct HookOutcome {
control: HookControl,
effects: Vec<HookEffect>,
}
#[derive(Clone, Debug)]
pub enum PreToolAction {
Allow,
Deny(String),
Modify(serde_json::Value),
Reminder {
spec: ReminderSpec,
then: Box<PreToolAction>,
},
}
#[derive(Clone, Debug)]
pub enum PostToolAction {
Pass,
Modify(String),
Reminder {
spec: ReminderSpec,
then: Box<PostToolAction>,
},
}
pub type PreToolHookFn = Rc<dyn Fn(&str, &serde_json::Value) -> PreToolAction>;
pub type PostToolHookFn = Rc<dyn Fn(&str, &str) -> PostToolAction>;
#[derive(Clone)]
pub struct ToolHook {
pub pattern: String,
pub pre: Option<PreToolHookFn>,
pub post: Option<PostToolHookFn>,
}
impl std::fmt::Debug for ToolHook {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ToolHook")
.field("pattern", &self.pattern)
.field("has_pre", &self.pre.is_some())
.field("has_post", &self.post.is_some())
.finish()
}
}
#[derive(Clone)]
enum PatternMatcher {
ToolNameGlob(String),
EventExpression {
source: String,
expression: EventPatternExpression,
},
}
#[derive(Clone)]
enum EventPatternExpression {
MatchAll,
NeverMatch,
Regex { path: String, regex: Regex },
Equals { path: String, value: String },
NotEquals { path: String, value: String },
PathTruthy(String),
ToolNameGlob(String),
}
impl std::fmt::Debug for PatternMatcher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ToolNameGlob(pattern) => f.debug_tuple("ToolNameGlob").field(pattern).finish(),
Self::EventExpression { source, expression } => f
.debug_struct("EventExpression")
.field("source", source)
.field("expression", expression)
.finish(),
}
}
}
impl std::fmt::Debug for EventPatternExpression {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MatchAll => f.write_str("MatchAll"),
Self::NeverMatch => f.write_str("NeverMatch"),
Self::Regex { path, regex } => f
.debug_struct("Regex")
.field("path", path)
.field("regex", ®ex.as_str())
.finish(),
Self::Equals { path, value } => f
.debug_struct("Equals")
.field("path", path)
.field("value", value)
.finish(),
Self::NotEquals { path, value } => f
.debug_struct("NotEquals")
.field("path", path)
.field("value", value)
.finish(),
Self::PathTruthy(path) => f.debug_tuple("PathTruthy").field(path).finish(),
Self::ToolNameGlob(pattern) => f.debug_tuple("ToolNameGlob").field(pattern).finish(),
}
}
}
#[derive(Clone)]
enum RuntimeHookHandler {
NativePreTool(PreToolHookFn),
NativePostTool(PostToolHookFn),
Vm {
handler_name: String,
closure: Rc<VmClosure>,
},
}
impl std::fmt::Debug for RuntimeHookHandler {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NativePreTool(_) => f.write_str("NativePreTool(..)"),
Self::NativePostTool(_) => f.write_str("NativePostTool(..)"),
Self::Vm { handler_name, .. } => f
.debug_struct("Vm")
.field("handler_name", handler_name)
.finish(),
}
}
}
#[derive(Clone, Debug)]
struct RuntimeHook {
event: HookEvent,
matcher: PatternMatcher,
handler: RuntimeHookHandler,
}
#[derive(Clone, Debug)]
pub struct VmLifecycleHookInvocation {
pub closure: Rc<VmClosure>,
pub handler_name: String,
}
#[derive(Clone, Debug)]
struct VmLifecycleHookRegistration {
handler_name: String,
closure: Rc<VmClosure>,
}
thread_local! {
static RUNTIME_HOOKS: RefCell<Vec<RuntimeHook>> = const { RefCell::new(Vec::new()) };
static FILE_EDIT_QUEUE: RefCell<Vec<FileEditedNotification>> = const { RefCell::new(Vec::new()) };
}
#[derive(Clone, Debug)]
pub struct FileEditedNotification {
pub path: String,
pub metadata: serde_json::Value,
}
pub fn queue_file_edited(path: &str, metadata: serde_json::Value) {
FILE_EDIT_QUEUE.with(|queue| {
queue.borrow_mut().push(FileEditedNotification {
path: path.to_string(),
metadata,
});
});
}
pub fn drain_file_edits() -> Vec<FileEditedNotification> {
FILE_EDIT_QUEUE.with(|queue| std::mem::take(&mut *queue.borrow_mut()))
}
pub fn clear_file_edit_queue() {
FILE_EDIT_QUEUE.with(|queue| queue.borrow_mut().clear());
}
pub(crate) fn glob_match(pattern: &str, name: &str) -> bool {
if pattern == "*" {
return true;
}
if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
if let Ok(glob) = globset::Glob::new(pattern) {
if glob.compile_matcher().is_match(name) {
return true;
}
}
}
if let Some(prefix) = pattern.strip_suffix('*') {
return name.starts_with(prefix);
}
if let Some(suffix) = pattern.strip_prefix('*') {
return name.ends_with(suffix);
}
pattern == name
}
pub fn register_tool_hook(hook: ToolHook) {
if let Some(pre) = hook.pre {
RUNTIME_HOOKS.with(|hooks| {
hooks.borrow_mut().push(RuntimeHook {
event: HookEvent::PreToolUse,
matcher: PatternMatcher::ToolNameGlob(hook.pattern.clone()),
handler: RuntimeHookHandler::NativePreTool(pre),
});
});
}
if let Some(post) = hook.post {
RUNTIME_HOOKS.with(|hooks| {
hooks.borrow_mut().push(RuntimeHook {
event: HookEvent::PostToolUse,
matcher: PatternMatcher::ToolNameGlob(hook.pattern),
handler: RuntimeHookHandler::NativePostTool(post),
});
});
}
}
pub fn register_vm_hook(
event: HookEvent,
pattern: impl Into<String>,
handler_name: impl Into<String>,
closure: Rc<VmClosure>,
) {
RUNTIME_HOOKS.with(|hooks| {
hooks.borrow_mut().push(RuntimeHook {
event,
matcher: compile_event_pattern(pattern.into()),
handler: RuntimeHookHandler::Vm {
handler_name: handler_name.into(),
closure,
},
});
});
}
pub fn clear_tool_hooks() {
RUNTIME_HOOKS.with(|hooks| {
hooks
.borrow_mut()
.retain(|hook| !matches!(hook.event, HookEvent::PreToolUse | HookEvent::PostToolUse));
});
}
pub fn clear_runtime_hooks() {
RUNTIME_HOOKS.with(|hooks| hooks.borrow_mut().clear());
super::clear_command_policies();
}
pub fn clear_session_hooks() {
RUNTIME_HOOKS.with(|hooks| {
hooks.borrow_mut().retain(|hook| {
!matches!(
hook.event,
HookEvent::SessionStart
| HookEvent::SessionEnd
| HookEvent::UserPromptSubmit
| HookEvent::PreCompact
| HookEvent::PostCompact
| HookEvent::PostTurn
| HookEvent::PermissionAsked
| HookEvent::PermissionReplied
| HookEvent::FileEdited
| HookEvent::SessionError
| HookEvent::SessionIdle
| HookEvent::PreFinish
| HookEvent::PostFinish
| HookEvent::OnUnsettledDetected
| HookEvent::PreSuspend
| HookEvent::PostSuspend
| HookEvent::PreResume
| HookEvent::PostResume
| HookEvent::PreDrain
| HookEvent::PostDrain
| HookEvent::OnDrainDecision
)
});
});
}
fn value_at_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
let mut current = value;
for segment in path.split('.') {
let serde_json::Value::Object(map) = current else {
return None;
};
current = map.get(segment)?;
}
Some(current)
}
fn value_truthy(value: &serde_json::Value) -> bool {
match value {
serde_json::Value::Null => false,
serde_json::Value::Bool(value) => *value,
serde_json::Value::Number(value) => value
.as_i64()
.map(|number| number != 0)
.or_else(|| value.as_u64().map(|number| number != 0))
.or_else(|| value.as_f64().map(|number| number != 0.0))
.unwrap_or(false),
serde_json::Value::String(value) => !value.is_empty(),
serde_json::Value::Array(values) => !values.is_empty(),
serde_json::Value::Object(values) => !values.is_empty(),
}
}
fn value_to_pattern_string(value: Option<&serde_json::Value>) -> String {
match value {
Some(serde_json::Value::String(text)) => text.clone(),
Some(other) => other.to_string(),
None => String::new(),
}
}
fn strip_quoted(value: &str) -> &str {
value
.trim()
.strip_prefix('"')
.and_then(|text| text.strip_suffix('"'))
.or_else(|| {
value
.trim()
.strip_prefix('\'')
.and_then(|text| text.strip_suffix('\''))
})
.unwrap_or(value.trim())
}
fn compile_event_pattern(pattern: String) -> PatternMatcher {
let trimmed = pattern.trim();
let expression = if trimmed.is_empty() || trimmed == "*" {
EventPatternExpression::MatchAll
} else if let Some((lhs, rhs)) = trimmed.split_once("=~") {
match Regex::new(strip_quoted(rhs)) {
Ok(regex) => EventPatternExpression::Regex {
path: lhs.trim().to_string(),
regex,
},
Err(_) => EventPatternExpression::NeverMatch,
}
} else if let Some((lhs, rhs)) = trimmed.split_once("==") {
EventPatternExpression::Equals {
path: lhs.trim().to_string(),
value: strip_quoted(rhs).to_string(),
}
} else if let Some((lhs, rhs)) = trimmed.split_once("!=") {
EventPatternExpression::NotEquals {
path: lhs.trim().to_string(),
value: strip_quoted(rhs).to_string(),
}
} else if trimmed.contains('.') {
EventPatternExpression::PathTruthy(trimmed.to_string())
} else {
EventPatternExpression::ToolNameGlob(trimmed.to_string())
};
PatternMatcher::EventExpression {
source: pattern,
expression,
}
}
fn expression_matches(
source: &str,
expression: &EventPatternExpression,
payload: &serde_json::Value,
) -> bool {
let pattern = source.trim();
if pattern.is_empty() || pattern == "*" {
return true;
}
if let Some(target) = value_at_path(payload, "target").and_then(serde_json::Value::as_str) {
if glob_match(pattern, target) {
return true;
}
}
match expression {
EventPatternExpression::MatchAll => true,
EventPatternExpression::NeverMatch => false,
EventPatternExpression::Regex { path, regex } => {
let value = value_to_pattern_string(value_at_path(payload, path));
regex.is_match(&value)
}
EventPatternExpression::Equals { path, value } => {
value_to_pattern_string(value_at_path(payload, path)) == *value
}
EventPatternExpression::NotEquals { path, value } => {
value_to_pattern_string(value_at_path(payload, path)) != *value
}
EventPatternExpression::PathTruthy(path) => {
value_at_path(payload, path).is_some_and(value_truthy)
}
EventPatternExpression::ToolNameGlob(pattern) => glob_match(
pattern,
&value_to_pattern_string(value_at_path(payload, "tool.name")),
),
}
}
fn hook_matches(hook: &RuntimeHook, tool_name: Option<&str>, payload: &serde_json::Value) -> bool {
match &hook.matcher {
PatternMatcher::ToolNameGlob(pattern) => {
tool_name.is_some_and(|candidate| glob_match(pattern, candidate))
}
PatternMatcher::EventExpression { source, expression } => {
expression_matches(source, expression, payload)
}
}
}
fn runtime_hooks_for_event(event: HookEvent) -> Vec<RuntimeHook> {
RUNTIME_HOOKS.with(|hooks| {
hooks
.borrow()
.iter()
.filter(|hook| hook.event == event)
.cloned()
.collect()
})
}
async fn invoke_vm_hook(
closure: &Rc<VmClosure>,
payload: &serde_json::Value,
) -> Result<VmValue, VmError> {
let Some(mut vm) = crate::vm::clone_async_builtin_child_vm() else {
return Err(VmError::Runtime(
"runtime hook requires an async builtin VM context".to_string(),
));
};
let arg = crate::stdlib::json_to_vm_value(payload);
vm.call_closure_pub(closure, &[arg]).await
}
async fn invoke_vm_lifecycle_hooks(
event: HookEvent,
registrations: Vec<VmLifecycleHookRegistration>,
payload: &serde_json::Value,
) -> Result<(), VmError> {
let Some(mut vm) = crate::vm::clone_async_builtin_child_vm() else {
return Err(VmError::Runtime(
"runtime hook requires an async builtin VM context".to_string(),
));
};
let arg = crate::stdlib::json_to_vm_value(payload);
let session_id = payload
.get("session")
.and_then(|v| v.get("id"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
for registration in registrations {
record_hook_call(&session_id, event, ®istration.handler_name, payload);
let raw = vm
.call_closure_pub(®istration.closure, &[arg.clone()])
.await?;
let effects = parse_hook_effects(event, &raw)?;
record_hook_returned(
&session_id,
event,
®istration.handler_name,
&HookControl::Allow,
&raw,
);
inject_hook_effects(session_id.as_str(), effects, Some(event))?;
}
Ok(())
}
fn reminder_error(context: &str, message: impl Into<String>) -> VmError {
VmError::Runtime(format!("{context}: {}", message.into()))
}
fn reminder_code_error(context: &str, code: Code, message: impl Into<String>) -> VmError {
reminder_error(context, format!("{}: {}", code.as_str(), message.into()))
}
fn unsupported_reminder_event_error(event: HookEvent, context: &str) -> VmError {
reminder_code_error(
context,
Code::ReminderUnsupportedHookEvent,
format!(
"{} does not support reminder effects; use a session, tool, step, or persona hook",
event.as_str()
),
)
}
fn required_reminder_spec_string(
options: &BTreeMap<String, VmValue>,
key: &str,
context: &str,
) -> Result<String, VmError> {
match options.get(key) {
Some(VmValue::String(value)) if !value.trim().is_empty() => Ok(value.to_string()),
Some(VmValue::String(_)) | None | Some(VmValue::Nil) => Err(reminder_error(
context,
format!("`{key}` must be a non-empty string"),
)),
Some(other) => Err(reminder_error(
context,
format!("`{key}` must be a string, got {}", other.type_name()),
)),
}
}
fn optional_reminder_spec_string(
options: &BTreeMap<String, VmValue>,
key: &str,
context: &str,
) -> Result<Option<String>, VmError> {
match options.get(key) {
None | Some(VmValue::Nil) => Ok(None),
Some(VmValue::String(value)) => {
let trimmed = value.trim();
if trimmed.is_empty() {
Ok(None)
} else {
Ok(Some(trimmed.to_string()))
}
}
Some(other) => Err(reminder_error(
context,
format!("`{key}` must be a string or nil, got {}", other.type_name()),
)),
}
}
fn optional_reminder_spec_bool(
options: &BTreeMap<String, VmValue>,
key: &str,
context: &str,
) -> Result<Option<bool>, VmError> {
match options.get(key) {
None | Some(VmValue::Nil) => Ok(None),
Some(VmValue::Bool(value)) => Ok(Some(*value)),
Some(other) => Err(reminder_error(
context,
format!("`{key}` must be a bool or nil, got {}", other.type_name()),
)),
}
}
fn reminder_spec_tags(
options: &BTreeMap<String, VmValue>,
context: &str,
) -> Result<Vec<String>, VmError> {
match options.get("tags") {
None | Some(VmValue::Nil) => Ok(Vec::new()),
Some(VmValue::List(values)) => {
let mut tags = Vec::new();
for value in values.iter() {
let VmValue::String(tag) = value else {
return Err(reminder_error(
context,
format!("`tags` entries must be strings, got {}", value.type_name()),
));
};
let trimmed = tag.trim();
if trimmed.is_empty() {
return Err(reminder_error(
context,
"`tags` entries must be non-empty strings",
));
}
if !tags.iter().any(|existing| existing == trimmed) {
tags.push(trimmed.to_string());
}
}
Ok(tags)
}
Some(other) => Err(reminder_error(
context,
format!("`tags` must be a list or nil, got {}", other.type_name()),
)),
}
}
fn optional_reminder_spec_ttl(
options: &BTreeMap<String, VmValue>,
context: &str,
) -> Result<Option<i64>, VmError> {
match options.get("ttl_turns") {
None | Some(VmValue::Nil) => Ok(None),
Some(VmValue::Int(value)) if *value > 0 => Ok(Some(*value)),
Some(VmValue::Int(_)) => Err(reminder_error(context, "`ttl_turns` must be > 0")),
Some(other) => Err(reminder_error(
context,
format!(
"`ttl_turns` must be an int or nil, got {}",
other.type_name()
),
)),
}
}
fn optional_reminder_spec_propagate(
options: &BTreeMap<String, VmValue>,
context: &str,
) -> Result<Option<ReminderPropagate>, VmError> {
optional_reminder_spec_string(options, "propagate", context)?
.map(|value| match value.as_str() {
"all" => Ok(ReminderPropagate::All),
"session" => Ok(ReminderPropagate::Session),
"none" => Ok(ReminderPropagate::None),
_ => Err(reminder_code_error(
context,
Code::ReminderUnknownPropagate,
"`propagate` must be one of all, session, or none",
)),
})
.transpose()
}
fn optional_reminder_spec_role_hint(
options: &BTreeMap<String, VmValue>,
context: &str,
) -> Result<Option<ReminderRoleHint>, VmError> {
optional_reminder_spec_string(options, "role_hint", context)?
.map(|value| match value.as_str() {
"system" => Ok(ReminderRoleHint::System),
"developer" => Ok(ReminderRoleHint::Developer),
"user_block" => Ok(ReminderRoleHint::UserBlock),
"ephemeral_cache" => Ok(ReminderRoleHint::EphemeralCache),
_ => Err(reminder_error(
context,
"`role_hint` must be one of system, developer, user_block, or ephemeral_cache",
)),
})
.transpose()
}
fn parse_reminder_spec(value: &VmValue, context: &str) -> Result<ReminderSpec, VmError> {
let Some(options) = value.as_dict() else {
return Err(reminder_error(
context,
format!("reminder spec must be a dict, got {}", value.type_name()),
));
};
const ALLOWED: &[&str] = &[
"body",
"tags",
"dedupe_key",
"ttl_turns",
"preserve_on_compact",
"propagate",
"role_hint",
];
let unknown = options
.keys()
.filter(|key| !ALLOWED.contains(&key.as_str()))
.map(String::as_str)
.collect::<Vec<_>>();
if !unknown.is_empty() {
return Err(reminder_code_error(
context,
Code::ReminderUnknownOption,
format!("unknown reminder option(s): {}", unknown.join(", ")),
));
}
Ok(SystemReminder {
id: uuid::Uuid::now_v7().to_string(),
tags: reminder_spec_tags(options, context)?,
dedupe_key: optional_reminder_spec_string(options, "dedupe_key", context)?,
ttl_turns: optional_reminder_spec_ttl(options, context)?,
preserve_on_compact: optional_reminder_spec_bool(options, "preserve_on_compact", context)?
.unwrap_or(false),
propagate: optional_reminder_spec_propagate(options, context)?
.unwrap_or(ReminderPropagate::Session),
role_hint: optional_reminder_spec_role_hint(options, context)?
.unwrap_or(ReminderRoleHint::System),
source: ReminderSource::Hook,
body: required_reminder_spec_string(options, "body", context)?,
fired_at_turn: 0,
originating_agent_id: None,
})
}
fn looks_like_reminder_spec(map: &BTreeMap<String, VmValue>) -> bool {
map.contains_key("body")
&& !map.contains_key("deny")
&& !map.contains_key("args")
&& !map.contains_key("result")
&& !map.contains_key("output")
&& !map.contains_key("modify")
&& !map.contains_key("block")
&& !map.contains_key("decision")
&& !map.contains_key("action")
&& !map.contains_key("control")
}
fn parse_hook_effect_item(event: HookEvent, value: &VmValue) -> Result<HookEffect, VmError> {
let context = format!("{} hook reminder", event.as_str());
if let Some(map) = value.as_dict() {
if let Some(reminder) = map.get("reminder") {
if !event.supports_reminder_effects() {
return Err(unsupported_reminder_event_error(event, &context));
}
return Ok(HookEffect::Reminder(parse_reminder_spec(
reminder, &context,
)?));
}
if matches!(
map.get("type")
.or_else(|| map.get("kind"))
.map(|value| value.display())
.as_deref(),
Some("reminder" | "Reminder")
) {
if !event.supports_reminder_effects() {
return Err(unsupported_reminder_event_error(event, &context));
}
let spec = map
.get("spec")
.or_else(|| map.get("reminder"))
.ok_or_else(|| reminder_error(&context, "reminder effect missing `spec`"))?;
return Ok(HookEffect::Reminder(parse_reminder_spec(spec, &context)?));
}
if looks_like_reminder_spec(map) {
if !event.supports_reminder_effects() {
return Err(unsupported_reminder_event_error(event, &context));
}
return Ok(HookEffect::Reminder(parse_reminder_spec(value, &context)?));
}
}
Err(reminder_error(
&context,
"hook effect must be {reminder: {...}} or a reminder spec",
))
}
pub fn parse_hook_effects(event: HookEvent, value: &VmValue) -> Result<Vec<HookEffect>, VmError> {
let Some(map) = value.as_dict() else {
if let VmValue::List(items) = value {
return items
.iter()
.map(|item| parse_hook_effect_item(event, item))
.collect();
}
return Ok(Vec::new());
};
let mut effects = Vec::new();
if let Some(items) = map.get("effects") {
match items {
VmValue::List(list) => {
for item in list.iter() {
effects.push(parse_hook_effect_item(event, item)?);
}
}
other => effects.push(parse_hook_effect_item(event, other)?),
}
}
if let Some(reminder) = map.get("reminder") {
let context = format!("{} hook reminder", event.as_str());
if !event.supports_reminder_effects() {
return Err(unsupported_reminder_event_error(event, &context));
}
effects.push(HookEffect::Reminder(parse_reminder_spec(
reminder, &context,
)?));
} else if effects.is_empty() && looks_like_reminder_spec(map) {
let context = format!("{} hook reminder", event.as_str());
if !event.supports_reminder_effects() {
return Err(unsupported_reminder_event_error(event, &context));
}
effects.push(HookEffect::Reminder(parse_reminder_spec(value, &context)?));
}
Ok(effects)
}
fn action_value_after_effects(value: VmValue, default_action: VmValue) -> VmValue {
let VmValue::Dict(map) = value else {
return value;
};
if let Some(then) = map.get("then") {
return then.clone();
}
let has_effects = map.contains_key("effects")
|| map.contains_key("reminder")
|| looks_like_reminder_spec(map.as_ref());
if !has_effects {
return VmValue::Dict(map);
}
let mut action = map.as_ref().clone();
action.remove("effects");
action.remove("reminder");
action.remove("then");
if action.keys().any(|key| {
matches!(
key.as_str(),
"deny" | "args" | "result" | "output" | "modify" | "block" | "decision" | "action"
)
}) {
VmValue::Dict(Rc::new(action))
} else {
default_action
}
}
pub fn collect_hook_effects_and_action(
event: HookEvent,
value: VmValue,
default_action: VmValue,
) -> Result<(VmValue, Vec<HookEffect>), VmError> {
let mut current = value;
let mut effects = Vec::new();
for _ in 0..32 {
let current_effects = parse_hook_effects(event, ¤t)?;
if current_effects.is_empty() {
return Ok((current, effects));
}
effects.extend(current_effects);
current = action_value_after_effects(current, default_action.clone());
}
Err(VmError::Runtime(format!(
"{} hook reminder return nested too deeply",
event.as_str()
)))
}
fn inject_hook_effects(
session_id: &str,
effects: Vec<HookEffect>,
event: Option<HookEvent>,
) -> Result<(), VmError> {
if effects.is_empty() {
return Ok(());
}
let target_session = if session_id.is_empty() {
crate::agent_sessions::current_session_id().unwrap_or_default()
} else {
session_id.to_string()
};
if target_session.is_empty() {
return Ok(());
}
for effect in effects {
match effect {
HookEffect::Reminder(spec) => {
let reminder_id = spec.id.clone();
let tags = spec.tags.clone();
let dedupe_key = spec.dedupe_key.clone();
let role_hint = spec.role_hint.as_str();
let source = spec.source.as_str();
let ttl_turns = spec.ttl_turns;
let report = crate::agent_sessions::inject_reminder(&target_session, spec)
.map_err(VmError::Runtime)?;
record_hook_reminder_report(serde_json::json!({
"hook_event": event.map(|event| event.as_str()),
"session_id": &target_session,
"tool_call_id": crate::agent_sessions::current_tool_call_id(),
"reminder_id": reminder_id,
"tags": tags,
"dedupe_key": dedupe_key,
"role_hint": role_hint,
"source": source,
"ttl_turns": ttl_turns,
"deduped_count": report.deduped_count,
}));
}
}
}
Ok(())
}
pub fn inject_hook_effects_into_current_session(effects: Vec<HookEffect>) -> Result<(), VmError> {
inject_hook_effects("", effects, None)
}
fn wrap_pre_tool_effects(effects: Vec<HookEffect>, mut action: PreToolAction) -> PreToolAction {
for effect in effects.into_iter().rev() {
match effect {
HookEffect::Reminder(spec) => {
action = PreToolAction::Reminder {
spec,
then: Box::new(action),
};
}
}
}
action
}
fn wrap_post_tool_effects(effects: Vec<HookEffect>, mut action: PostToolAction) -> PostToolAction {
for effect in effects.into_iter().rev() {
match effect {
HookEffect::Reminder(spec) => {
action = PostToolAction::Reminder {
spec,
then: Box::new(action),
};
}
}
}
action
}
fn parse_pre_tool_result(value: VmValue) -> Result<PreToolAction, VmError> {
let (value, effects) =
collect_hook_effects_and_action(HookEvent::PreToolUse, value, VmValue::Nil)?;
match value {
VmValue::Nil => Ok(wrap_pre_tool_effects(effects, PreToolAction::Allow)),
VmValue::Dict(map) => {
if let Some(reason) = map.get("deny") {
return Ok(wrap_pre_tool_effects(
effects,
PreToolAction::Deny(reason.display()),
));
}
if let Some(args) = map.get("args") {
return Ok(wrap_pre_tool_effects(
effects,
PreToolAction::Modify(crate::llm::vm_value_to_json(args)),
));
}
Ok(wrap_pre_tool_effects(effects, PreToolAction::Allow))
}
other => Err(VmError::Runtime(format!(
"PreToolUse hook must return nil or {{deny, args}}, got {}",
other.type_name()
))),
}
}
fn parse_post_tool_result(value: VmValue) -> Result<PostToolAction, VmError> {
let (value, effects) =
collect_hook_effects_and_action(HookEvent::PostToolUse, value, VmValue::Nil)?;
match value {
VmValue::Nil => Ok(wrap_post_tool_effects(effects, PostToolAction::Pass)),
VmValue::String(text) => Ok(wrap_post_tool_effects(
effects,
PostToolAction::Modify(text.to_string()),
)),
VmValue::Dict(map) => {
if let Some(result) = map.get("result") {
return Ok(wrap_post_tool_effects(
effects,
PostToolAction::Modify(result.display()),
));
}
Ok(wrap_post_tool_effects(effects, PostToolAction::Pass))
}
other => Err(VmError::Runtime(format!(
"PostToolUse hook must return nil, string, or {{result}}, got {}",
other.type_name()
))),
}
}
pub fn apply_pre_tool_action(
action: PreToolAction,
current_args: &mut serde_json::Value,
) -> Result<Option<String>, VmError> {
match action {
PreToolAction::Allow => Ok(None),
PreToolAction::Deny(reason) => Ok(Some(reason)),
PreToolAction::Modify(new_args) => {
*current_args = new_args;
Ok(None)
}
PreToolAction::Reminder { spec, then } => {
inject_hook_effects(
"",
vec![HookEffect::Reminder(spec)],
Some(HookEvent::PreToolUse),
)?;
apply_pre_tool_action(*then, current_args)
}
}
}
fn apply_post_tool_action(action: PostToolAction, current: String) -> Result<String, VmError> {
match action {
PostToolAction::Pass => Ok(current),
PostToolAction::Modify(new_result) => Ok(new_result),
PostToolAction::Reminder { spec, then } => {
inject_hook_effects(
"",
vec![HookEffect::Reminder(spec)],
Some(HookEvent::PostToolUse),
)?;
apply_post_tool_action(*then, current)
}
}
}
pub async fn run_pre_tool_hooks(
tool_name: &str,
args: &serde_json::Value,
) -> Result<PreToolAction, VmError> {
let hooks = runtime_hooks_for_event(HookEvent::PreToolUse);
let mut current_args = args.clone();
for hook in &hooks {
let payload = if matches!(hook.matcher, PatternMatcher::EventExpression { .. }) {
Some(serde_json::json!({
"event": HookEvent::PreToolUse.as_str(),
"tool": {
"name": tool_name,
"args": current_args.clone(),
"tool_call_id": crate::agent_sessions::current_tool_call_id(),
},
"tool_call_id": crate::agent_sessions::current_tool_call_id(),
}))
} else {
None
};
if !hook_matches(
hook,
Some(tool_name),
payload.as_ref().unwrap_or(&serde_json::Value::Null),
) {
continue;
}
let action = match &hook.handler {
RuntimeHookHandler::NativePreTool(pre) => pre(tool_name, ¤t_args),
RuntimeHookHandler::Vm { closure, .. } => {
let payload = payload.as_ref().ok_or_else(|| {
VmError::Runtime("VM PreToolUse hook requires an event payload".to_string())
})?;
parse_pre_tool_result(invoke_vm_hook(closure, payload).await?)?
}
RuntimeHookHandler::NativePostTool(_) => continue,
};
if let Some(reason) = apply_pre_tool_action(action, &mut current_args)? {
return Ok(PreToolAction::Deny(reason));
}
}
if current_args != *args {
Ok(PreToolAction::Modify(current_args))
} else {
Ok(PreToolAction::Allow)
}
}
pub async fn run_post_tool_hooks(
tool_name: &str,
args: &serde_json::Value,
result: &str,
) -> Result<String, VmError> {
let hooks = runtime_hooks_for_event(HookEvent::PostToolUse);
let mut current = result.to_string();
for hook in &hooks {
let payload = if matches!(hook.matcher, PatternMatcher::EventExpression { .. }) {
Some(serde_json::json!({
"event": HookEvent::PostToolUse.as_str(),
"tool": {
"name": tool_name,
"args": args,
"tool_call_id": crate::agent_sessions::current_tool_call_id(),
},
"tool_call_id": crate::agent_sessions::current_tool_call_id(),
"result": {
"text": current.clone(),
},
}))
} else {
None
};
if !hook_matches(
hook,
Some(tool_name),
payload.as_ref().unwrap_or(&serde_json::Value::Null),
) {
continue;
}
let action = match &hook.handler {
RuntimeHookHandler::NativePostTool(post) => post(tool_name, ¤t),
RuntimeHookHandler::Vm { closure, .. } => {
let payload = payload.as_ref().ok_or_else(|| {
VmError::Runtime("VM PostToolUse hook requires an event payload".to_string())
})?;
parse_post_tool_result(invoke_vm_hook(closure, payload).await?)?
}
RuntimeHookHandler::NativePreTool(_) => continue,
};
match action {
PostToolAction::Pass => {}
PostToolAction::Modify(new_result) => {
current = new_result;
}
PostToolAction::Reminder { spec, then } => {
inject_hook_effects(
"",
vec![HookEffect::Reminder(spec)],
Some(HookEvent::PostToolUse),
)?;
current = apply_post_tool_action(*then, current)?;
}
}
}
Ok(current)
}
pub async fn run_lifecycle_hooks(
event: HookEvent,
payload: &serde_json::Value,
) -> Result<(), VmError> {
let registrations = matching_vm_lifecycle_registrations(event, payload);
if registrations.is_empty() {
return Ok(());
}
invoke_vm_lifecycle_hooks(event, registrations, payload).await
}
pub async fn run_lifecycle_hooks_with_control(
event: HookEvent,
payload: &serde_json::Value,
) -> Result<HookControl, VmError> {
let registrations = matching_vm_lifecycle_registrations(event, payload);
if registrations.is_empty() {
return Ok(HookControl::Allow);
}
let Some(mut vm) = crate::vm::clone_async_builtin_child_vm() else {
return Err(VmError::Runtime(
"session lifecycle hook requires an async builtin VM context".to_string(),
));
};
let session_id = payload
.get("session")
.and_then(|v| v.get("id"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let mut current_payload = payload.clone();
let mut accumulated_modify: Option<serde_json::Value> = None;
for registration in registrations {
let arg = crate::stdlib::json_to_vm_value(¤t_payload);
record_hook_call(
&session_id,
event,
®istration.handler_name,
¤t_payload,
);
let raw = vm.call_closure_pub(®istration.closure, &[arg]).await?;
let outcome = parse_hook_outcome(event, &raw)?;
record_hook_returned(
&session_id,
event,
®istration.handler_name,
&outcome.control,
&raw,
);
inject_hook_effects(session_id.as_str(), outcome.effects, Some(event))?;
match outcome.control {
HookControl::Allow => continue,
HookControl::Modify { payload: modified } => {
current_payload = modified.clone();
accumulated_modify = Some(modified);
}
other @ (HookControl::Block { .. } | HookControl::Decision { .. }) => {
record_hook_vetoed(&session_id, event, ®istration.handler_name, &other);
return Ok(other);
}
}
}
if let Some(payload) = accumulated_modify {
Ok(HookControl::Modify { payload })
} else {
Ok(HookControl::Allow)
}
}
fn parse_hook_outcome(event: HookEvent, value: &VmValue) -> Result<HookOutcome, VmError> {
let effects = parse_hook_effects(event, value)?;
let action_value = if matches!(value, VmValue::List(_)) {
VmValue::Nil
} else {
action_value_after_effects(value.clone(), VmValue::Nil)
};
let control = parse_hook_control(event, &action_value)?;
Ok(HookOutcome { control, effects })
}
pub fn parse_hook_control_for_finish(
event: HookEvent,
value: &VmValue,
) -> Result<HookControl, VmError> {
parse_hook_control(event, value)
}
fn parse_hook_control(event: HookEvent, value: &VmValue) -> Result<HookControl, VmError> {
match value {
VmValue::Nil | VmValue::Bool(true) => Ok(HookControl::Allow),
VmValue::Bool(false) => Ok(HookControl::Block {
reason: format!("{} hook returned false", event.as_str()),
}),
VmValue::Dict(map) => {
if let Some(decision) = map.get("decision") {
let kind = decision.display();
let kind_norm = kind.trim().to_ascii_lowercase();
if !matches!(kind_norm.as_str(), "allow" | "deny" | "ask") {
return Err(VmError::Runtime(format!(
"{} hook `decision` must be \"allow\", \"deny\", or \"ask\"; got \"{kind}\"",
event.as_str()
)));
}
let reason = map.get("reason").and_then(|v| match v {
VmValue::Nil => None,
other => Some(other.display()),
});
return Ok(HookControl::Decision {
kind: kind_norm,
reason,
});
}
let block = map.get("block").map(vm_value_truthy).unwrap_or(false);
if block {
let reason = map
.get("reason")
.map(|v| v.display())
.unwrap_or_else(|| format!("{} hook blocked the operation", event.as_str()));
return Ok(HookControl::Block { reason });
}
if let Some(modify) = map.get("modify") {
return Ok(HookControl::Modify {
payload: crate::llm::vm_value_to_json(modify),
});
}
Ok(HookControl::Allow)
}
other => Err(VmError::Runtime(format!(
"{} hook must return nil, bool, or a control dict; got {}",
event.as_str(),
other.type_name()
))),
}
}
fn vm_value_truthy(value: &VmValue) -> bool {
match value {
VmValue::Nil => false,
VmValue::Bool(value) => *value,
VmValue::Int(value) => *value != 0,
VmValue::Float(value) => *value != 0.0,
VmValue::String(value) => !value.is_empty(),
VmValue::List(value) => !value.is_empty(),
VmValue::Dict(value) => !value.is_empty(),
_ => true,
}
}
fn record_hook_call(
session_id: &str,
event: HookEvent,
handler: &str,
payload: &serde_json::Value,
) {
if session_id.is_empty() {
return;
}
let metadata = serde_json::json!({
"event": event.as_str(),
"handler": handler,
"payload": payload,
});
let entry = crate::llm::helpers::transcript_event(
"hook_call",
"system",
"internal",
&format!("hook {} invoked: {}", event.as_str(), handler),
Some(metadata),
);
let _ = crate::agent_sessions::append_event(session_id, entry);
}
fn record_hook_returned(
session_id: &str,
event: HookEvent,
handler: &str,
control: &HookControl,
raw: &VmValue,
) {
if session_id.is_empty() {
return;
}
let metadata = serde_json::json!({
"event": event.as_str(),
"handler": handler,
"result": control.as_str(),
"raw": crate::llm::vm_value_to_json(raw),
});
let entry = crate::llm::helpers::transcript_event(
"hook_returned",
"system",
"internal",
&format!(
"hook {} returned {} from {}",
event.as_str(),
control.as_str(),
handler
),
Some(metadata),
);
let _ = crate::agent_sessions::append_event(session_id, entry);
}
fn record_hook_vetoed(session_id: &str, event: HookEvent, handler: &str, control: &HookControl) {
if session_id.is_empty() {
return;
}
let (reason, decision) = match control {
HookControl::Allow => return,
HookControl::Block { reason } => (reason.clone(), None),
HookControl::Decision { kind, reason } => (
reason.clone().unwrap_or_else(|| format!("decision={kind}")),
Some(kind.clone()),
),
HookControl::Modify { .. } => return,
};
let metadata = serde_json::json!({
"event": event.as_str(),
"handler": handler,
"reason": reason,
"decision": decision,
});
let entry = crate::llm::helpers::transcript_event(
"hook_vetoed",
"system",
"internal",
&format!("hook {} vetoed by {}: {reason}", event.as_str(), handler),
Some(metadata),
);
let _ = crate::agent_sessions::append_event(session_id, entry);
}
pub fn matching_vm_lifecycle_hooks(
event: HookEvent,
payload: &serde_json::Value,
) -> Vec<VmLifecycleHookInvocation> {
matching_vm_lifecycle_registrations(event, payload)
.into_iter()
.map(|registration| VmLifecycleHookInvocation {
closure: registration.closure,
handler_name: registration.handler_name,
})
.collect()
}
fn matching_vm_lifecycle_registrations(
event: HookEvent,
payload: &serde_json::Value,
) -> Vec<VmLifecycleHookRegistration> {
RUNTIME_HOOKS.with(|hooks| {
hooks
.borrow()
.iter()
.filter(|hook| hook.event == event)
.filter(|hook| hook_matches(hook, None, payload))
.filter_map(|hook| match &hook.handler {
RuntimeHookHandler::Vm {
closure,
handler_name,
} => Some(VmLifecycleHookRegistration {
handler_name: handler_name.clone(),
closure: Rc::clone(closure),
}),
RuntimeHookHandler::NativePreTool(_) | RuntimeHookHandler::NativePostTool(_) => {
None
}
})
.collect()
})
}
#[cfg(test)]
mod tests {
use super::*;
fn vm_string(value: &str) -> VmValue {
VmValue::String(Rc::from(value))
}
fn dict(entries: Vec<(&str, VmValue)>) -> VmValue {
VmValue::Dict(Rc::new(
entries
.into_iter()
.map(|(key, value)| (key.to_string(), value))
.collect(),
))
}
fn error_message(result: Result<Vec<HookEffect>, VmError>) -> String {
match result.expect_err("expected hook reminder parse error") {
VmError::Runtime(message) => message,
other => panic!("expected runtime error, got {other:?}"),
}
}
#[test]
fn unknown_reminder_option_reports_code() {
let value = dict(vec![(
"reminder",
dict(vec![
("body", vm_string("remember this")),
("typo_key", VmValue::Bool(true)),
]),
)]);
let message = error_message(parse_hook_effects(HookEvent::PostTurn, &value));
assert!(message.contains(Code::ReminderUnknownOption.as_str()));
assert!(message.contains("typo_key"), "{message}");
}
#[test]
fn unknown_reminder_propagate_reports_specific_code() {
let value = dict(vec![(
"reminder",
dict(vec![
("body", vm_string("remember this")),
("propagate", vm_string("workspace")),
]),
)]);
let message = error_message(parse_hook_effects(HookEvent::PostTurn, &value));
assert!(message.contains(Code::ReminderUnknownPropagate.as_str()));
assert!(message.contains("propagate"), "{message}");
}
#[test]
fn worker_events_reject_reminder_effects_with_specific_code() {
let value = dict(vec![(
"reminder",
dict(vec![("body", vm_string("worker lifecycle"))]),
)]);
let message = error_message(parse_hook_effects(HookEvent::WorkerSpawned, &value));
assert!(message.contains(Code::ReminderUnsupportedHookEvent.as_str()));
assert!(message.contains("WorkerSpawned"), "{message}");
}
}