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 fn as_str(&self) -> &'static str {
53 match self {
54 Self::BeforeToolCall => "before_tool_call",
55 Self::AfterToolCall => "after_tool_call",
56 Self::BeforeMessage => "before_message",
57 Self::OnMessageComplete => "on_message_complete",
58 Self::OnCompaction => "on_compaction",
59 Self::OnSessionStart => "on_session_start",
60 Self::OnSessionEnd => "on_session_end",
61 }
62 }
63
64 #[allow(clippy::should_implement_trait)]
69 pub fn from_str(s: &str) -> Option<Self> {
70 match s {
71 "before_tool_call" => Some(Self::BeforeToolCall),
72 "after_tool_call" => Some(Self::AfterToolCall),
73 "before_message" => Some(Self::BeforeMessage),
74 "on_message_complete" => Some(Self::OnMessageComplete),
75 "on_compaction" => Some(Self::OnCompaction),
76 "on_session_start" => Some(Self::OnSessionStart),
77 "on_session_end" => Some(Self::OnSessionEnd),
78 _ => None,
79 }
80 }
81
82 pub fn allowed_action_names(&self) -> &'static [&'static str] {
84 match self {
85 Self::BeforeToolCall => &["continue", "block", "confirm", "modify"],
86 Self::AfterToolCall => &["continue", "replace"],
87 Self::BeforeMessage => &["continue", "inject"],
88 Self::OnMessageComplete
89 | Self::OnCompaction
90 | Self::OnSessionStart
91 | Self::OnSessionEnd => &["continue"],
92 }
93 }
94
95 pub fn allows_tool_filter(&self) -> bool {
97 matches!(self, Self::BeforeToolCall | Self::AfterToolCall)
98 }
99
100 pub fn allows_result(&self, result: &HookResult) -> bool {
102 matches!(
103 (self, result),
104 (_, HookResult::Continue)
105 | (Self::BeforeToolCall, HookResult::Block { .. })
106 | (Self::BeforeToolCall, HookResult::Confirm { .. })
107 | (Self::BeforeToolCall, HookResult::Modify { .. })
108 | (Self::AfterToolCall, HookResult::Replace { .. })
109 | (Self::BeforeMessage, HookResult::Inject { .. })
110 )
111 }
112
113 pub fn required_permission(&self) -> Permission {
119 match self {
120 Self::BeforeToolCall | Self::AfterToolCall => Permission::ToolsIntercept,
121 Self::BeforeMessage | Self::OnMessageComplete | Self::OnCompaction => {
122 Permission::LlmContent
123 }
124 Self::OnSessionStart | Self::OnSessionEnd => Permission::SessionLifecycle,
125 }
126 }
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct HookEvent {
149 pub kind: HookKind,
151 pub tool_name: Option<String>,
154 #[serde(default)]
157 pub tool_runtime_name: Option<String>,
158 pub tool_input: Option<Value>,
160 pub tool_output: Option<String>,
162 pub message: Option<String>,
164 pub session_id: Option<String>,
166 #[serde(default)]
170 pub transcript: Option<Vec<crate::SharedMessage>>,
171 pub data: Value,
173}
174
175impl HookEvent {
176 pub fn before_tool_call(tool_name: &str, input: Value) -> Self {
178 Self {
179 kind: HookKind::BeforeToolCall,
180 tool_name: Some(tool_name.to_string()),
181 tool_input: Some(input),
182 tool_output: None,
183 message: None,
184 session_id: None,
185 tool_runtime_name: None,
186 transcript: None,
187 data: Value::Null,
188 }
189 }
190
191 pub fn after_tool_call(tool_name: &str, input: Value, output: String) -> Self {
199 let bounded = agent_core::BoundedText::new(&output, MAX_HOOK_OUTPUT);
200 let truncated_output = if bounded.truncated {
201 format!(
202 "{}…[truncated, {} total bytes]",
203 bounded.text, bounded.original_bytes
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<crate::SharedMessage>>) -> 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}