use std::path::Path;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::extensions::permissions::Permission;
pub const MAX_HOOK_OUTPUT: usize = 256 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookKind {
BeforeToolCall,
AfterToolCall,
BeforeMessage,
OnMessageComplete,
OnCompaction,
OnSessionStart,
OnSessionEnd,
OnContextArchived,
OnMemoryForgotten,
}
impl HookKind {
pub const ALL: &'static [HookKind] = &[
Self::BeforeToolCall,
Self::AfterToolCall,
Self::BeforeMessage,
Self::OnMessageComplete,
Self::OnCompaction,
Self::OnSessionStart,
Self::OnSessionEnd,
Self::OnContextArchived,
Self::OnMemoryForgotten,
];
pub fn as_str(&self) -> &'static str {
match self {
Self::BeforeToolCall => "before_tool_call",
Self::AfterToolCall => "after_tool_call",
Self::BeforeMessage => "before_message",
Self::OnMessageComplete => "on_message_complete",
Self::OnCompaction => "on_compaction",
Self::OnSessionStart => "on_session_start",
Self::OnSessionEnd => "on_session_end",
Self::OnContextArchived => "on_context_archived",
Self::OnMemoryForgotten => "on_memory_forgotten",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
match s {
"before_tool_call" => Some(Self::BeforeToolCall),
"after_tool_call" => Some(Self::AfterToolCall),
"before_message" => Some(Self::BeforeMessage),
"on_message_complete" => Some(Self::OnMessageComplete),
"on_compaction" => Some(Self::OnCompaction),
"on_session_start" => Some(Self::OnSessionStart),
"on_session_end" => Some(Self::OnSessionEnd),
"on_context_archived" => Some(Self::OnContextArchived),
"on_memory_forgotten" => Some(Self::OnMemoryForgotten),
_ => None,
}
}
pub fn allowed_action_names(&self) -> &'static [&'static str] {
match self {
Self::BeforeToolCall => &["continue", "block", "confirm", "modify"],
Self::AfterToolCall => &["continue", "replace"],
Self::BeforeMessage => &["continue", "inject"],
Self::OnSessionStart => &["continue", "inject"],
Self::OnMessageComplete => &["continue", "context_phase"],
Self::OnCompaction
| Self::OnSessionEnd
| Self::OnContextArchived
| Self::OnMemoryForgotten => &["continue"],
}
}
pub fn is_notification(&self) -> bool {
matches!(self, Self::OnContextArchived | Self::OnMemoryForgotten)
}
pub fn allows_tool_filter(&self) -> bool {
matches!(self, Self::BeforeToolCall | Self::AfterToolCall)
}
pub fn allows_result(&self, result: &HookResult) -> bool {
matches!(
(self, result),
(_, HookResult::Continue)
| (Self::BeforeToolCall, HookResult::Block { .. })
| (Self::BeforeToolCall, HookResult::Confirm { .. })
| (Self::BeforeToolCall, HookResult::Modify { .. })
| (Self::AfterToolCall, HookResult::Replace { .. })
| (Self::BeforeMessage, HookResult::Inject { .. })
| (Self::OnSessionStart, HookResult::Inject { .. })
| (Self::OnMessageComplete, HookResult::ContextPhase { .. })
)
}
pub fn required_permission(&self) -> Permission {
match self {
Self::BeforeToolCall | Self::AfterToolCall => Permission::ToolsIntercept,
Self::BeforeMessage
| Self::OnMessageComplete
| Self::OnCompaction
| Self::OnContextArchived => Permission::LlmContent,
Self::OnSessionStart | Self::OnSessionEnd | Self::OnMemoryForgotten => {
Permission::SessionLifecycle
}
}
}
}
pub fn hook_session_id_enabled() -> bool {
!matches!(
std::env::var("SYNAPS_HOOK_SESSION_ID").as_deref(),
Ok("0") | Ok("false") | Ok("off")
)
}
pub fn hook_cwd(cwd: Option<&Path>) -> Option<String> {
match cwd {
Some(dir) => dir.to_str().map(str::to_string),
None => std::env::current_dir().ok()?.to_str().map(str::to_string),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookEvent {
pub kind: HookKind,
pub tool_name: Option<String>,
#[serde(default)]
pub tool_runtime_name: Option<String>,
pub tool_input: Option<Value>,
pub tool_output: Option<String>,
pub message: Option<String>,
pub session_id: Option<String>,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub transcript: Option<Vec<crate::SharedMessage>>,
pub data: Value,
}
impl HookEvent {
pub fn with_session(mut self, id: Option<&str>) -> Self {
self.session_id = if hook_session_id_enabled() {
id.map(str::to_string)
} else {
None
};
self
}
pub fn with_cwd(mut self, cwd: Option<&Path>) -> Self {
self.cwd = hook_cwd(cwd);
self
}
pub fn before_tool_call(tool_name: &str, input: Value) -> Self {
Self {
kind: HookKind::BeforeToolCall,
tool_name: Some(tool_name.to_string()),
tool_input: Some(input),
tool_output: None,
message: None,
session_id: None,
cwd: None,
tool_runtime_name: None,
transcript: None,
data: Value::Null,
}
}
pub fn after_tool_call(tool_name: &str, input: Value, output: String) -> Self {
let bounded = agent_core::BoundedText::new(&output, MAX_HOOK_OUTPUT);
let truncated_output = if bounded.truncated {
format!(
"{}…[truncated, {} total bytes]",
bounded.text, bounded.original_bytes
)
} else {
output
};
Self {
kind: HookKind::AfterToolCall,
tool_name: Some(tool_name.to_string()),
tool_input: Some(input),
tool_output: Some(truncated_output),
message: None,
session_id: None,
cwd: None,
tool_runtime_name: None,
transcript: None,
data: Value::Null,
}
}
pub fn before_message(message: &str) -> Self {
Self {
kind: HookKind::BeforeMessage,
tool_name: None,
tool_input: None,
tool_output: None,
message: Some(message.to_string()),
session_id: None,
cwd: None,
tool_runtime_name: None,
transcript: None,
data: Value::Null,
}
}
pub fn on_message_complete(message: &str, data: Value) -> Self {
Self {
kind: HookKind::OnMessageComplete,
tool_name: None,
tool_input: None,
tool_output: None,
message: Some(message.to_string()),
session_id: None,
cwd: None,
tool_runtime_name: None,
transcript: None,
data,
}
}
pub fn on_compaction(
old_session_id: &str,
new_session_id: &str,
summary: &str,
message_count: usize,
mut data: Value,
) -> Self {
if !data.is_object() {
data = Value::Object(Default::default());
}
if let Some(object) = data.as_object_mut() {
object.insert(
"old_session_id".to_string(),
Value::String(old_session_id.to_string()),
);
object.insert(
"new_session_id".to_string(),
Value::String(new_session_id.to_string()),
);
object.insert(
"message_count".to_string(),
Value::Number(message_count.into()),
);
}
Self {
kind: HookKind::OnCompaction,
tool_name: None,
tool_input: None,
tool_output: None,
message: Some(summary.to_string()),
session_id: Some(new_session_id.to_string()),
cwd: None,
tool_runtime_name: None,
transcript: None,
data,
}
}
pub fn on_context_archived(
session_id: &str,
archive_id: &str,
path: Option<&Path>,
project_key: &str,
message_count: usize,
source_message_count: usize,
window: u64,
) -> Self {
Self {
kind: HookKind::OnContextArchived,
tool_name: None,
tool_input: None,
tool_output: None,
message: None,
session_id: Some(session_id.to_string()),
cwd: None,
tool_runtime_name: None,
transcript: None,
data: serde_json::json!({
"archive_id": archive_id,
"path": path.and_then(Path::to_str),
"project_key": project_key,
"message_count": message_count,
"source_message_count": source_message_count,
"window": window,
"part": 1,
"parts": 1,
"first_source_index": 0,
"reduced_messages": 0,
}),
}
}
pub fn with_part(
mut self,
part: usize,
parts: usize,
first_source_index: usize,
reduced_messages: usize,
) -> Self {
if let (HookKind::OnContextArchived, Some(data)) = (self.kind, self.data.as_object_mut()) {
for (field, value) in [
("part", part),
("parts", parts),
("first_source_index", first_source_index),
("reduced_messages", reduced_messages),
] {
data.insert(field.to_owned(), value.into());
}
}
self
}
pub fn on_memory_forgotten(
session_id: Option<&str>,
id: &str,
kind: &str,
project_key: &str,
scope: &str,
) -> Self {
Self {
kind: HookKind::OnMemoryForgotten,
tool_name: None,
tool_input: None,
tool_output: None,
message: None,
session_id: session_id.map(str::to_string),
cwd: None,
tool_runtime_name: None,
transcript: None,
data: serde_json::json!({
"id": id,
"kind": kind,
"project_key": project_key,
"scope": scope,
}),
}
}
pub fn on_session_start(session_id: &str) -> Self {
Self {
kind: HookKind::OnSessionStart,
tool_name: None,
tool_input: None,
tool_output: None,
message: None,
session_id: Some(session_id.to_string()),
cwd: None,
tool_runtime_name: None,
transcript: None,
data: Value::Null,
}
}
pub fn on_session_end(session_id: &str, transcript: Option<Vec<crate::SharedMessage>>) -> Self {
Self {
kind: HookKind::OnSessionEnd,
tool_name: None,
tool_input: None,
tool_output: None,
message: None,
session_id: Some(session_id.to_string()),
cwd: None,
tool_runtime_name: None,
transcript,
data: Value::Null,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum HookResult {
#[default]
Continue,
Block { reason: String },
Inject { content: String },
Confirm {
message: String,
#[serde(skip)]
sources: Vec<String>,
#[serde(skip)]
generations: Vec<u64>,
#[serde(skip)]
tool: Option<String>,
#[serde(skip)]
modified_input: Option<Value>,
#[serde(skip)]
modified_by: Vec<String>,
},
Modify { input: Value },
Replace { output: String },
ContextPhase { phase: String },
}
impl HookResult {
pub fn confirm(message: impl Into<String>) -> Self {
Self::Confirm {
message: message.into(),
sources: Vec::new(),
generations: Vec::new(),
tool: None,
modified_input: None,
modified_by: vec![],
}
}
}
impl HookEvent {
pub fn with_runtime_name(mut self, name: &str) -> Self {
self.tool_runtime_name = Some(name.to_string());
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn replace_action_roundtrips_extension_wire_format() {
let wire = json!({"action": "replace", "output": "compressed"});
let parsed: HookResult = serde_json::from_value(wire).expect("must deserialize");
match parsed {
HookResult::Replace { output } => assert_eq!(output, "compressed"),
other => panic!("expected Replace, got {other:?}"),
}
let out = serde_json::to_value(HookResult::Replace { output: "x".into() }).unwrap();
assert_eq!(out["action"], "replace");
assert_eq!(out["output"], "x");
}
#[test]
fn context_phase_action_roundtrips_extension_wire_format() {
let wire = json!({"action": "context_phase", "phase": "new_task"});
let parsed: HookResult = serde_json::from_value(wire).expect("must deserialize");
match &parsed {
HookResult::ContextPhase { phase } => assert_eq!(phase, "new_task"),
other => panic!("expected ContextPhase, got {other:?}"),
}
let out = serde_json::to_value(&parsed).unwrap();
assert_eq!(out, json!({"action": "context_phase", "phase": "new_task"}));
let back: HookResult = serde_json::from_value(out).unwrap();
assert_eq!(back, parsed);
assert!(serde_json::from_value::<HookResult>(json!({"action": "context_phase"})).is_err());
}
#[test]
fn only_on_message_complete_allows_context_phase() {
let report = HookResult::ContextPhase {
phase: "execute".into(),
};
for kind in HookKind::ALL {
assert_eq!(
kind.allows_result(&report),
*kind == HookKind::OnMessageComplete,
"context_phase must be allowed only on on_message_complete (checked {})",
kind.as_str()
);
}
assert!(HookKind::OnMessageComplete
.allowed_action_names()
.contains(&"context_phase"));
assert!(
!HookKind::OnMessageComplete.allows_result(&HookResult::Block { reason: "r".into() })
);
assert!(
!HookKind::OnMessageComplete.allows_result(&HookResult::Inject {
content: "c".into()
})
);
}
#[test]
fn after_tool_call_allows_replace_output() {
let replace = HookResult::Replace {
output: "compressed".into(),
};
assert!(
HookKind::AfterToolCall.allows_result(&replace),
"after_tool_call must permit Replace to enable output transforms"
);
assert!(
!HookKind::BeforeToolCall.allows_result(&replace),
"before_tool_call must not permit Replace (input-only)"
);
assert!(
!HookKind::BeforeMessage.allows_result(&replace),
"before_message must not permit Replace"
);
assert!(
HookKind::AfterToolCall
.allowed_action_names()
.contains(&"replace"),
"after_tool_call must advertise the 'replace' action"
);
}
#[test]
fn hook_kind_as_str_roundtrip() {
let all = [
HookKind::BeforeToolCall,
HookKind::AfterToolCall,
HookKind::BeforeMessage,
HookKind::OnMessageComplete,
HookKind::OnCompaction,
HookKind::OnSessionStart,
HookKind::OnSessionEnd,
HookKind::OnContextArchived,
HookKind::OnMemoryForgotten,
];
for kind in all {
let s = kind.as_str();
assert_eq!(
HookKind::from_str(s),
Some(kind),
"round-trip failed for {s}"
);
}
}
#[test]
fn hook_kind_from_str_unknown_returns_none() {
assert_eq!(HookKind::from_str(""), None);
assert_eq!(HookKind::from_str("BeforeToolCall"), None); assert_eq!(HookKind::from_str("on_crash"), None);
}
#[test]
fn hook_kind_serde_snake_case() {
let serialized = serde_json::to_string(&HookKind::BeforeToolCall).unwrap();
assert_eq!(serialized, r#""before_tool_call""#);
let back: HookKind = serde_json::from_str(r#""on_session_end""#).unwrap();
assert_eq!(back, HookKind::OnSessionEnd);
}
#[test]
fn hook_kind_required_permission() {
assert_eq!(
HookKind::BeforeToolCall.required_permission(),
Permission::ToolsIntercept
);
assert_eq!(
HookKind::AfterToolCall.required_permission(),
Permission::ToolsIntercept
);
assert_eq!(
HookKind::BeforeMessage.required_permission(),
Permission::LlmContent
);
assert_eq!(
HookKind::OnMessageComplete.required_permission(),
Permission::LlmContent
);
assert_eq!(
HookKind::OnCompaction.required_permission(),
Permission::LlmContent
);
assert_eq!(
HookKind::OnSessionStart.required_permission(),
Permission::SessionLifecycle
);
assert_eq!(
HookKind::OnSessionEnd.required_permission(),
Permission::SessionLifecycle
);
assert_eq!(
HookKind::OnContextArchived.required_permission(),
Permission::LlmContent
);
assert_eq!(
HookKind::OnMemoryForgotten.required_permission(),
Permission::SessionLifecycle
);
}
#[test]
fn notification_kinds_are_continue_only() {
let notifications: Vec<HookKind> = HookKind::ALL
.iter()
.copied()
.filter(HookKind::is_notification)
.collect();
assert_eq!(
notifications,
[HookKind::OnContextArchived, HookKind::OnMemoryForgotten]
);
for kind in notifications {
assert_eq!(kind.allowed_action_names(), ["continue"], "{kind:?}");
assert!(!kind.allows_tool_filter(), "{kind:?}");
assert!(kind.allows_result(&HookResult::Continue));
for refused in [
HookResult::Block { reason: "r".into() },
HookResult::Inject { content: "c".into() },
HookResult::confirm("m"),
HookResult::Modify { input: json!({}) },
HookResult::Replace { output: "o".into() },
HookResult::ContextPhase { phase: "new_task".into() },
] {
assert!(!kind.allows_result(&refused), "{kind:?} {refused:?}");
}
}
}
#[test]
fn hook_event_before_tool_call() {
let input = json!({"path": "/tmp/foo"});
let ev = HookEvent::before_tool_call("read_file", input.clone());
assert_eq!(ev.kind, HookKind::BeforeToolCall);
assert_eq!(ev.tool_name.as_deref(), Some("read_file"));
assert_eq!(ev.tool_input.as_ref(), Some(&input));
assert!(ev.tool_output.is_none());
assert!(ev.message.is_none());
assert!(ev.session_id.is_none());
assert_eq!(ev.data, Value::Null);
}
#[test]
fn hook_event_after_tool_call() {
let input = json!({"query": "select 1"});
let ev = HookEvent::after_tool_call("sql_query", input.clone(), "1 row".to_string());
assert_eq!(ev.kind, HookKind::AfterToolCall);
assert_eq!(ev.tool_name.as_deref(), Some("sql_query"));
assert_eq!(ev.tool_input.as_ref(), Some(&input));
assert_eq!(ev.tool_output.as_deref(), Some("1 row"));
assert!(ev.message.is_none());
assert!(ev.session_id.is_none());
}
#[test]
fn hook_event_before_message() {
let ev = HookEvent::before_message("Hello, LLM");
assert_eq!(ev.kind, HookKind::BeforeMessage);
assert!(ev.tool_name.is_none());
assert!(ev.tool_input.is_none());
assert!(ev.tool_output.is_none());
assert_eq!(ev.message.as_deref(), Some("Hello, LLM"));
assert!(ev.session_id.is_none());
}
#[test]
fn hook_event_on_message_complete() {
let ev = HookEvent::on_message_complete("Done", json!({"content_block_count": 1}));
assert_eq!(ev.kind, HookKind::OnMessageComplete);
assert!(ev.tool_name.is_none());
assert!(ev.tool_input.is_none());
assert!(ev.tool_output.is_none());
assert_eq!(ev.message.as_deref(), Some("Done"));
assert_eq!(ev.data["content_block_count"], 1);
assert!(ev.session_id.is_none());
}
#[test]
fn hook_event_on_compaction() {
let ev = HookEvent::on_compaction(
"old-session",
"new-session",
"Summary",
7,
json!({"source": "manual"}),
);
assert_eq!(ev.kind, HookKind::OnCompaction);
assert_eq!(ev.message.as_deref(), Some("Summary"));
assert_eq!(ev.session_id.as_deref(), Some("new-session"));
assert_eq!(ev.data["old_session_id"], "old-session");
assert_eq!(ev.data["new_session_id"], "new-session");
assert_eq!(ev.data["message_count"], 7);
assert_eq!(ev.data["source"], "manual");
assert!(ev.transcript.is_none());
}
#[test]
fn hook_event_on_context_archived() {
let id = "9f2c".repeat(8);
let file = std::env::temp_dir().join("s000001").join(format!("{id}.json"));
let ev = HookEvent::on_context_archived(
"chat-1",
&id,
Some(&file),
"p0123456789abcdef",
212,
240,
4,
);
assert_eq!(
serde_json::to_value(&ev).unwrap(),
json!({
"kind": "on_context_archived",
"tool_name": null,
"tool_runtime_name": null,
"tool_input": null,
"tool_output": null,
"message": null,
"session_id": "chat-1",
"cwd": null,
"transcript": null,
"data": {
"archive_id": id,
"path": file.to_str().unwrap(),
"project_key": "p0123456789abcdef",
"message_count": 212,
"source_message_count": 240,
"window": 4,
"part": 1,
"parts": 1,
"first_source_index": 0,
"reduced_messages": 0,
},
})
);
assert_eq!(ev.session_id.as_deref(), Some("chat-1"));
let lost = HookEvent::on_context_archived("chat-1", &id, None, "p0", 1, 1, 2);
assert_eq!(lost.data["path"], Value::Null);
assert_eq!(lost.data["archive_id"], json!(id));
}
#[test]
fn hook_event_on_context_archived_for_one_part_of_several() {
let id = "9f2c".repeat(8);
let whole = || HookEvent::on_context_archived("chat-1", &id, None, "p0", 212, 240, 4);
let wire = |ev: &HookEvent| serde_json::to_string(ev).unwrap();
assert_eq!(wire(&whole().with_part(1, 1, 0, 0)), wire(&whole()));
let second = whole().with_part(2, 3, 240, 1);
let mut expected = serde_json::to_value(whole()).unwrap();
expected["data"]["part"] = json!(2);
expected["data"]["parts"] = json!(3);
expected["data"]["first_source_index"] = json!(240);
expected["data"]["reduced_messages"] = json!(1);
assert_eq!(serde_json::to_value(&second).unwrap(), expected);
assert_eq!(
second.data.as_object().unwrap().len(),
whole().data.as_object().unwrap().len(),
"no field is added"
);
let other = HookEvent::on_session_start("chat-1");
assert_eq!(wire(&other.clone().with_part(2, 3, 240, 1)), wire(&other));
}
#[test]
fn hook_event_on_memory_forgotten() {
let id = "9f2c".repeat(8);
let ev =
HookEvent::on_memory_forgotten(Some("chat-1"), &id, "archive", "p0123456789abcdef", "repository");
assert_eq!(
serde_json::to_value(&ev).unwrap(),
json!({
"kind": "on_memory_forgotten",
"tool_name": null,
"tool_runtime_name": null,
"tool_input": null,
"tool_output": null,
"message": null,
"session_id": "chat-1",
"cwd": null,
"transcript": null,
"data": {
"id": id,
"kind": "archive",
"project_key": "p0123456789abcdef",
"scope": "repository",
},
})
);
let note =
HookEvent::on_memory_forgotten(None, "mem-1", "note", "p0000000000000000", "user");
assert_eq!(note.session_id, None);
assert_eq!(
note.data,
json!({"id": "mem-1", "kind": "note", "project_key": "p0000000000000000", "scope": "user"})
);
}
#[test]
fn hook_event_on_session_start() {
let ev = HookEvent::on_session_start("sess-abc-123");
assert_eq!(ev.kind, HookKind::OnSessionStart);
assert_eq!(ev.session_id.as_deref(), Some("sess-abc-123"));
assert!(ev.tool_name.is_none());
assert!(ev.message.is_none());
}
#[test]
fn hook_event_on_session_end() {
let ev = HookEvent::on_session_end("sess-abc-123", None);
assert_eq!(ev.kind, HookKind::OnSessionEnd);
assert_eq!(ev.session_id.as_deref(), Some("sess-abc-123"));
assert!(ev.tool_name.is_none());
assert!(ev.message.is_none());
}
#[test]
fn hook_event_serde_roundtrip() {
let ev = HookEvent::before_tool_call("bash", json!({"cmd": "ls"}));
let json = serde_json::to_string(&ev).unwrap();
let back: HookEvent = serde_json::from_str(&json).unwrap();
assert_eq!(back.kind, ev.kind);
assert_eq!(back.tool_name, ev.tool_name);
assert_eq!(back.tool_input, ev.tool_input);
}
#[test]
fn with_cwd_serialises_the_session_folder_as_given() {
let dir = std::env::temp_dir().join("hook-cwd").join("..").join("proj");
let ev = HookEvent::before_message("hi").with_cwd(Some(&dir));
let wire = serde_json::to_value(&ev).unwrap();
assert_eq!(wire["cwd"], json!(dir.to_str().unwrap()));
}
#[test]
fn with_cwd_none_serialises_the_process_folder() {
let process = std::env::current_dir().unwrap();
let ev = HookEvent::on_session_start("s").with_cwd(None);
let wire = serde_json::to_value(&ev).unwrap();
assert_eq!(wire["cwd"], json!(process.to_str().unwrap()));
}
#[test]
fn cwd_key_is_always_present() {
for ev in [
HookEvent::before_tool_call("bash", json!({})),
HookEvent::after_tool_call("bash", json!({}), "o".into()),
HookEvent::before_message("m"),
HookEvent::on_message_complete("m", json!({})),
HookEvent::on_compaction("a", "b", "s", 1, json!({})),
HookEvent::on_session_start("s"),
HookEvent::on_session_end("s", None),
HookEvent::on_context_archived("s", "a", None, "p", 1, 1, 2),
HookEvent::on_memory_forgotten(Some("s"), "a", "archive", "p", "repository"),
] {
let wire = serde_json::to_value(&ev).unwrap();
assert_eq!(wire.get("cwd"), Some(&Value::Null), "{:?}", ev.kind);
}
}
#[cfg(unix)]
#[test]
fn non_utf8_cwd_is_null() {
use std::os::unix::ffi::OsStrExt;
let dir = Path::new(std::ffi::OsStr::from_bytes(b"/tmp/hook-cwd-\xff"));
assert_eq!(hook_cwd(Some(dir)), None);
let ev = HookEvent::before_message("hi").with_cwd(Some(dir));
assert_eq!(serde_json::to_value(&ev).unwrap()["cwd"], Value::Null);
}
#[cfg(unix)]
#[test]
fn non_utf8_archive_path_is_null() {
use std::os::unix::ffi::OsStrExt;
let file = Path::new(std::ffi::OsStr::from_bytes(b"/tmp/archives-\xff/a.json"));
let ev = HookEvent::on_context_archived("s", "a", Some(file), "p", 1, 1, 2);
assert_eq!(ev.data["path"], Value::Null);
}
#[test]
fn hook_result_default_is_continue() {
assert!(matches!(HookResult::default(), HookResult::Continue));
}
#[test]
fn hook_result_block_serde() {
let r = HookResult::Block {
reason: "denied by policy".to_string(),
};
let json = serde_json::to_string(&r).unwrap();
assert!(json.contains(r#""action":"block""#));
assert!(json.contains("denied by policy"));
let back: HookResult = serde_json::from_str(&json).unwrap();
assert!(matches!(back, HookResult::Block { reason } if reason == "denied by policy"));
}
#[test]
fn hook_result_confirm_serde() {
let r = HookResult::Confirm {
message: "Run this command?".to_string(),
sources: vec!["host-stamped".into()],
generations: vec![3],
tool: Some("bash".into()),
modified_input: Some(serde_json::json!({"x": 1})),
modified_by: vec![],
};
let json = serde_json::to_string(&r).unwrap();
assert_eq!(
json,
r#"{"action":"confirm","message":"Run this command?"}"#
);
let back: HookResult = serde_json::from_str(&json).unwrap();
assert!(matches!(back, HookResult::Confirm { message, .. } if message == "Run this command?"));
let spoof: HookResult = serde_json::from_str(
r#"{"action":"confirm","message":"m","sources":["trusted"],"tool":"x"}"#,
)
.unwrap();
assert!(
matches!(&spoof, HookResult::Confirm { sources, tool: None, modified_input: None, modified_by, .. } if sources.is_empty() && modified_by.is_empty()),
"{spoof:?}"
);
}
#[test]
fn hook_result_modify_serde() {
let r = HookResult::Modify {
input: json!({"command": "echo safe"}),
};
let json = serde_json::to_string(&r).unwrap();
assert_eq!(
json,
r#"{"action":"modify","input":{"command":"echo safe"}}"#
);
let back: HookResult = serde_json::from_str(&json).unwrap();
assert!(
matches!(back, HookResult::Modify { input } if input == json!({"command": "echo safe"}))
);
}
#[test]
fn hook_result_continue_serde() {
let json = serde_json::to_string(&HookResult::Continue).unwrap();
assert_eq!(json, r#"{"action":"continue"}"#);
}
#[test]
fn all_covers_every_variant() {
for kind in HookKind::ALL {
match kind {
HookKind::BeforeToolCall
| HookKind::AfterToolCall
| HookKind::BeforeMessage
| HookKind::OnMessageComplete
| HookKind::OnCompaction
| HookKind::OnSessionStart
| HookKind::OnSessionEnd
| HookKind::OnContextArchived
| HookKind::OnMemoryForgotten => {}
}
}
assert_eq!(
HookKind::ALL.len(),
9,
"a HookKind variant was added or removed -- update HookKind::ALL \
and docs/extensions/contract.json to match"
);
let unique: std::collections::HashSet<HookKind> = HookKind::ALL.iter().copied().collect();
assert_eq!(
unique.len(),
HookKind::ALL.len(),
"HookKind::ALL has a duplicate"
);
}
#[test]
fn all_hook_kinds_round_trip_through_wire_format() {
for kind in HookKind::ALL {
assert_eq!(
HookKind::from_str(kind.as_str()),
Some(*kind),
"{} does not round-trip",
kind.as_str()
);
}
}
#[test]
fn advertised_actions_match_enforced_actions() {
let samples: [(&str, HookResult); 7] = [
("continue", HookResult::Continue),
("block", HookResult::Block { reason: "r".into() }),
(
"confirm",
HookResult::Confirm {
message: "m".into(),
sources: vec![],
generations: vec![],
tool: None,
modified_input: None,
modified_by: vec![],
},
),
(
"modify",
HookResult::Modify {
input: serde_json::json!({}),
},
),
("replace", HookResult::Replace { output: "o".into() }),
(
"inject",
HookResult::Inject {
content: "c".into(),
},
),
(
"context_phase",
HookResult::ContextPhase {
phase: "new_task".into(),
},
),
];
for kind in HookKind::ALL {
for (name, result) in &samples {
let advertised = kind.allowed_action_names().contains(name);
let enforced = kind.allows_result(result);
assert_eq!(
advertised,
enforced,
"hook {} disagrees about action {:?}: advertised={} enforced={}",
kind.as_str(),
name,
advertised,
enforced
);
}
}
}
}