1use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use crate::extensions::permissions::Permission;
15
16pub const MAX_HOOK_OUTPUT: usize = 256 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum HookKind {
33 BeforeToolCall,
35 AfterToolCall,
37 BeforeMessage,
39 OnMessageComplete,
41 OnCompaction,
43 OnSessionStart,
45 OnSessionEnd,
47}
48
49impl HookKind {
50 pub const ALL: &'static [HookKind] = &[
57 Self::BeforeToolCall,
58 Self::AfterToolCall,
59 Self::BeforeMessage,
60 Self::OnMessageComplete,
61 Self::OnCompaction,
62 Self::OnSessionStart,
63 Self::OnSessionEnd,
64 ];
65
66 pub fn as_str(&self) -> &'static str {
69 match self {
70 Self::BeforeToolCall => "before_tool_call",
71 Self::AfterToolCall => "after_tool_call",
72 Self::BeforeMessage => "before_message",
73 Self::OnMessageComplete => "on_message_complete",
74 Self::OnCompaction => "on_compaction",
75 Self::OnSessionStart => "on_session_start",
76 Self::OnSessionEnd => "on_session_end",
77 }
78 }
79
80 #[allow(clippy::should_implement_trait)]
85 pub fn from_str(s: &str) -> Option<Self> {
86 match s {
87 "before_tool_call" => Some(Self::BeforeToolCall),
88 "after_tool_call" => Some(Self::AfterToolCall),
89 "before_message" => Some(Self::BeforeMessage),
90 "on_message_complete" => Some(Self::OnMessageComplete),
91 "on_compaction" => Some(Self::OnCompaction),
92 "on_session_start" => Some(Self::OnSessionStart),
93 "on_session_end" => Some(Self::OnSessionEnd),
94 _ => None,
95 }
96 }
97
98 pub fn allowed_action_names(&self) -> &'static [&'static str] {
100 match self {
101 Self::BeforeToolCall => &["continue", "block", "confirm", "modify"],
102 Self::AfterToolCall => &["continue", "replace"],
103 Self::BeforeMessage => &["continue", "inject"],
104 Self::OnSessionStart => &["continue", "inject"],
109 Self::OnMessageComplete => &["continue", "context_phase"],
114 Self::OnCompaction | Self::OnSessionEnd => &["continue"],
115 }
116 }
117
118 pub fn allows_tool_filter(&self) -> bool {
120 matches!(self, Self::BeforeToolCall | Self::AfterToolCall)
121 }
122
123 pub fn allows_result(&self, result: &HookResult) -> bool {
125 matches!(
126 (self, result),
127 (_, HookResult::Continue)
128 | (Self::BeforeToolCall, HookResult::Block { .. })
129 | (Self::BeforeToolCall, HookResult::Confirm { .. })
130 | (Self::BeforeToolCall, HookResult::Modify { .. })
131 | (Self::AfterToolCall, HookResult::Replace { .. })
132 | (Self::BeforeMessage, HookResult::Inject { .. })
133 | (Self::OnSessionStart, HookResult::Inject { .. })
134 | (Self::OnMessageComplete, HookResult::ContextPhase { .. })
135 )
136 }
137
138 pub fn required_permission(&self) -> Permission {
144 match self {
145 Self::BeforeToolCall | Self::AfterToolCall => Permission::ToolsIntercept,
146 Self::BeforeMessage | Self::OnMessageComplete | Self::OnCompaction => {
147 Permission::LlmContent
148 }
149 Self::OnSessionStart | Self::OnSessionEnd => Permission::SessionLifecycle,
150 }
151 }
152}
153
154pub fn hook_session_id_enabled() -> bool {
159 !matches!(
160 std::env::var("SYNAPS_HOOK_SESSION_ID").as_deref(),
161 Ok("0") | Ok("false") | Ok("off")
162 )
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct HookEvent {
186 pub kind: HookKind,
188 pub tool_name: Option<String>,
191 #[serde(default)]
194 pub tool_runtime_name: Option<String>,
195 pub tool_input: Option<Value>,
197 pub tool_output: Option<String>,
199 pub message: Option<String>,
201 pub session_id: Option<String>,
203 #[serde(default)]
207 pub transcript: Option<Vec<crate::SharedMessage>>,
208 pub data: Value,
210}
211
212impl HookEvent {
213 pub fn with_session(mut self, id: Option<&str>) -> Self {
220 self.session_id = if hook_session_id_enabled() {
221 id.map(str::to_string)
222 } else {
223 None
224 };
225 self
226 }
227
228 pub fn before_tool_call(tool_name: &str, input: Value) -> Self {
230 Self {
231 kind: HookKind::BeforeToolCall,
232 tool_name: Some(tool_name.to_string()),
233 tool_input: Some(input),
234 tool_output: None,
235 message: None,
236 session_id: None,
237 tool_runtime_name: None,
238 transcript: None,
239 data: Value::Null,
240 }
241 }
242
243 pub fn after_tool_call(tool_name: &str, input: Value, output: String) -> Self {
251 let bounded = agent_core::BoundedText::new(&output, MAX_HOOK_OUTPUT);
252 let truncated_output = if bounded.truncated {
253 format!(
254 "{}…[truncated, {} total bytes]",
255 bounded.text, bounded.original_bytes
256 )
257 } else {
258 output
259 };
260 Self {
261 kind: HookKind::AfterToolCall,
262 tool_name: Some(tool_name.to_string()),
263 tool_input: Some(input),
264 tool_output: Some(truncated_output),
265 message: None,
266 session_id: None,
267 tool_runtime_name: None,
268 transcript: None,
269 data: Value::Null,
270 }
271 }
272
273 pub fn before_message(message: &str) -> Self {
275 Self {
276 kind: HookKind::BeforeMessage,
277 tool_name: None,
278 tool_input: None,
279 tool_output: None,
280 message: Some(message.to_string()),
281 session_id: None,
282 tool_runtime_name: None,
283 transcript: None,
284 data: Value::Null,
285 }
286 }
287
288 pub fn on_message_complete(message: &str, data: Value) -> Self {
290 Self {
291 kind: HookKind::OnMessageComplete,
292 tool_name: None,
293 tool_input: None,
294 tool_output: None,
295 message: Some(message.to_string()),
296 session_id: None,
297 tool_runtime_name: None,
298 transcript: None,
299 data,
300 }
301 }
302
303 pub fn on_compaction(
305 old_session_id: &str,
306 new_session_id: &str,
307 summary: &str,
308 message_count: usize,
309 mut data: Value,
310 ) -> Self {
311 if !data.is_object() {
312 data = Value::Object(Default::default());
313 }
314 if let Some(object) = data.as_object_mut() {
315 object.insert(
316 "old_session_id".to_string(),
317 Value::String(old_session_id.to_string()),
318 );
319 object.insert(
320 "new_session_id".to_string(),
321 Value::String(new_session_id.to_string()),
322 );
323 object.insert(
324 "message_count".to_string(),
325 Value::Number(message_count.into()),
326 );
327 }
328 Self {
329 kind: HookKind::OnCompaction,
330 tool_name: None,
331 tool_input: None,
332 tool_output: None,
333 message: Some(summary.to_string()),
334 session_id: Some(new_session_id.to_string()),
335 tool_runtime_name: None,
336 transcript: None,
337 data,
338 }
339 }
340
341 pub fn on_session_start(session_id: &str) -> Self {
343 Self {
344 kind: HookKind::OnSessionStart,
345 tool_name: None,
346 tool_input: None,
347 tool_output: None,
348 message: None,
349 session_id: Some(session_id.to_string()),
350 tool_runtime_name: None,
351 transcript: None,
352 data: Value::Null,
353 }
354 }
355
356 pub fn on_session_end(session_id: &str, transcript: Option<Vec<crate::SharedMessage>>) -> Self {
358 Self {
359 kind: HookKind::OnSessionEnd,
360 tool_name: None,
361 tool_input: None,
362 tool_output: None,
363 message: None,
364 session_id: Some(session_id.to_string()),
365 tool_runtime_name: None,
366 transcript,
367 data: Value::Null,
368 }
369 }
370}
371
372#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
383#[serde(tag = "action", rename_all = "snake_case")]
384pub enum HookResult {
385 #[default]
387 Continue,
388 Block { reason: String },
390 Inject { content: String },
397 Confirm {
405 message: String,
406 #[serde(skip)]
407 sources: Vec<String>,
408 #[serde(skip)]
412 generations: Vec<u64>,
413 #[serde(skip)]
414 tool: Option<String>,
415 #[serde(skip)]
418 modified_input: Option<Value>,
419 #[serde(skip)]
423 modified_by: Vec<String>,
424 },
425 Modify { input: Value },
430 Replace { output: String },
437 ContextPhase { phase: String },
448}
449
450impl HookResult {
451 pub fn confirm(message: impl Into<String>) -> Self {
454 Self::Confirm {
455 message: message.into(),
456 sources: Vec::new(),
457 generations: Vec::new(),
458 tool: None,
459 modified_input: None,
460 modified_by: vec![],
461 }
462 }
463}
464
465impl HookEvent {
468 pub fn with_runtime_name(mut self, name: &str) -> Self {
470 self.tool_runtime_name = Some(name.to_string());
471 self
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use serde_json::json;
479
480 #[test]
484 fn replace_action_roundtrips_extension_wire_format() {
485 let wire = json!({"action": "replace", "output": "compressed"});
487 let parsed: HookResult = serde_json::from_value(wire).expect("must deserialize");
488 match parsed {
489 HookResult::Replace { output } => assert_eq!(output, "compressed"),
490 other => panic!("expected Replace, got {other:?}"),
491 }
492
493 let out = serde_json::to_value(HookResult::Replace { output: "x".into() }).unwrap();
495 assert_eq!(out["action"], "replace");
496 assert_eq!(out["output"], "x");
497 }
498
499 #[test]
502 fn context_phase_action_roundtrips_extension_wire_format() {
503 let wire = json!({"action": "context_phase", "phase": "new_task"});
504 let parsed: HookResult = serde_json::from_value(wire).expect("must deserialize");
505 match &parsed {
506 HookResult::ContextPhase { phase } => assert_eq!(phase, "new_task"),
507 other => panic!("expected ContextPhase, got {other:?}"),
508 }
509
510 let out = serde_json::to_value(&parsed).unwrap();
511 assert_eq!(out, json!({"action": "context_phase", "phase": "new_task"}));
512 let back: HookResult = serde_json::from_value(out).unwrap();
513 assert_eq!(back, parsed);
514
515 assert!(serde_json::from_value::<HookResult>(json!({"action": "context_phase"})).is_err());
517 }
518
519 #[test]
522 fn only_on_message_complete_allows_context_phase() {
523 let report = HookResult::ContextPhase {
524 phase: "execute".into(),
525 };
526 for kind in HookKind::ALL {
527 assert_eq!(
528 kind.allows_result(&report),
529 *kind == HookKind::OnMessageComplete,
530 "context_phase must be allowed only on on_message_complete (checked {})",
531 kind.as_str()
532 );
533 }
534 assert!(HookKind::OnMessageComplete
535 .allowed_action_names()
536 .contains(&"context_phase"));
537 assert!(
539 !HookKind::OnMessageComplete.allows_result(&HookResult::Block { reason: "r".into() })
540 );
541 assert!(
542 !HookKind::OnMessageComplete.allows_result(&HookResult::Inject {
543 content: "c".into()
544 })
545 );
546 }
547
548 #[test]
553 fn after_tool_call_allows_replace_output() {
554 let replace = HookResult::Replace {
555 output: "compressed".into(),
556 };
557
558 assert!(
560 HookKind::AfterToolCall.allows_result(&replace),
561 "after_tool_call must permit Replace to enable output transforms"
562 );
563
564 assert!(
566 !HookKind::BeforeToolCall.allows_result(&replace),
567 "before_tool_call must not permit Replace (input-only)"
568 );
569 assert!(
570 !HookKind::BeforeMessage.allows_result(&replace),
571 "before_message must not permit Replace"
572 );
573
574 assert!(
576 HookKind::AfterToolCall
577 .allowed_action_names()
578 .contains(&"replace"),
579 "after_tool_call must advertise the 'replace' action"
580 );
581 }
582
583 #[test]
585 fn hook_kind_as_str_roundtrip() {
586 let all = [
587 HookKind::BeforeToolCall,
588 HookKind::AfterToolCall,
589 HookKind::BeforeMessage,
590 HookKind::OnMessageComplete,
591 HookKind::OnCompaction,
592 HookKind::OnSessionStart,
593 HookKind::OnSessionEnd,
594 ];
595 for kind in all {
596 let s = kind.as_str();
597 assert_eq!(
598 HookKind::from_str(s),
599 Some(kind),
600 "round-trip failed for {s}"
601 );
602 }
603 }
604
605 #[test]
607 fn hook_kind_from_str_unknown_returns_none() {
608 assert_eq!(HookKind::from_str(""), None);
609 assert_eq!(HookKind::from_str("BeforeToolCall"), None); assert_eq!(HookKind::from_str("on_crash"), None);
611 }
612
613 #[test]
615 fn hook_kind_serde_snake_case() {
616 let serialized = serde_json::to_string(&HookKind::BeforeToolCall).unwrap();
617 assert_eq!(serialized, r#""before_tool_call""#);
618
619 let back: HookKind = serde_json::from_str(r#""on_session_end""#).unwrap();
620 assert_eq!(back, HookKind::OnSessionEnd);
621 }
622
623 #[test]
625 fn hook_kind_required_permission() {
626 assert_eq!(
627 HookKind::BeforeToolCall.required_permission(),
628 Permission::ToolsIntercept
629 );
630 assert_eq!(
631 HookKind::AfterToolCall.required_permission(),
632 Permission::ToolsIntercept
633 );
634 assert_eq!(
635 HookKind::BeforeMessage.required_permission(),
636 Permission::LlmContent
637 );
638 assert_eq!(
639 HookKind::OnMessageComplete.required_permission(),
640 Permission::LlmContent
641 );
642 assert_eq!(
643 HookKind::OnCompaction.required_permission(),
644 Permission::LlmContent
645 );
646 assert_eq!(
647 HookKind::OnSessionStart.required_permission(),
648 Permission::SessionLifecycle
649 );
650 assert_eq!(
651 HookKind::OnSessionEnd.required_permission(),
652 Permission::SessionLifecycle
653 );
654 }
655
656 #[test]
659 fn hook_event_before_tool_call() {
660 let input = json!({"path": "/tmp/foo"});
661 let ev = HookEvent::before_tool_call("read_file", input.clone());
662
663 assert_eq!(ev.kind, HookKind::BeforeToolCall);
664 assert_eq!(ev.tool_name.as_deref(), Some("read_file"));
665 assert_eq!(ev.tool_input.as_ref(), Some(&input));
666 assert!(ev.tool_output.is_none());
667 assert!(ev.message.is_none());
668 assert!(ev.session_id.is_none());
669 assert_eq!(ev.data, Value::Null);
670 }
671
672 #[test]
673 fn hook_event_after_tool_call() {
674 let input = json!({"query": "select 1"});
675 let ev = HookEvent::after_tool_call("sql_query", input.clone(), "1 row".to_string());
676
677 assert_eq!(ev.kind, HookKind::AfterToolCall);
678 assert_eq!(ev.tool_name.as_deref(), Some("sql_query"));
679 assert_eq!(ev.tool_input.as_ref(), Some(&input));
680 assert_eq!(ev.tool_output.as_deref(), Some("1 row"));
681 assert!(ev.message.is_none());
682 assert!(ev.session_id.is_none());
683 }
684
685 #[test]
686 fn hook_event_before_message() {
687 let ev = HookEvent::before_message("Hello, LLM");
688
689 assert_eq!(ev.kind, HookKind::BeforeMessage);
690 assert!(ev.tool_name.is_none());
691 assert!(ev.tool_input.is_none());
692 assert!(ev.tool_output.is_none());
693 assert_eq!(ev.message.as_deref(), Some("Hello, LLM"));
694 assert!(ev.session_id.is_none());
695 }
696
697 #[test]
698 fn hook_event_on_message_complete() {
699 let ev = HookEvent::on_message_complete("Done", json!({"content_block_count": 1}));
700
701 assert_eq!(ev.kind, HookKind::OnMessageComplete);
702 assert!(ev.tool_name.is_none());
703 assert!(ev.tool_input.is_none());
704 assert!(ev.tool_output.is_none());
705 assert_eq!(ev.message.as_deref(), Some("Done"));
706 assert_eq!(ev.data["content_block_count"], 1);
707 assert!(ev.session_id.is_none());
708 }
709
710 #[test]
711 fn hook_event_on_compaction() {
712 let ev = HookEvent::on_compaction(
713 "old-session",
714 "new-session",
715 "Summary",
716 7,
717 json!({"source": "manual"}),
718 );
719
720 assert_eq!(ev.kind, HookKind::OnCompaction);
721 assert_eq!(ev.message.as_deref(), Some("Summary"));
722 assert_eq!(ev.session_id.as_deref(), Some("new-session"));
723 assert_eq!(ev.data["old_session_id"], "old-session");
724 assert_eq!(ev.data["new_session_id"], "new-session");
725 assert_eq!(ev.data["message_count"], 7);
726 assert_eq!(ev.data["source"], "manual");
727 assert!(ev.transcript.is_none());
728 }
729
730 #[test]
731 fn hook_event_on_session_start() {
732 let ev = HookEvent::on_session_start("sess-abc-123");
733
734 assert_eq!(ev.kind, HookKind::OnSessionStart);
735 assert_eq!(ev.session_id.as_deref(), Some("sess-abc-123"));
736 assert!(ev.tool_name.is_none());
737 assert!(ev.message.is_none());
738 }
739
740 #[test]
741 fn hook_event_on_session_end() {
742 let ev = HookEvent::on_session_end("sess-abc-123", None);
743
744 assert_eq!(ev.kind, HookKind::OnSessionEnd);
745 assert_eq!(ev.session_id.as_deref(), Some("sess-abc-123"));
746 assert!(ev.tool_name.is_none());
747 assert!(ev.message.is_none());
748 }
749
750 #[test]
752 fn hook_event_serde_roundtrip() {
753 let ev = HookEvent::before_tool_call("bash", json!({"cmd": "ls"}));
754 let json = serde_json::to_string(&ev).unwrap();
755 let back: HookEvent = serde_json::from_str(&json).unwrap();
756
757 assert_eq!(back.kind, ev.kind);
758 assert_eq!(back.tool_name, ev.tool_name);
759 assert_eq!(back.tool_input, ev.tool_input);
760 }
761
762 #[test]
766 fn hook_result_default_is_continue() {
767 assert!(matches!(HookResult::default(), HookResult::Continue));
768 }
769
770 #[test]
772 fn hook_result_block_serde() {
773 let r = HookResult::Block {
774 reason: "denied by policy".to_string(),
775 };
776 let json = serde_json::to_string(&r).unwrap();
777 assert!(json.contains(r#""action":"block""#));
779 assert!(json.contains("denied by policy"));
780
781 let back: HookResult = serde_json::from_str(&json).unwrap();
782 assert!(matches!(back, HookResult::Block { reason } if reason == "denied by policy"));
783 }
784
785 #[test]
787 fn hook_result_confirm_serde() {
788 let r = HookResult::Confirm {
789 message: "Run this command?".to_string(),
790 sources: vec!["host-stamped".into()],
791 generations: vec![3],
792 tool: Some("bash".into()),
793 modified_input: Some(serde_json::json!({"x": 1})),
794 modified_by: vec![],
795 };
796 let json = serde_json::to_string(&r).unwrap();
797 assert_eq!(
798 json,
799 r#"{"action":"confirm","message":"Run this command?"}"#
800 );
801
802 let back: HookResult = serde_json::from_str(&json).unwrap();
803 assert!(matches!(back, HookResult::Confirm { message, .. } if message == "Run this command?"));
804
805 let spoof: HookResult = serde_json::from_str(
807 r#"{"action":"confirm","message":"m","sources":["trusted"],"tool":"x"}"#,
808 )
809 .unwrap();
810 assert!(
811 matches!(&spoof, HookResult::Confirm { sources, tool: None, modified_input: None, modified_by, .. } if sources.is_empty() && modified_by.is_empty()),
812 "{spoof:?}"
813 );
814 }
815
816 #[test]
818 fn hook_result_modify_serde() {
819 let r = HookResult::Modify {
820 input: json!({"command": "echo safe"}),
821 };
822 let json = serde_json::to_string(&r).unwrap();
823 assert_eq!(
824 json,
825 r#"{"action":"modify","input":{"command":"echo safe"}}"#
826 );
827
828 let back: HookResult = serde_json::from_str(&json).unwrap();
829 assert!(
830 matches!(back, HookResult::Modify { input } if input == json!({"command": "echo safe"}))
831 );
832 }
833
834 #[test]
836 fn hook_result_continue_serde() {
837 let json = serde_json::to_string(&HookResult::Continue).unwrap();
838 assert_eq!(json, r#"{"action":"continue"}"#);
839 }
840
841 #[test]
845 fn all_covers_every_variant() {
846 for kind in HookKind::ALL {
847 match kind {
848 HookKind::BeforeToolCall
849 | HookKind::AfterToolCall
850 | HookKind::BeforeMessage
851 | HookKind::OnMessageComplete
852 | HookKind::OnCompaction
853 | HookKind::OnSessionStart
854 | HookKind::OnSessionEnd => {}
855 }
856 }
857 assert_eq!(
858 HookKind::ALL.len(),
859 7,
860 "a HookKind variant was added or removed -- update HookKind::ALL \
861 and docs/extensions/contract.json to match"
862 );
863 let unique: std::collections::HashSet<HookKind> = HookKind::ALL.iter().copied().collect();
864 assert_eq!(
865 unique.len(),
866 HookKind::ALL.len(),
867 "HookKind::ALL has a duplicate"
868 );
869 }
870
871 #[test]
873 fn all_hook_kinds_round_trip_through_wire_format() {
874 for kind in HookKind::ALL {
875 assert_eq!(
876 HookKind::from_str(kind.as_str()),
877 Some(*kind),
878 "{} does not round-trip",
879 kind.as_str()
880 );
881 }
882 }
883
884 #[test]
890 fn advertised_actions_match_enforced_actions() {
891 let samples: [(&str, HookResult); 7] = [
892 ("continue", HookResult::Continue),
893 ("block", HookResult::Block { reason: "r".into() }),
894 (
895 "confirm",
896 HookResult::Confirm {
897 message: "m".into(),
898 sources: vec![],
899 generations: vec![],
900 tool: None,
901 modified_input: None,
902 modified_by: vec![],
903 },
904 ),
905 (
906 "modify",
907 HookResult::Modify {
908 input: serde_json::json!({}),
909 },
910 ),
911 ("replace", HookResult::Replace { output: "o".into() }),
912 (
913 "inject",
914 HookResult::Inject {
915 content: "c".into(),
916 },
917 ),
918 (
919 "context_phase",
920 HookResult::ContextPhase {
921 phase: "new_task".into(),
922 },
923 ),
924 ];
925 for kind in HookKind::ALL {
926 for (name, result) in &samples {
927 let advertised = kind.allowed_action_names().contains(name);
928 let enforced = kind.allows_result(result);
929 assert_eq!(
930 advertised,
931 enforced,
932 "hook {} disagrees about action {:?}: advertised={} enforced={}",
933 kind.as_str(),
934 name,
935 advertised,
936 enforced
937 );
938 }
939 }
940 }
941}