1use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use crate::extensions::permissions::Permission;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum HookKind {
26 BeforeToolCall,
28 AfterToolCall,
30 BeforeMessage,
32 OnMessageComplete,
34 OnCompaction,
36 OnSessionStart,
38 OnSessionEnd,
40}
41
42impl HookKind {
43 pub fn as_str(&self) -> &'static str {
46 match self {
47 Self::BeforeToolCall => "before_tool_call",
48 Self::AfterToolCall => "after_tool_call",
49 Self::BeforeMessage => "before_message",
50 Self::OnMessageComplete => "on_message_complete",
51 Self::OnCompaction => "on_compaction",
52 Self::OnSessionStart => "on_session_start",
53 Self::OnSessionEnd => "on_session_end",
54 }
55 }
56
57 #[allow(clippy::should_implement_trait)]
62 pub fn from_str(s: &str) -> Option<Self> {
63 match s {
64 "before_tool_call" => Some(Self::BeforeToolCall),
65 "after_tool_call" => Some(Self::AfterToolCall),
66 "before_message" => Some(Self::BeforeMessage),
67 "on_message_complete" => Some(Self::OnMessageComplete),
68 "on_compaction" => Some(Self::OnCompaction),
69 "on_session_start" => Some(Self::OnSessionStart),
70 "on_session_end" => Some(Self::OnSessionEnd),
71 _ => None,
72 }
73 }
74
75 pub fn allowed_action_names(&self) -> &'static [&'static str] {
77 match self {
78 Self::BeforeToolCall => &["continue", "block", "confirm", "modify"],
79 Self::AfterToolCall => &["continue", "replace"],
80 Self::BeforeMessage => &["continue", "inject"],
81 Self::OnMessageComplete
82 | Self::OnCompaction
83 | Self::OnSessionStart
84 | Self::OnSessionEnd => &["continue"],
85 }
86 }
87
88 pub fn allows_tool_filter(&self) -> bool {
90 matches!(self, Self::BeforeToolCall | Self::AfterToolCall)
91 }
92
93 pub fn allows_result(&self, result: &HookResult) -> bool {
95 matches!(
96 (self, result),
97 (_, HookResult::Continue)
98 | (Self::BeforeToolCall, HookResult::Block { .. })
99 | (Self::BeforeToolCall, HookResult::Confirm { .. })
100 | (Self::BeforeToolCall, HookResult::Modify { .. })
101 | (Self::AfterToolCall, HookResult::Replace { .. })
102 | (Self::BeforeMessage, HookResult::Inject { .. })
103 )
104 }
105
106 pub fn required_permission(&self) -> Permission {
112 match self {
113 Self::BeforeToolCall | Self::AfterToolCall => Permission::ToolsIntercept,
114 Self::BeforeMessage | Self::OnMessageComplete | Self::OnCompaction => {
115 Permission::LlmContent
116 }
117 Self::OnSessionStart | Self::OnSessionEnd => Permission::SessionLifecycle,
118 }
119 }
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct HookEvent {
142 pub kind: HookKind,
144 pub tool_name: Option<String>,
147 #[serde(default)]
150 pub tool_runtime_name: Option<String>,
151 pub tool_input: Option<Value>,
153 pub tool_output: Option<String>,
155 pub message: Option<String>,
157 pub session_id: Option<String>,
159 #[serde(default)]
163 pub transcript: Option<Vec<Value>>,
164 pub data: Value,
166}
167
168impl HookEvent {
169 pub fn before_tool_call(tool_name: &str, input: Value) -> Self {
171 Self {
172 kind: HookKind::BeforeToolCall,
173 tool_name: Some(tool_name.to_string()),
174 tool_input: Some(input),
175 tool_output: None,
176 message: None,
177 session_id: None,
178 tool_runtime_name: None,
179 transcript: None,
180 data: Value::Null,
181 }
182 }
183
184 pub fn after_tool_call(tool_name: &str, input: Value, output: String) -> Self {
192 const MAX_HOOK_OUTPUT: usize = 256 * 1024; let truncated_output = if output.len() > MAX_HOOK_OUTPUT {
194 let boundary = output
195 .char_indices()
196 .map(|(idx, _)| idx)
197 .take_while(|idx| *idx <= MAX_HOOK_OUTPUT)
198 .last()
199 .unwrap_or(0);
200 format!(
201 "{}…[truncated, {} total bytes]",
202 &output[..boundary],
203 output.len()
204 )
205 } else {
206 output
207 };
208 Self {
209 kind: HookKind::AfterToolCall,
210 tool_name: Some(tool_name.to_string()),
211 tool_input: Some(input),
212 tool_output: Some(truncated_output),
213 message: None,
214 session_id: None,
215 tool_runtime_name: None,
216 transcript: None,
217 data: Value::Null,
218 }
219 }
220
221 pub fn before_message(message: &str) -> Self {
223 Self {
224 kind: HookKind::BeforeMessage,
225 tool_name: None,
226 tool_input: None,
227 tool_output: None,
228 message: Some(message.to_string()),
229 session_id: None,
230 tool_runtime_name: None,
231 transcript: None,
232 data: Value::Null,
233 }
234 }
235
236 pub fn on_message_complete(message: &str, data: Value) -> Self {
238 Self {
239 kind: HookKind::OnMessageComplete,
240 tool_name: None,
241 tool_input: None,
242 tool_output: None,
243 message: Some(message.to_string()),
244 session_id: None,
245 tool_runtime_name: None,
246 transcript: None,
247 data,
248 }
249 }
250
251 pub fn on_compaction(
253 old_session_id: &str,
254 new_session_id: &str,
255 summary: &str,
256 message_count: usize,
257 mut data: Value,
258 ) -> Self {
259 if !data.is_object() {
260 data = Value::Object(Default::default());
261 }
262 if let Some(object) = data.as_object_mut() {
263 object.insert(
264 "old_session_id".to_string(),
265 Value::String(old_session_id.to_string()),
266 );
267 object.insert(
268 "new_session_id".to_string(),
269 Value::String(new_session_id.to_string()),
270 );
271 object.insert(
272 "message_count".to_string(),
273 Value::Number(message_count.into()),
274 );
275 }
276 Self {
277 kind: HookKind::OnCompaction,
278 tool_name: None,
279 tool_input: None,
280 tool_output: None,
281 message: Some(summary.to_string()),
282 session_id: Some(new_session_id.to_string()),
283 tool_runtime_name: None,
284 transcript: None,
285 data,
286 }
287 }
288
289 pub fn on_session_start(session_id: &str) -> Self {
291 Self {
292 kind: HookKind::OnSessionStart,
293 tool_name: None,
294 tool_input: None,
295 tool_output: None,
296 message: None,
297 session_id: Some(session_id.to_string()),
298 tool_runtime_name: None,
299 transcript: None,
300 data: Value::Null,
301 }
302 }
303
304 pub fn on_session_end(session_id: &str, transcript: Option<Vec<Value>>) -> Self {
306 Self {
307 kind: HookKind::OnSessionEnd,
308 tool_name: None,
309 tool_input: None,
310 tool_output: None,
311 message: None,
312 session_id: Some(session_id.to_string()),
313 tool_runtime_name: None,
314 transcript,
315 data: Value::Null,
316 }
317 }
318}
319
320#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
329#[serde(tag = "action", rename_all = "snake_case")]
330pub enum HookResult {
331 #[default]
333 Continue,
334 Block { reason: String },
336 Inject { content: String },
339 Confirm { message: String },
342 Modify { input: Value },
346 Replace { output: String },
353}
354
355impl HookEvent {
358 pub fn with_runtime_name(mut self, name: &str) -> Self {
360 self.tool_runtime_name = Some(name.to_string());
361 self
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use serde_json::json;
369
370 #[test]
374 fn replace_action_roundtrips_extension_wire_format() {
375 let wire = json!({"action": "replace", "output": "compressed"});
377 let parsed: HookResult = serde_json::from_value(wire).expect("must deserialize");
378 match parsed {
379 HookResult::Replace { output } => assert_eq!(output, "compressed"),
380 other => panic!("expected Replace, got {other:?}"),
381 }
382
383 let out = serde_json::to_value(HookResult::Replace { output: "x".into() }).unwrap();
385 assert_eq!(out["action"], "replace");
386 assert_eq!(out["output"], "x");
387 }
388
389 #[test]
394 fn after_tool_call_allows_replace_output() {
395 let replace = HookResult::Replace {
396 output: "compressed".into(),
397 };
398
399 assert!(
401 HookKind::AfterToolCall.allows_result(&replace),
402 "after_tool_call must permit Replace to enable output transforms"
403 );
404
405 assert!(
407 !HookKind::BeforeToolCall.allows_result(&replace),
408 "before_tool_call must not permit Replace (input-only)"
409 );
410 assert!(
411 !HookKind::BeforeMessage.allows_result(&replace),
412 "before_message must not permit Replace"
413 );
414
415 assert!(
417 HookKind::AfterToolCall
418 .allowed_action_names()
419 .contains(&"replace"),
420 "after_tool_call must advertise the 'replace' action"
421 );
422 }
423
424 #[test]
426 fn hook_kind_as_str_roundtrip() {
427 let all = [
428 HookKind::BeforeToolCall,
429 HookKind::AfterToolCall,
430 HookKind::BeforeMessage,
431 HookKind::OnMessageComplete,
432 HookKind::OnCompaction,
433 HookKind::OnSessionStart,
434 HookKind::OnSessionEnd,
435 ];
436 for kind in all {
437 let s = kind.as_str();
438 assert_eq!(
439 HookKind::from_str(s),
440 Some(kind),
441 "round-trip failed for {s}"
442 );
443 }
444 }
445
446 #[test]
448 fn hook_kind_from_str_unknown_returns_none() {
449 assert_eq!(HookKind::from_str(""), None);
450 assert_eq!(HookKind::from_str("BeforeToolCall"), None); assert_eq!(HookKind::from_str("on_crash"), None);
452 }
453
454 #[test]
456 fn hook_kind_serde_snake_case() {
457 let serialized = serde_json::to_string(&HookKind::BeforeToolCall).unwrap();
458 assert_eq!(serialized, r#""before_tool_call""#);
459
460 let back: HookKind = serde_json::from_str(r#""on_session_end""#).unwrap();
461 assert_eq!(back, HookKind::OnSessionEnd);
462 }
463
464 #[test]
466 fn hook_kind_required_permission() {
467 assert_eq!(
468 HookKind::BeforeToolCall.required_permission(),
469 Permission::ToolsIntercept
470 );
471 assert_eq!(
472 HookKind::AfterToolCall.required_permission(),
473 Permission::ToolsIntercept
474 );
475 assert_eq!(
476 HookKind::BeforeMessage.required_permission(),
477 Permission::LlmContent
478 );
479 assert_eq!(
480 HookKind::OnMessageComplete.required_permission(),
481 Permission::LlmContent
482 );
483 assert_eq!(
484 HookKind::OnCompaction.required_permission(),
485 Permission::LlmContent
486 );
487 assert_eq!(
488 HookKind::OnSessionStart.required_permission(),
489 Permission::SessionLifecycle
490 );
491 assert_eq!(
492 HookKind::OnSessionEnd.required_permission(),
493 Permission::SessionLifecycle
494 );
495 }
496
497 #[test]
500 fn hook_event_before_tool_call() {
501 let input = json!({"path": "/tmp/foo"});
502 let ev = HookEvent::before_tool_call("read_file", input.clone());
503
504 assert_eq!(ev.kind, HookKind::BeforeToolCall);
505 assert_eq!(ev.tool_name.as_deref(), Some("read_file"));
506 assert_eq!(ev.tool_input.as_ref(), Some(&input));
507 assert!(ev.tool_output.is_none());
508 assert!(ev.message.is_none());
509 assert!(ev.session_id.is_none());
510 assert_eq!(ev.data, Value::Null);
511 }
512
513 #[test]
514 fn hook_event_after_tool_call() {
515 let input = json!({"query": "select 1"});
516 let ev = HookEvent::after_tool_call("sql_query", input.clone(), "1 row".to_string());
517
518 assert_eq!(ev.kind, HookKind::AfterToolCall);
519 assert_eq!(ev.tool_name.as_deref(), Some("sql_query"));
520 assert_eq!(ev.tool_input.as_ref(), Some(&input));
521 assert_eq!(ev.tool_output.as_deref(), Some("1 row"));
522 assert!(ev.message.is_none());
523 assert!(ev.session_id.is_none());
524 }
525
526 #[test]
527 fn hook_event_before_message() {
528 let ev = HookEvent::before_message("Hello, LLM");
529
530 assert_eq!(ev.kind, HookKind::BeforeMessage);
531 assert!(ev.tool_name.is_none());
532 assert!(ev.tool_input.is_none());
533 assert!(ev.tool_output.is_none());
534 assert_eq!(ev.message.as_deref(), Some("Hello, LLM"));
535 assert!(ev.session_id.is_none());
536 }
537
538 #[test]
539 fn hook_event_on_message_complete() {
540 let ev = HookEvent::on_message_complete("Done", json!({"content_block_count": 1}));
541
542 assert_eq!(ev.kind, HookKind::OnMessageComplete);
543 assert!(ev.tool_name.is_none());
544 assert!(ev.tool_input.is_none());
545 assert!(ev.tool_output.is_none());
546 assert_eq!(ev.message.as_deref(), Some("Done"));
547 assert_eq!(ev.data["content_block_count"], 1);
548 assert!(ev.session_id.is_none());
549 }
550
551 #[test]
552 fn hook_event_on_compaction() {
553 let ev = HookEvent::on_compaction(
554 "old-session",
555 "new-session",
556 "Summary",
557 7,
558 json!({"source": "manual"}),
559 );
560
561 assert_eq!(ev.kind, HookKind::OnCompaction);
562 assert_eq!(ev.message.as_deref(), Some("Summary"));
563 assert_eq!(ev.session_id.as_deref(), Some("new-session"));
564 assert_eq!(ev.data["old_session_id"], "old-session");
565 assert_eq!(ev.data["new_session_id"], "new-session");
566 assert_eq!(ev.data["message_count"], 7);
567 assert_eq!(ev.data["source"], "manual");
568 assert!(ev.transcript.is_none());
569 }
570
571 #[test]
572 fn hook_event_on_session_start() {
573 let ev = HookEvent::on_session_start("sess-abc-123");
574
575 assert_eq!(ev.kind, HookKind::OnSessionStart);
576 assert_eq!(ev.session_id.as_deref(), Some("sess-abc-123"));
577 assert!(ev.tool_name.is_none());
578 assert!(ev.message.is_none());
579 }
580
581 #[test]
582 fn hook_event_on_session_end() {
583 let ev = HookEvent::on_session_end("sess-abc-123", None);
584
585 assert_eq!(ev.kind, HookKind::OnSessionEnd);
586 assert_eq!(ev.session_id.as_deref(), Some("sess-abc-123"));
587 assert!(ev.tool_name.is_none());
588 assert!(ev.message.is_none());
589 }
590
591 #[test]
593 fn hook_event_serde_roundtrip() {
594 let ev = HookEvent::before_tool_call("bash", json!({"cmd": "ls"}));
595 let json = serde_json::to_string(&ev).unwrap();
596 let back: HookEvent = serde_json::from_str(&json).unwrap();
597
598 assert_eq!(back.kind, ev.kind);
599 assert_eq!(back.tool_name, ev.tool_name);
600 assert_eq!(back.tool_input, ev.tool_input);
601 }
602
603 #[test]
607 fn hook_result_default_is_continue() {
608 assert!(matches!(HookResult::default(), HookResult::Continue));
609 }
610
611 #[test]
613 fn hook_result_block_serde() {
614 let r = HookResult::Block {
615 reason: "denied by policy".to_string(),
616 };
617 let json = serde_json::to_string(&r).unwrap();
618 assert!(json.contains(r#""action":"block""#));
620 assert!(json.contains("denied by policy"));
621
622 let back: HookResult = serde_json::from_str(&json).unwrap();
623 assert!(matches!(back, HookResult::Block { reason } if reason == "denied by policy"));
624 }
625
626 #[test]
628 fn hook_result_confirm_serde() {
629 let r = HookResult::Confirm {
630 message: "Run this command?".to_string(),
631 };
632 let json = serde_json::to_string(&r).unwrap();
633 assert_eq!(
634 json,
635 r#"{"action":"confirm","message":"Run this command?"}"#
636 );
637
638 let back: HookResult = serde_json::from_str(&json).unwrap();
639 assert!(matches!(back, HookResult::Confirm { message } if message == "Run this command?"));
640 }
641
642 #[test]
644 fn hook_result_modify_serde() {
645 let r = HookResult::Modify {
646 input: json!({"command": "echo safe"}),
647 };
648 let json = serde_json::to_string(&r).unwrap();
649 assert_eq!(
650 json,
651 r#"{"action":"modify","input":{"command":"echo safe"}}"#
652 );
653
654 let back: HookResult = serde_json::from_str(&json).unwrap();
655 assert!(
656 matches!(back, HookResult::Modify { input } if input == json!({"command": "echo safe"}))
657 );
658 }
659
660 #[test]
662 fn hook_result_continue_serde() {
663 let json = serde_json::to_string(&HookResult::Continue).unwrap();
664 assert_eq!(json, r#"{"action":"continue"}"#);
665 }
666}