use crate::orchestration::*;
use std::sync::Arc;
#[tokio::test(flavor = "current_thread")]
async fn pre_tool_hook_deny_blocks_execution() {
clear_tool_hooks();
register_tool_hook(ToolHook {
pattern: "dangerous_*".to_string(),
pre: Some(Arc::new(|_name, _args| {
PreToolAction::Deny("blocked by policy".to_string())
})),
post: None,
});
let result = run_pre_tool_hooks("dangerous_delete", &serde_json::json!({}))
.await
.expect("hook result");
clear_tool_hooks();
assert!(matches!(result, PreToolAction::Deny(_)));
}
#[tokio::test(flavor = "current_thread")]
async fn pre_tool_hook_allow_passes_through() {
clear_tool_hooks();
register_tool_hook(ToolHook {
pattern: "safe_*".to_string(),
pre: Some(Arc::new(|_name, _args| PreToolAction::Allow)),
post: None,
});
let result = run_pre_tool_hooks("safe_read", &serde_json::json!({}))
.await
.expect("hook result");
clear_tool_hooks();
assert!(matches!(result, PreToolAction::Allow));
}
#[tokio::test(flavor = "current_thread")]
async fn pre_tool_hook_modify_rewrites_args() {
clear_tool_hooks();
register_tool_hook(ToolHook {
pattern: "*".to_string(),
pre: Some(Arc::new(|_name, _args| {
PreToolAction::Modify(serde_json::json!({"path": "/sanitized"}))
})),
post: None,
});
let result = run_pre_tool_hooks("read_file", &serde_json::json!({"path": "/etc/passwd"}))
.await
.expect("hook result");
clear_tool_hooks();
match result {
PreToolAction::Modify(args) => assert_eq!(args["path"], "/sanitized"),
_ => panic!("expected Modify"),
}
}
#[tokio::test(flavor = "current_thread")]
async fn post_tool_hook_modifies_result() {
clear_tool_hooks();
register_tool_hook(ToolHook {
pattern: "exec".to_string(),
pre: None,
post: Some(Arc::new(|_name, result: &str| {
if result.contains("SECRET") {
PostToolAction::Modify("[REDACTED]".to_string())
} else {
PostToolAction::Pass
}
})),
});
let result = run_post_tool_hooks("exec", &serde_json::json!({}), "output with SECRET data")
.await
.expect("hook result");
let clean = run_post_tool_hooks("exec", &serde_json::json!({}), "clean output")
.await
.expect("hook result");
clear_tool_hooks();
assert_eq!(result, "[REDACTED]");
assert_eq!(clean, "clean output");
}
#[tokio::test(flavor = "current_thread")]
async fn unmatched_hook_pattern_does_not_fire() {
clear_tool_hooks();
register_tool_hook(ToolHook {
pattern: "exec".to_string(),
pre: Some(Arc::new(|_name, _args| {
PreToolAction::Deny("should not match".to_string())
})),
post: None,
});
let result = run_pre_tool_hooks("read_file", &serde_json::json!({}))
.await
.expect("hook result");
clear_tool_hooks();
assert!(matches!(result, PreToolAction::Allow));
}
#[tokio::test(flavor = "current_thread")]
async fn lifecycle_hook_patterns_match_payload_shapes() {
clear_runtime_hooks();
let mut vm = crate::Vm::new();
crate::register_vm_stdlib(&mut vm);
let exports = vm
.load_module_exports_from_source(
"orchestration/tests/noop_hook.harn",
"pub fn noop(event) { return nil }\n",
)
.await
.expect("compile noop hook");
let closure = exports.get("noop").expect("noop export").clone();
for pattern in [
"trigger.script_*",
"trigger.provider == 'cron'",
"trigger.kind =~ '^schedule'",
"trigger.provider != 'webhook'",
"script.path",
"trigger.kind =~ '['",
] {
register_vm_hook(
HookEvent::PreAgentTurn,
pattern,
format!("test::{pattern}"),
std::sync::Arc::clone(&closure),
);
}
let payload = serde_json::json!({
"target": "trigger.script_001",
"trigger": {
"provider": "cron",
"kind": "schedule.tick",
},
"script": {
"path": "scripts/bench_001.harn",
},
});
assert_eq!(
matching_vm_lifecycle_hooks(HookEvent::PreAgentTurn, &payload).len(),
5,
"valid glob, equality, regex, inequality, and truthy-path patterns should match"
);
clear_runtime_hooks();
}
#[tokio::test(flavor = "current_thread")]
async fn pipeline_on_finish_callback_replaces_return_value() {
crate::reset_thread_local_state();
let local = tokio::task::LocalSet::new();
let value = local
.run_until(async {
let mut vm = crate::Vm::new();
crate::register_vm_stdlib(&mut vm);
let chunk = crate::compile_source(
"pipeline default() { pipeline_on_finish({ _h, v -> v + 100 }); return 7 }",
)
.expect("compile");
vm.execute(&chunk).await.expect("execute")
})
.await;
match value {
VmValue::Int(n) => assert_eq!(n, 107, "on_finish callback should add 100 to 7"),
other => panic!("expected Int, got {}", other.type_name()),
}
let next = local
.run_until(async {
let mut vm = crate::Vm::new();
crate::register_vm_stdlib(&mut vm);
let chunk = crate::compile_source("pipeline default() { return 7 }").expect("compile");
vm.execute(&chunk).await.expect("execute")
})
.await;
match next {
VmValue::Int(n) => assert_eq!(n, 7, "no callback registered → value passes through"),
other => panic!("expected Int, got {}", other.type_name()),
}
}
#[test]
fn pipeline_finish_events_round_trip_through_session_hook_parser() {
for (input, expected) in [
("pre_finish", HookEvent::PreFinish),
("PreFinish", HookEvent::PreFinish),
("post_finish", HookEvent::PostFinish),
("PostFinish", HookEvent::PostFinish),
("on_unsettled_detected", HookEvent::OnUnsettledDetected),
("OnUnsettledDetected", HookEvent::OnUnsettledDetected),
] {
let parsed = HookEvent::parse_session_event(input)
.unwrap_or_else(|err| panic!("{input} should parse as a session event: {err}"));
assert_eq!(parsed, expected, "{input} should map to {expected:?}");
}
}
#[test]
fn lifecycle_hook_events_round_trip_through_session_hook_parser() {
for (input, expected) in [
("pre_suspend", HookEvent::PreSuspend),
("PreSuspend", HookEvent::PreSuspend),
("post_suspend", HookEvent::PostSuspend),
("PostSuspend", HookEvent::PostSuspend),
("pre_resume", HookEvent::PreResume),
("PreResume", HookEvent::PreResume),
("post_resume", HookEvent::PostResume),
("PostResume", HookEvent::PostResume),
("pre_drain", HookEvent::PreDrain),
("PreDrain", HookEvent::PreDrain),
("post_drain", HookEvent::PostDrain),
("PostDrain", HookEvent::PostDrain),
("on_drain_decision", HookEvent::OnDrainDecision),
("OnDrainDecision", HookEvent::OnDrainDecision),
] {
let parsed = HookEvent::parse_session_event(input)
.unwrap_or_else(|err| panic!("{input} should parse as a session event: {err}"));
assert_eq!(parsed, expected, "{input} should map to {expected:?}");
}
}