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
148#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct HookEvent {
168 pub kind: HookKind,
170 pub tool_name: Option<String>,
173 #[serde(default)]
176 pub tool_runtime_name: Option<String>,
177 pub tool_input: Option<Value>,
179 pub tool_output: Option<String>,
181 pub message: Option<String>,
183 pub session_id: Option<String>,
185 #[serde(default)]
189 pub transcript: Option<Vec<crate::SharedMessage>>,
190 pub data: Value,
192}
193
194impl HookEvent {
195 pub fn before_tool_call(tool_name: &str, input: Value) -> Self {
197 Self {
198 kind: HookKind::BeforeToolCall,
199 tool_name: Some(tool_name.to_string()),
200 tool_input: Some(input),
201 tool_output: None,
202 message: None,
203 session_id: None,
204 tool_runtime_name: None,
205 transcript: None,
206 data: Value::Null,
207 }
208 }
209
210 pub fn after_tool_call(tool_name: &str, input: Value, output: String) -> Self {
218 let bounded = agent_core::BoundedText::new(&output, MAX_HOOK_OUTPUT);
219 let truncated_output = if bounded.truncated {
220 format!(
221 "{}…[truncated, {} total bytes]",
222 bounded.text, bounded.original_bytes
223 )
224 } else {
225 output
226 };
227 Self {
228 kind: HookKind::AfterToolCall,
229 tool_name: Some(tool_name.to_string()),
230 tool_input: Some(input),
231 tool_output: Some(truncated_output),
232 message: None,
233 session_id: None,
234 tool_runtime_name: None,
235 transcript: None,
236 data: Value::Null,
237 }
238 }
239
240 pub fn before_message(message: &str) -> Self {
242 Self {
243 kind: HookKind::BeforeMessage,
244 tool_name: None,
245 tool_input: None,
246 tool_output: None,
247 message: Some(message.to_string()),
248 session_id: None,
249 tool_runtime_name: None,
250 transcript: None,
251 data: Value::Null,
252 }
253 }
254
255 pub fn on_message_complete(message: &str, data: Value) -> Self {
257 Self {
258 kind: HookKind::OnMessageComplete,
259 tool_name: None,
260 tool_input: None,
261 tool_output: None,
262 message: Some(message.to_string()),
263 session_id: None,
264 tool_runtime_name: None,
265 transcript: None,
266 data,
267 }
268 }
269
270 pub fn on_compaction(
272 old_session_id: &str,
273 new_session_id: &str,
274 summary: &str,
275 message_count: usize,
276 mut data: Value,
277 ) -> Self {
278 if !data.is_object() {
279 data = Value::Object(Default::default());
280 }
281 if let Some(object) = data.as_object_mut() {
282 object.insert(
283 "old_session_id".to_string(),
284 Value::String(old_session_id.to_string()),
285 );
286 object.insert(
287 "new_session_id".to_string(),
288 Value::String(new_session_id.to_string()),
289 );
290 object.insert(
291 "message_count".to_string(),
292 Value::Number(message_count.into()),
293 );
294 }
295 Self {
296 kind: HookKind::OnCompaction,
297 tool_name: None,
298 tool_input: None,
299 tool_output: None,
300 message: Some(summary.to_string()),
301 session_id: Some(new_session_id.to_string()),
302 tool_runtime_name: None,
303 transcript: None,
304 data,
305 }
306 }
307
308 pub fn on_session_start(session_id: &str) -> Self {
310 Self {
311 kind: HookKind::OnSessionStart,
312 tool_name: None,
313 tool_input: None,
314 tool_output: None,
315 message: None,
316 session_id: Some(session_id.to_string()),
317 tool_runtime_name: None,
318 transcript: None,
319 data: Value::Null,
320 }
321 }
322
323 pub fn on_session_end(session_id: &str, transcript: Option<Vec<crate::SharedMessage>>) -> Self {
325 Self {
326 kind: HookKind::OnSessionEnd,
327 tool_name: None,
328 tool_input: None,
329 tool_output: None,
330 message: None,
331 session_id: Some(session_id.to_string()),
332 tool_runtime_name: None,
333 transcript,
334 data: Value::Null,
335 }
336 }
337}
338
339#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
348#[serde(tag = "action", rename_all = "snake_case")]
349pub enum HookResult {
350 #[default]
352 Continue,
353 Block { reason: String },
355 Inject { content: String },
362 Confirm { message: String },
365 Modify { input: Value },
369 Replace { output: String },
376}
377
378impl HookEvent {
381 pub fn with_runtime_name(mut self, name: &str) -> Self {
383 self.tool_runtime_name = Some(name.to_string());
384 self
385 }
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use serde_json::json;
392
393 #[test]
397 fn replace_action_roundtrips_extension_wire_format() {
398 let wire = json!({"action": "replace", "output": "compressed"});
400 let parsed: HookResult = serde_json::from_value(wire).expect("must deserialize");
401 match parsed {
402 HookResult::Replace { output } => assert_eq!(output, "compressed"),
403 other => panic!("expected Replace, got {other:?}"),
404 }
405
406 let out = serde_json::to_value(HookResult::Replace { output: "x".into() }).unwrap();
408 assert_eq!(out["action"], "replace");
409 assert_eq!(out["output"], "x");
410 }
411
412 #[test]
417 fn after_tool_call_allows_replace_output() {
418 let replace = HookResult::Replace {
419 output: "compressed".into(),
420 };
421
422 assert!(
424 HookKind::AfterToolCall.allows_result(&replace),
425 "after_tool_call must permit Replace to enable output transforms"
426 );
427
428 assert!(
430 !HookKind::BeforeToolCall.allows_result(&replace),
431 "before_tool_call must not permit Replace (input-only)"
432 );
433 assert!(
434 !HookKind::BeforeMessage.allows_result(&replace),
435 "before_message must not permit Replace"
436 );
437
438 assert!(
440 HookKind::AfterToolCall
441 .allowed_action_names()
442 .contains(&"replace"),
443 "after_tool_call must advertise the 'replace' action"
444 );
445 }
446
447 #[test]
449 fn hook_kind_as_str_roundtrip() {
450 let all = [
451 HookKind::BeforeToolCall,
452 HookKind::AfterToolCall,
453 HookKind::BeforeMessage,
454 HookKind::OnMessageComplete,
455 HookKind::OnCompaction,
456 HookKind::OnSessionStart,
457 HookKind::OnSessionEnd,
458 ];
459 for kind in all {
460 let s = kind.as_str();
461 assert_eq!(
462 HookKind::from_str(s),
463 Some(kind),
464 "round-trip failed for {s}"
465 );
466 }
467 }
468
469 #[test]
471 fn hook_kind_from_str_unknown_returns_none() {
472 assert_eq!(HookKind::from_str(""), None);
473 assert_eq!(HookKind::from_str("BeforeToolCall"), None); assert_eq!(HookKind::from_str("on_crash"), None);
475 }
476
477 #[test]
479 fn hook_kind_serde_snake_case() {
480 let serialized = serde_json::to_string(&HookKind::BeforeToolCall).unwrap();
481 assert_eq!(serialized, r#""before_tool_call""#);
482
483 let back: HookKind = serde_json::from_str(r#""on_session_end""#).unwrap();
484 assert_eq!(back, HookKind::OnSessionEnd);
485 }
486
487 #[test]
489 fn hook_kind_required_permission() {
490 assert_eq!(
491 HookKind::BeforeToolCall.required_permission(),
492 Permission::ToolsIntercept
493 );
494 assert_eq!(
495 HookKind::AfterToolCall.required_permission(),
496 Permission::ToolsIntercept
497 );
498 assert_eq!(
499 HookKind::BeforeMessage.required_permission(),
500 Permission::LlmContent
501 );
502 assert_eq!(
503 HookKind::OnMessageComplete.required_permission(),
504 Permission::LlmContent
505 );
506 assert_eq!(
507 HookKind::OnCompaction.required_permission(),
508 Permission::LlmContent
509 );
510 assert_eq!(
511 HookKind::OnSessionStart.required_permission(),
512 Permission::SessionLifecycle
513 );
514 assert_eq!(
515 HookKind::OnSessionEnd.required_permission(),
516 Permission::SessionLifecycle
517 );
518 }
519
520 #[test]
523 fn hook_event_before_tool_call() {
524 let input = json!({"path": "/tmp/foo"});
525 let ev = HookEvent::before_tool_call("read_file", input.clone());
526
527 assert_eq!(ev.kind, HookKind::BeforeToolCall);
528 assert_eq!(ev.tool_name.as_deref(), Some("read_file"));
529 assert_eq!(ev.tool_input.as_ref(), Some(&input));
530 assert!(ev.tool_output.is_none());
531 assert!(ev.message.is_none());
532 assert!(ev.session_id.is_none());
533 assert_eq!(ev.data, Value::Null);
534 }
535
536 #[test]
537 fn hook_event_after_tool_call() {
538 let input = json!({"query": "select 1"});
539 let ev = HookEvent::after_tool_call("sql_query", input.clone(), "1 row".to_string());
540
541 assert_eq!(ev.kind, HookKind::AfterToolCall);
542 assert_eq!(ev.tool_name.as_deref(), Some("sql_query"));
543 assert_eq!(ev.tool_input.as_ref(), Some(&input));
544 assert_eq!(ev.tool_output.as_deref(), Some("1 row"));
545 assert!(ev.message.is_none());
546 assert!(ev.session_id.is_none());
547 }
548
549 #[test]
550 fn hook_event_before_message() {
551 let ev = HookEvent::before_message("Hello, LLM");
552
553 assert_eq!(ev.kind, HookKind::BeforeMessage);
554 assert!(ev.tool_name.is_none());
555 assert!(ev.tool_input.is_none());
556 assert!(ev.tool_output.is_none());
557 assert_eq!(ev.message.as_deref(), Some("Hello, LLM"));
558 assert!(ev.session_id.is_none());
559 }
560
561 #[test]
562 fn hook_event_on_message_complete() {
563 let ev = HookEvent::on_message_complete("Done", json!({"content_block_count": 1}));
564
565 assert_eq!(ev.kind, HookKind::OnMessageComplete);
566 assert!(ev.tool_name.is_none());
567 assert!(ev.tool_input.is_none());
568 assert!(ev.tool_output.is_none());
569 assert_eq!(ev.message.as_deref(), Some("Done"));
570 assert_eq!(ev.data["content_block_count"], 1);
571 assert!(ev.session_id.is_none());
572 }
573
574 #[test]
575 fn hook_event_on_compaction() {
576 let ev = HookEvent::on_compaction(
577 "old-session",
578 "new-session",
579 "Summary",
580 7,
581 json!({"source": "manual"}),
582 );
583
584 assert_eq!(ev.kind, HookKind::OnCompaction);
585 assert_eq!(ev.message.as_deref(), Some("Summary"));
586 assert_eq!(ev.session_id.as_deref(), Some("new-session"));
587 assert_eq!(ev.data["old_session_id"], "old-session");
588 assert_eq!(ev.data["new_session_id"], "new-session");
589 assert_eq!(ev.data["message_count"], 7);
590 assert_eq!(ev.data["source"], "manual");
591 assert!(ev.transcript.is_none());
592 }
593
594 #[test]
595 fn hook_event_on_session_start() {
596 let ev = HookEvent::on_session_start("sess-abc-123");
597
598 assert_eq!(ev.kind, HookKind::OnSessionStart);
599 assert_eq!(ev.session_id.as_deref(), Some("sess-abc-123"));
600 assert!(ev.tool_name.is_none());
601 assert!(ev.message.is_none());
602 }
603
604 #[test]
605 fn hook_event_on_session_end() {
606 let ev = HookEvent::on_session_end("sess-abc-123", None);
607
608 assert_eq!(ev.kind, HookKind::OnSessionEnd);
609 assert_eq!(ev.session_id.as_deref(), Some("sess-abc-123"));
610 assert!(ev.tool_name.is_none());
611 assert!(ev.message.is_none());
612 }
613
614 #[test]
616 fn hook_event_serde_roundtrip() {
617 let ev = HookEvent::before_tool_call("bash", json!({"cmd": "ls"}));
618 let json = serde_json::to_string(&ev).unwrap();
619 let back: HookEvent = serde_json::from_str(&json).unwrap();
620
621 assert_eq!(back.kind, ev.kind);
622 assert_eq!(back.tool_name, ev.tool_name);
623 assert_eq!(back.tool_input, ev.tool_input);
624 }
625
626 #[test]
630 fn hook_result_default_is_continue() {
631 assert!(matches!(HookResult::default(), HookResult::Continue));
632 }
633
634 #[test]
636 fn hook_result_block_serde() {
637 let r = HookResult::Block {
638 reason: "denied by policy".to_string(),
639 };
640 let json = serde_json::to_string(&r).unwrap();
641 assert!(json.contains(r#""action":"block""#));
643 assert!(json.contains("denied by policy"));
644
645 let back: HookResult = serde_json::from_str(&json).unwrap();
646 assert!(matches!(back, HookResult::Block { reason } if reason == "denied by policy"));
647 }
648
649 #[test]
651 fn hook_result_confirm_serde() {
652 let r = HookResult::Confirm {
653 message: "Run this command?".to_string(),
654 };
655 let json = serde_json::to_string(&r).unwrap();
656 assert_eq!(
657 json,
658 r#"{"action":"confirm","message":"Run this command?"}"#
659 );
660
661 let back: HookResult = serde_json::from_str(&json).unwrap();
662 assert!(matches!(back, HookResult::Confirm { message } if message == "Run this command?"));
663 }
664
665 #[test]
667 fn hook_result_modify_serde() {
668 let r = HookResult::Modify {
669 input: json!({"command": "echo safe"}),
670 };
671 let json = serde_json::to_string(&r).unwrap();
672 assert_eq!(
673 json,
674 r#"{"action":"modify","input":{"command":"echo safe"}}"#
675 );
676
677 let back: HookResult = serde_json::from_str(&json).unwrap();
678 assert!(
679 matches!(back, HookResult::Modify { input } if input == json!({"command": "echo safe"}))
680 );
681 }
682
683 #[test]
685 fn hook_result_continue_serde() {
686 let json = serde_json::to_string(&HookResult::Continue).unwrap();
687 assert_eq!(json, r#"{"action":"continue"}"#);
688 }
689
690 #[test]
694 fn all_covers_every_variant() {
695 for kind in HookKind::ALL {
696 match kind {
697 HookKind::BeforeToolCall
698 | HookKind::AfterToolCall
699 | HookKind::BeforeMessage
700 | HookKind::OnMessageComplete
701 | HookKind::OnCompaction
702 | HookKind::OnSessionStart
703 | HookKind::OnSessionEnd => {}
704 }
705 }
706 assert_eq!(
707 HookKind::ALL.len(),
708 7,
709 "a HookKind variant was added or removed -- update HookKind::ALL \
710 and docs/extensions/contract.json to match"
711 );
712 let unique: std::collections::HashSet<HookKind> = HookKind::ALL.iter().copied().collect();
713 assert_eq!(
714 unique.len(),
715 HookKind::ALL.len(),
716 "HookKind::ALL has a duplicate"
717 );
718 }
719
720 #[test]
722 fn all_hook_kinds_round_trip_through_wire_format() {
723 for kind in HookKind::ALL {
724 assert_eq!(
725 HookKind::from_str(kind.as_str()),
726 Some(*kind),
727 "{} does not round-trip",
728 kind.as_str()
729 );
730 }
731 }
732
733 #[test]
739 fn advertised_actions_match_enforced_actions() {
740 let samples: [(&str, HookResult); 6] = [
741 ("continue", HookResult::Continue),
742 ("block", HookResult::Block { reason: "r".into() }),
743 (
744 "confirm",
745 HookResult::Confirm {
746 message: "m".into(),
747 },
748 ),
749 (
750 "modify",
751 HookResult::Modify {
752 input: serde_json::json!({}),
753 },
754 ),
755 ("replace", HookResult::Replace { output: "o".into() }),
756 (
757 "inject",
758 HookResult::Inject {
759 content: "c".into(),
760 },
761 ),
762 ];
763 for kind in HookKind::ALL {
764 for (name, result) in &samples {
765 let advertised = kind.allowed_action_names().contains(name);
766 let enforced = kind.allows_result(result);
767 assert_eq!(
768 advertised,
769 enforced,
770 "hook {} disagrees about action {:?}: advertised={} enforced={}",
771 kind.as_str(),
772 name,
773 advertised,
774 enforced
775 );
776 }
777 }
778 }
779}