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 | Self::OnCompaction | Self::OnSessionEnd => &["continue"],
110 }
111 }
112
113 pub fn allows_tool_filter(&self) -> bool {
115 matches!(self, Self::BeforeToolCall | Self::AfterToolCall)
116 }
117
118 pub fn allows_result(&self, result: &HookResult) -> bool {
120 matches!(
121 (self, result),
122 (_, HookResult::Continue)
123 | (Self::BeforeToolCall, HookResult::Block { .. })
124 | (Self::BeforeToolCall, HookResult::Confirm { .. })
125 | (Self::BeforeToolCall, HookResult::Modify { .. })
126 | (Self::AfterToolCall, HookResult::Replace { .. })
127 | (Self::BeforeMessage, HookResult::Inject { .. })
128 | (Self::OnSessionStart, HookResult::Inject { .. })
129 )
130 }
131
132 pub fn required_permission(&self) -> Permission {
138 match self {
139 Self::BeforeToolCall | Self::AfterToolCall => Permission::ToolsIntercept,
140 Self::BeforeMessage | Self::OnMessageComplete | Self::OnCompaction => {
141 Permission::LlmContent
142 }
143 Self::OnSessionStart | Self::OnSessionEnd => Permission::SessionLifecycle,
144 }
145 }
146}
147
148pub fn hook_session_id_enabled() -> bool {
153 !matches!(
154 std::env::var("SYNAPS_HOOK_SESSION_ID").as_deref(),
155 Ok("0") | Ok("false") | Ok("off")
156 )
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct HookEvent {
180 pub kind: HookKind,
182 pub tool_name: Option<String>,
185 #[serde(default)]
188 pub tool_runtime_name: Option<String>,
189 pub tool_input: Option<Value>,
191 pub tool_output: Option<String>,
193 pub message: Option<String>,
195 pub session_id: Option<String>,
197 #[serde(default)]
201 pub transcript: Option<Vec<crate::SharedMessage>>,
202 pub data: Value,
204}
205
206impl HookEvent {
207 pub fn with_session(mut self, id: Option<&str>) -> Self {
214 self.session_id = if hook_session_id_enabled() {
215 id.map(str::to_string)
216 } else {
217 None
218 };
219 self
220 }
221
222 pub fn before_tool_call(tool_name: &str, input: Value) -> Self {
224 Self {
225 kind: HookKind::BeforeToolCall,
226 tool_name: Some(tool_name.to_string()),
227 tool_input: Some(input),
228 tool_output: None,
229 message: None,
230 session_id: None,
231 tool_runtime_name: None,
232 transcript: None,
233 data: Value::Null,
234 }
235 }
236
237 pub fn after_tool_call(tool_name: &str, input: Value, output: String) -> Self {
245 let bounded = agent_core::BoundedText::new(&output, MAX_HOOK_OUTPUT);
246 let truncated_output = if bounded.truncated {
247 format!(
248 "{}…[truncated, {} total bytes]",
249 bounded.text, bounded.original_bytes
250 )
251 } else {
252 output
253 };
254 Self {
255 kind: HookKind::AfterToolCall,
256 tool_name: Some(tool_name.to_string()),
257 tool_input: Some(input),
258 tool_output: Some(truncated_output),
259 message: None,
260 session_id: None,
261 tool_runtime_name: None,
262 transcript: None,
263 data: Value::Null,
264 }
265 }
266
267 pub fn before_message(message: &str) -> Self {
269 Self {
270 kind: HookKind::BeforeMessage,
271 tool_name: None,
272 tool_input: None,
273 tool_output: None,
274 message: Some(message.to_string()),
275 session_id: None,
276 tool_runtime_name: None,
277 transcript: None,
278 data: Value::Null,
279 }
280 }
281
282 pub fn on_message_complete(message: &str, data: Value) -> Self {
284 Self {
285 kind: HookKind::OnMessageComplete,
286 tool_name: None,
287 tool_input: None,
288 tool_output: None,
289 message: Some(message.to_string()),
290 session_id: None,
291 tool_runtime_name: None,
292 transcript: None,
293 data,
294 }
295 }
296
297 pub fn on_compaction(
299 old_session_id: &str,
300 new_session_id: &str,
301 summary: &str,
302 message_count: usize,
303 mut data: Value,
304 ) -> Self {
305 if !data.is_object() {
306 data = Value::Object(Default::default());
307 }
308 if let Some(object) = data.as_object_mut() {
309 object.insert(
310 "old_session_id".to_string(),
311 Value::String(old_session_id.to_string()),
312 );
313 object.insert(
314 "new_session_id".to_string(),
315 Value::String(new_session_id.to_string()),
316 );
317 object.insert(
318 "message_count".to_string(),
319 Value::Number(message_count.into()),
320 );
321 }
322 Self {
323 kind: HookKind::OnCompaction,
324 tool_name: None,
325 tool_input: None,
326 tool_output: None,
327 message: Some(summary.to_string()),
328 session_id: Some(new_session_id.to_string()),
329 tool_runtime_name: None,
330 transcript: None,
331 data,
332 }
333 }
334
335 pub fn on_session_start(session_id: &str) -> Self {
337 Self {
338 kind: HookKind::OnSessionStart,
339 tool_name: None,
340 tool_input: None,
341 tool_output: None,
342 message: None,
343 session_id: Some(session_id.to_string()),
344 tool_runtime_name: None,
345 transcript: None,
346 data: Value::Null,
347 }
348 }
349
350 pub fn on_session_end(session_id: &str, transcript: Option<Vec<crate::SharedMessage>>) -> Self {
352 Self {
353 kind: HookKind::OnSessionEnd,
354 tool_name: None,
355 tool_input: None,
356 tool_output: None,
357 message: None,
358 session_id: Some(session_id.to_string()),
359 tool_runtime_name: None,
360 transcript,
361 data: Value::Null,
362 }
363 }
364}
365
366#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
375#[serde(tag = "action", rename_all = "snake_case")]
376pub enum HookResult {
377 #[default]
379 Continue,
380 Block { reason: String },
382 Inject { content: String },
389 Confirm { message: String },
392 Modify { input: Value },
396 Replace { output: String },
403}
404
405impl HookEvent {
408 pub fn with_runtime_name(mut self, name: &str) -> Self {
410 self.tool_runtime_name = Some(name.to_string());
411 self
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418 use serde_json::json;
419
420 #[test]
424 fn replace_action_roundtrips_extension_wire_format() {
425 let wire = json!({"action": "replace", "output": "compressed"});
427 let parsed: HookResult = serde_json::from_value(wire).expect("must deserialize");
428 match parsed {
429 HookResult::Replace { output } => assert_eq!(output, "compressed"),
430 other => panic!("expected Replace, got {other:?}"),
431 }
432
433 let out = serde_json::to_value(HookResult::Replace { output: "x".into() }).unwrap();
435 assert_eq!(out["action"], "replace");
436 assert_eq!(out["output"], "x");
437 }
438
439 #[test]
444 fn after_tool_call_allows_replace_output() {
445 let replace = HookResult::Replace {
446 output: "compressed".into(),
447 };
448
449 assert!(
451 HookKind::AfterToolCall.allows_result(&replace),
452 "after_tool_call must permit Replace to enable output transforms"
453 );
454
455 assert!(
457 !HookKind::BeforeToolCall.allows_result(&replace),
458 "before_tool_call must not permit Replace (input-only)"
459 );
460 assert!(
461 !HookKind::BeforeMessage.allows_result(&replace),
462 "before_message must not permit Replace"
463 );
464
465 assert!(
467 HookKind::AfterToolCall
468 .allowed_action_names()
469 .contains(&"replace"),
470 "after_tool_call must advertise the 'replace' action"
471 );
472 }
473
474 #[test]
476 fn hook_kind_as_str_roundtrip() {
477 let all = [
478 HookKind::BeforeToolCall,
479 HookKind::AfterToolCall,
480 HookKind::BeforeMessage,
481 HookKind::OnMessageComplete,
482 HookKind::OnCompaction,
483 HookKind::OnSessionStart,
484 HookKind::OnSessionEnd,
485 ];
486 for kind in all {
487 let s = kind.as_str();
488 assert_eq!(
489 HookKind::from_str(s),
490 Some(kind),
491 "round-trip failed for {s}"
492 );
493 }
494 }
495
496 #[test]
498 fn hook_kind_from_str_unknown_returns_none() {
499 assert_eq!(HookKind::from_str(""), None);
500 assert_eq!(HookKind::from_str("BeforeToolCall"), None); assert_eq!(HookKind::from_str("on_crash"), None);
502 }
503
504 #[test]
506 fn hook_kind_serde_snake_case() {
507 let serialized = serde_json::to_string(&HookKind::BeforeToolCall).unwrap();
508 assert_eq!(serialized, r#""before_tool_call""#);
509
510 let back: HookKind = serde_json::from_str(r#""on_session_end""#).unwrap();
511 assert_eq!(back, HookKind::OnSessionEnd);
512 }
513
514 #[test]
516 fn hook_kind_required_permission() {
517 assert_eq!(
518 HookKind::BeforeToolCall.required_permission(),
519 Permission::ToolsIntercept
520 );
521 assert_eq!(
522 HookKind::AfterToolCall.required_permission(),
523 Permission::ToolsIntercept
524 );
525 assert_eq!(
526 HookKind::BeforeMessage.required_permission(),
527 Permission::LlmContent
528 );
529 assert_eq!(
530 HookKind::OnMessageComplete.required_permission(),
531 Permission::LlmContent
532 );
533 assert_eq!(
534 HookKind::OnCompaction.required_permission(),
535 Permission::LlmContent
536 );
537 assert_eq!(
538 HookKind::OnSessionStart.required_permission(),
539 Permission::SessionLifecycle
540 );
541 assert_eq!(
542 HookKind::OnSessionEnd.required_permission(),
543 Permission::SessionLifecycle
544 );
545 }
546
547 #[test]
550 fn hook_event_before_tool_call() {
551 let input = json!({"path": "/tmp/foo"});
552 let ev = HookEvent::before_tool_call("read_file", input.clone());
553
554 assert_eq!(ev.kind, HookKind::BeforeToolCall);
555 assert_eq!(ev.tool_name.as_deref(), Some("read_file"));
556 assert_eq!(ev.tool_input.as_ref(), Some(&input));
557 assert!(ev.tool_output.is_none());
558 assert!(ev.message.is_none());
559 assert!(ev.session_id.is_none());
560 assert_eq!(ev.data, Value::Null);
561 }
562
563 #[test]
564 fn hook_event_after_tool_call() {
565 let input = json!({"query": "select 1"});
566 let ev = HookEvent::after_tool_call("sql_query", input.clone(), "1 row".to_string());
567
568 assert_eq!(ev.kind, HookKind::AfterToolCall);
569 assert_eq!(ev.tool_name.as_deref(), Some("sql_query"));
570 assert_eq!(ev.tool_input.as_ref(), Some(&input));
571 assert_eq!(ev.tool_output.as_deref(), Some("1 row"));
572 assert!(ev.message.is_none());
573 assert!(ev.session_id.is_none());
574 }
575
576 #[test]
577 fn hook_event_before_message() {
578 let ev = HookEvent::before_message("Hello, LLM");
579
580 assert_eq!(ev.kind, HookKind::BeforeMessage);
581 assert!(ev.tool_name.is_none());
582 assert!(ev.tool_input.is_none());
583 assert!(ev.tool_output.is_none());
584 assert_eq!(ev.message.as_deref(), Some("Hello, LLM"));
585 assert!(ev.session_id.is_none());
586 }
587
588 #[test]
589 fn hook_event_on_message_complete() {
590 let ev = HookEvent::on_message_complete("Done", json!({"content_block_count": 1}));
591
592 assert_eq!(ev.kind, HookKind::OnMessageComplete);
593 assert!(ev.tool_name.is_none());
594 assert!(ev.tool_input.is_none());
595 assert!(ev.tool_output.is_none());
596 assert_eq!(ev.message.as_deref(), Some("Done"));
597 assert_eq!(ev.data["content_block_count"], 1);
598 assert!(ev.session_id.is_none());
599 }
600
601 #[test]
602 fn hook_event_on_compaction() {
603 let ev = HookEvent::on_compaction(
604 "old-session",
605 "new-session",
606 "Summary",
607 7,
608 json!({"source": "manual"}),
609 );
610
611 assert_eq!(ev.kind, HookKind::OnCompaction);
612 assert_eq!(ev.message.as_deref(), Some("Summary"));
613 assert_eq!(ev.session_id.as_deref(), Some("new-session"));
614 assert_eq!(ev.data["old_session_id"], "old-session");
615 assert_eq!(ev.data["new_session_id"], "new-session");
616 assert_eq!(ev.data["message_count"], 7);
617 assert_eq!(ev.data["source"], "manual");
618 assert!(ev.transcript.is_none());
619 }
620
621 #[test]
622 fn hook_event_on_session_start() {
623 let ev = HookEvent::on_session_start("sess-abc-123");
624
625 assert_eq!(ev.kind, HookKind::OnSessionStart);
626 assert_eq!(ev.session_id.as_deref(), Some("sess-abc-123"));
627 assert!(ev.tool_name.is_none());
628 assert!(ev.message.is_none());
629 }
630
631 #[test]
632 fn hook_event_on_session_end() {
633 let ev = HookEvent::on_session_end("sess-abc-123", None);
634
635 assert_eq!(ev.kind, HookKind::OnSessionEnd);
636 assert_eq!(ev.session_id.as_deref(), Some("sess-abc-123"));
637 assert!(ev.tool_name.is_none());
638 assert!(ev.message.is_none());
639 }
640
641 #[test]
643 fn hook_event_serde_roundtrip() {
644 let ev = HookEvent::before_tool_call("bash", json!({"cmd": "ls"}));
645 let json = serde_json::to_string(&ev).unwrap();
646 let back: HookEvent = serde_json::from_str(&json).unwrap();
647
648 assert_eq!(back.kind, ev.kind);
649 assert_eq!(back.tool_name, ev.tool_name);
650 assert_eq!(back.tool_input, ev.tool_input);
651 }
652
653 #[test]
657 fn hook_result_default_is_continue() {
658 assert!(matches!(HookResult::default(), HookResult::Continue));
659 }
660
661 #[test]
663 fn hook_result_block_serde() {
664 let r = HookResult::Block {
665 reason: "denied by policy".to_string(),
666 };
667 let json = serde_json::to_string(&r).unwrap();
668 assert!(json.contains(r#""action":"block""#));
670 assert!(json.contains("denied by policy"));
671
672 let back: HookResult = serde_json::from_str(&json).unwrap();
673 assert!(matches!(back, HookResult::Block { reason } if reason == "denied by policy"));
674 }
675
676 #[test]
678 fn hook_result_confirm_serde() {
679 let r = HookResult::Confirm {
680 message: "Run this command?".to_string(),
681 };
682 let json = serde_json::to_string(&r).unwrap();
683 assert_eq!(
684 json,
685 r#"{"action":"confirm","message":"Run this command?"}"#
686 );
687
688 let back: HookResult = serde_json::from_str(&json).unwrap();
689 assert!(matches!(back, HookResult::Confirm { message } if message == "Run this command?"));
690 }
691
692 #[test]
694 fn hook_result_modify_serde() {
695 let r = HookResult::Modify {
696 input: json!({"command": "echo safe"}),
697 };
698 let json = serde_json::to_string(&r).unwrap();
699 assert_eq!(
700 json,
701 r#"{"action":"modify","input":{"command":"echo safe"}}"#
702 );
703
704 let back: HookResult = serde_json::from_str(&json).unwrap();
705 assert!(
706 matches!(back, HookResult::Modify { input } if input == json!({"command": "echo safe"}))
707 );
708 }
709
710 #[test]
712 fn hook_result_continue_serde() {
713 let json = serde_json::to_string(&HookResult::Continue).unwrap();
714 assert_eq!(json, r#"{"action":"continue"}"#);
715 }
716
717 #[test]
721 fn all_covers_every_variant() {
722 for kind in HookKind::ALL {
723 match kind {
724 HookKind::BeforeToolCall
725 | HookKind::AfterToolCall
726 | HookKind::BeforeMessage
727 | HookKind::OnMessageComplete
728 | HookKind::OnCompaction
729 | HookKind::OnSessionStart
730 | HookKind::OnSessionEnd => {}
731 }
732 }
733 assert_eq!(
734 HookKind::ALL.len(),
735 7,
736 "a HookKind variant was added or removed -- update HookKind::ALL \
737 and docs/extensions/contract.json to match"
738 );
739 let unique: std::collections::HashSet<HookKind> = HookKind::ALL.iter().copied().collect();
740 assert_eq!(
741 unique.len(),
742 HookKind::ALL.len(),
743 "HookKind::ALL has a duplicate"
744 );
745 }
746
747 #[test]
749 fn all_hook_kinds_round_trip_through_wire_format() {
750 for kind in HookKind::ALL {
751 assert_eq!(
752 HookKind::from_str(kind.as_str()),
753 Some(*kind),
754 "{} does not round-trip",
755 kind.as_str()
756 );
757 }
758 }
759
760 #[test]
766 fn advertised_actions_match_enforced_actions() {
767 let samples: [(&str, HookResult); 6] = [
768 ("continue", HookResult::Continue),
769 ("block", HookResult::Block { reason: "r".into() }),
770 (
771 "confirm",
772 HookResult::Confirm {
773 message: "m".into(),
774 },
775 ),
776 (
777 "modify",
778 HookResult::Modify {
779 input: serde_json::json!({}),
780 },
781 ),
782 ("replace", HookResult::Replace { output: "o".into() }),
783 (
784 "inject",
785 HookResult::Inject {
786 content: "c".into(),
787 },
788 ),
789 ];
790 for kind in HookKind::ALL {
791 for (name, result) in &samples {
792 let advertised = kind.allowed_action_names().contains(name);
793 let enforced = kind.allows_result(result);
794 assert_eq!(
795 advertised,
796 enforced,
797 "hook {} disagrees about action {:?}: advertised={} enforced={}",
798 kind.as_str(),
799 name,
800 advertised,
801 enforced
802 );
803 }
804 }
805 }
806}