1use std::path::PathBuf;
9use std::time::Instant;
10
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15use crate::types::SessionId;
16
17#[derive(Debug, Clone)]
19pub struct HookContext {
20 pub session_id: SessionId,
22}
23
24#[derive(Debug, Clone, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct PreToolUseInput {
28 pub session_id: String,
30 pub timestamp: f64,
32 #[serde(rename = "cwd")]
34 pub working_directory: PathBuf,
35 pub tool_name: String,
37 pub tool_args: Value,
39}
40
41#[derive(Debug, Clone, Default, Serialize)]
43#[serde(rename_all = "camelCase")]
44pub struct PreToolUseOutput {
45 #[serde(skip_serializing_if = "Option::is_none")]
47 pub permission_decision: Option<String>,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub permission_decision_reason: Option<String>,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub modified_args: Option<Value>,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub additional_context: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub suppress_output: Option<bool>,
60}
61
62#[derive(Debug, Clone, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct PreMcpToolCallInput {
66 pub session_id: String,
68 pub timestamp: f64,
70 #[serde(rename = "cwd")]
72 pub working_directory: PathBuf,
73 pub server_name: String,
75 pub tool_name: String,
77 pub arguments: Value,
79 #[serde(default)]
81 pub tool_call_id: Option<String>,
82 #[serde(default, rename = "_meta")]
84 pub meta: Option<Value>,
85}
86
87#[derive(Debug, Clone, Default, Serialize)]
94#[serde(rename_all = "camelCase")]
95pub struct PreMcpToolCallOutput {
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub meta_to_use: Option<Value>,
99}
100
101#[derive(Debug, Clone, Deserialize)]
103#[serde(rename_all = "camelCase")]
104pub struct PostToolUseInput {
105 pub session_id: String,
107 pub timestamp: f64,
109 #[serde(rename = "cwd")]
111 pub working_directory: PathBuf,
112 pub tool_name: String,
114 pub tool_args: Value,
116 pub tool_result: Value,
118}
119
120#[derive(Debug, Clone, Default, Serialize)]
122#[serde(rename_all = "camelCase")]
123pub struct PostToolUseOutput {
124 #[serde(skip_serializing_if = "Option::is_none")]
126 pub modified_result: Option<Value>,
127 #[serde(skip_serializing_if = "Option::is_none")]
129 pub additional_context: Option<String>,
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub suppress_output: Option<bool>,
133}
134
135#[derive(Debug, Clone, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct PostToolUseFailureInput {
145 pub session_id: String,
147 pub timestamp: f64,
149 #[serde(rename = "cwd")]
151 pub working_directory: PathBuf,
152 pub tool_name: String,
154 pub tool_args: Value,
156 pub error: String,
158}
159
160#[derive(Debug, Clone, Default, Serialize)]
165#[serde(rename_all = "camelCase")]
166pub struct PostToolUseFailureOutput {
167 #[serde(skip_serializing_if = "Option::is_none")]
169 pub additional_context: Option<String>,
170}
171
172#[derive(Debug, Clone, Deserialize)]
174#[serde(rename_all = "camelCase")]
175pub struct UserPromptSubmittedInput {
176 pub session_id: String,
178 pub timestamp: f64,
180 #[serde(rename = "cwd")]
182 pub working_directory: PathBuf,
183 pub prompt: String,
185}
186
187#[derive(Debug, Clone, Default, Serialize)]
189#[serde(rename_all = "camelCase")]
190pub struct UserPromptSubmittedOutput {
191 #[serde(skip_serializing_if = "Option::is_none")]
193 pub modified_prompt: Option<String>,
194 #[serde(skip_serializing_if = "Option::is_none")]
196 pub additional_context: Option<String>,
197 #[serde(skip_serializing_if = "Option::is_none")]
199 pub suppress_output: Option<bool>,
200}
201
202#[derive(Debug, Clone, Deserialize)]
204#[serde(rename_all = "camelCase")]
205pub struct SessionStartInput {
206 pub session_id: String,
208 pub timestamp: f64,
210 #[serde(rename = "cwd")]
212 pub working_directory: PathBuf,
213 pub source: String,
215 #[serde(default)]
217 pub initial_prompt: Option<String>,
218}
219
220#[derive(Debug, Clone, Default, Serialize)]
222#[serde(rename_all = "camelCase")]
223pub struct SessionStartOutput {
224 #[serde(skip_serializing_if = "Option::is_none")]
226 pub additional_context: Option<String>,
227 #[serde(skip_serializing_if = "Option::is_none")]
229 pub modified_config: Option<Value>,
230}
231
232#[derive(Debug, Clone, Deserialize)]
234#[serde(rename_all = "camelCase")]
235pub struct SessionEndInput {
236 pub session_id: String,
238 pub timestamp: f64,
240 #[serde(rename = "cwd")]
242 pub working_directory: PathBuf,
243 pub reason: String,
245 #[serde(default)]
247 pub final_message: Option<String>,
248 #[serde(default)]
250 pub error: Option<String>,
251}
252
253#[derive(Debug, Clone, Default, Serialize)]
255#[serde(rename_all = "camelCase")]
256pub struct SessionEndOutput {
257 #[serde(skip_serializing_if = "Option::is_none")]
259 pub suppress_output: Option<bool>,
260 #[serde(skip_serializing_if = "Option::is_none")]
262 pub cleanup_actions: Option<Vec<String>>,
263 #[serde(skip_serializing_if = "Option::is_none")]
265 pub session_summary: Option<String>,
266}
267
268#[derive(Debug, Clone, Deserialize)]
270#[serde(rename_all = "camelCase")]
271pub struct ErrorOccurredInput {
272 pub session_id: String,
274 pub timestamp: f64,
276 #[serde(rename = "cwd")]
278 pub working_directory: PathBuf,
279 pub error: String,
281 pub error_context: String,
283 pub recoverable: bool,
285}
286
287#[derive(Debug, Clone, Default, Serialize)]
289#[serde(rename_all = "camelCase")]
290pub struct ErrorOccurredOutput {
291 #[serde(skip_serializing_if = "Option::is_none")]
293 pub suppress_output: Option<bool>,
294 #[serde(skip_serializing_if = "Option::is_none")]
296 pub error_handling: Option<String>,
297 #[serde(skip_serializing_if = "Option::is_none")]
299 pub retry_count: Option<u32>,
300 #[serde(skip_serializing_if = "Option::is_none")]
302 pub user_notification: Option<String>,
303}
304
305#[derive(Debug, Clone, Deserialize)]
307#[serde(rename_all = "camelCase")]
308pub struct AgentStopInput {
309 pub session_id: String,
311 pub timestamp: f64,
313 #[serde(rename = "cwd")]
315 pub working_directory: PathBuf,
316 #[serde(default)]
318 pub stop_reason: Option<String>,
319 #[serde(default)]
321 pub transcript_path: Option<PathBuf>,
322 #[serde(default, rename = "stop_hook_active")]
324 pub stop_hook_active: Option<bool>,
325}
326
327#[derive(Debug, Clone, Default, Serialize)]
329#[serde(rename_all = "camelCase")]
330pub struct AgentStopOutput {
331 #[serde(skip_serializing_if = "Option::is_none")]
333 pub decision: Option<String>,
334 #[serde(skip_serializing_if = "Option::is_none")]
336 pub reason: Option<String>,
337}
338
339#[non_exhaustive]
345#[derive(Debug)]
346pub enum HookEvent {
347 PreToolUse {
349 input: PreToolUseInput,
351 ctx: HookContext,
353 },
354 PreMcpToolCall {
356 input: PreMcpToolCallInput,
358 ctx: HookContext,
360 },
361 PostToolUse {
363 input: PostToolUseInput,
365 ctx: HookContext,
367 },
368 PostToolUseFailure {
372 input: PostToolUseFailureInput,
374 ctx: HookContext,
376 },
377 UserPromptSubmitted {
379 input: UserPromptSubmittedInput,
381 ctx: HookContext,
383 },
384 SessionStart {
386 input: SessionStartInput,
388 ctx: HookContext,
390 },
391 SessionEnd {
393 input: SessionEndInput,
395 ctx: HookContext,
397 },
398 ErrorOccurred {
400 input: ErrorOccurredInput,
402 ctx: HookContext,
404 },
405 AgentStop {
407 input: AgentStopInput,
409 ctx: HookContext,
411 },
412}
413
414#[non_exhaustive]
419#[derive(Debug)]
420pub enum HookOutput {
421 None,
423 PreToolUse(PreToolUseOutput),
425 PreMcpToolCall(PreMcpToolCallOutput),
427 PostToolUse(PostToolUseOutput),
429 PostToolUseFailure(PostToolUseFailureOutput),
431 UserPromptSubmitted(UserPromptSubmittedOutput),
433 SessionStart(SessionStartOutput),
435 SessionEnd(SessionEndOutput),
437 ErrorOccurred(ErrorOccurredOutput),
439 AgentStop(AgentStopOutput),
441}
442
443impl HookOutput {
444 fn variant_name(&self) -> &'static str {
445 match self {
446 Self::None => "None",
447 Self::PreToolUse(_) => "PreToolUse",
448 Self::PreMcpToolCall(_) => "PreMcpToolCall",
449 Self::PostToolUse(_) => "PostToolUse",
450 Self::PostToolUseFailure(_) => "PostToolUseFailure",
451 Self::UserPromptSubmitted(_) => "UserPromptSubmitted",
452 Self::SessionStart(_) => "SessionStart",
453 Self::SessionEnd(_) => "SessionEnd",
454 Self::ErrorOccurred(_) => "ErrorOccurred",
455 Self::AgentStop(_) => "AgentStop",
456 }
457 }
458}
459
460#[async_trait]
478pub trait SessionHooks: Send + Sync + 'static {
479 async fn on_hook(&self, event: HookEvent) -> HookOutput {
483 match event {
484 HookEvent::PreToolUse { input, ctx } => self
485 .on_pre_tool_use(input, ctx)
486 .await
487 .map(HookOutput::PreToolUse)
488 .unwrap_or(HookOutput::None),
489 HookEvent::PreMcpToolCall { input, ctx } => self
490 .on_pre_mcp_tool_call(input, ctx)
491 .await
492 .map(HookOutput::PreMcpToolCall)
493 .unwrap_or(HookOutput::None),
494 HookEvent::PostToolUse { input, ctx } => self
495 .on_post_tool_use(input, ctx)
496 .await
497 .map(HookOutput::PostToolUse)
498 .unwrap_or(HookOutput::None),
499 HookEvent::PostToolUseFailure { input, ctx } => self
500 .on_post_tool_use_failure(input, ctx)
501 .await
502 .map(HookOutput::PostToolUseFailure)
503 .unwrap_or(HookOutput::None),
504 HookEvent::UserPromptSubmitted { input, ctx } => self
505 .on_user_prompt_submitted(input, ctx)
506 .await
507 .map(HookOutput::UserPromptSubmitted)
508 .unwrap_or(HookOutput::None),
509 HookEvent::SessionStart { input, ctx } => self
510 .on_session_start(input, ctx)
511 .await
512 .map(HookOutput::SessionStart)
513 .unwrap_or(HookOutput::None),
514 HookEvent::SessionEnd { input, ctx } => self
515 .on_session_end(input, ctx)
516 .await
517 .map(HookOutput::SessionEnd)
518 .unwrap_or(HookOutput::None),
519 HookEvent::ErrorOccurred { input, ctx } => self
520 .on_error_occurred(input, ctx)
521 .await
522 .map(HookOutput::ErrorOccurred)
523 .unwrap_or(HookOutput::None),
524 HookEvent::AgentStop { input, ctx } => self
525 .on_agent_stop(input, ctx)
526 .await
527 .map(HookOutput::AgentStop)
528 .unwrap_or(HookOutput::None),
529 }
530 }
531
532 async fn on_pre_tool_use(
535 &self,
536 _input: PreToolUseInput,
537 _ctx: HookContext,
538 ) -> Option<PreToolUseOutput> {
539 None
540 }
541
542 async fn on_pre_mcp_tool_call(
545 &self,
546 _input: PreMcpToolCallInput,
547 _ctx: HookContext,
548 ) -> Option<PreMcpToolCallOutput> {
549 None
550 }
551
552 async fn on_post_tool_use(
556 &self,
557 _input: PostToolUseInput,
558 _ctx: HookContext,
559 ) -> Option<PostToolUseOutput> {
560 None
561 }
562
563 async fn on_post_tool_use_failure(
568 &self,
569 _input: PostToolUseFailureInput,
570 _ctx: HookContext,
571 ) -> Option<PostToolUseFailureOutput> {
572 None
573 }
574
575 async fn on_user_prompt_submitted(
579 &self,
580 _input: UserPromptSubmittedInput,
581 _ctx: HookContext,
582 ) -> Option<UserPromptSubmittedOutput> {
583 None
584 }
585
586 async fn on_session_start(
589 &self,
590 _input: SessionStartInput,
591 _ctx: HookContext,
592 ) -> Option<SessionStartOutput> {
593 None
594 }
595
596 async fn on_session_end(
599 &self,
600 _input: SessionEndInput,
601 _ctx: HookContext,
602 ) -> Option<SessionEndOutput> {
603 None
604 }
605
606 async fn on_error_occurred(
609 &self,
610 _input: ErrorOccurredInput,
611 _ctx: HookContext,
612 ) -> Option<ErrorOccurredOutput> {
613 None
614 }
615
616 async fn on_agent_stop(
619 &self,
620 _input: AgentStopInput,
621 _ctx: HookContext,
622 ) -> Option<AgentStopOutput> {
623 None
624 }
625}
626
627pub(crate) async fn dispatch_hook(
633 hooks: &dyn SessionHooks,
634 session_id: &SessionId,
635 hook_type: &str,
636 raw_input: Value,
637) -> Result<Value, crate::Error> {
638 let ctx = HookContext {
639 session_id: session_id.clone(),
640 };
641
642 let event = match hook_type {
643 "preToolUse" => {
644 let input: PreToolUseInput = serde_json::from_value(raw_input)?;
645 HookEvent::PreToolUse { input, ctx }
646 }
647 "preMcpToolCall" => {
648 let input: PreMcpToolCallInput = serde_json::from_value(raw_input)?;
649 HookEvent::PreMcpToolCall { input, ctx }
650 }
651 "postToolUse" => {
652 let input: PostToolUseInput = serde_json::from_value(raw_input)?;
653 HookEvent::PostToolUse { input, ctx }
654 }
655 "postToolUseFailure" => {
656 let input: PostToolUseFailureInput = serde_json::from_value(raw_input)?;
657 HookEvent::PostToolUseFailure { input, ctx }
658 }
659 "userPromptSubmitted" => {
660 let input: UserPromptSubmittedInput = serde_json::from_value(raw_input)?;
661 HookEvent::UserPromptSubmitted { input, ctx }
662 }
663 "sessionStart" => {
664 let input: SessionStartInput = serde_json::from_value(raw_input)?;
665 HookEvent::SessionStart { input, ctx }
666 }
667 "sessionEnd" => {
668 let input: SessionEndInput = serde_json::from_value(raw_input)?;
669 HookEvent::SessionEnd { input, ctx }
670 }
671 "errorOccurred" => {
672 let input: ErrorOccurredInput = serde_json::from_value(raw_input)?;
673 HookEvent::ErrorOccurred { input, ctx }
674 }
675 "agentStop" => {
676 let input: AgentStopInput = serde_json::from_value(raw_input)?;
677 HookEvent::AgentStop { input, ctx }
678 }
679 _ => {
680 tracing::warn!(
681 hook_type = hook_type,
682 session_id = %session_id,
683 "unknown hook type"
684 );
685 return Ok(serde_json::json!({ "output": {} }));
686 }
687 };
688
689 let dispatch_start = Instant::now();
690 let output = hooks.on_hook(event).await;
691 tracing::debug!(
692 elapsed_ms = dispatch_start.elapsed().as_millis(),
693 session_id = %session_id,
694 hook_type = hook_type,
695 "SessionHooks::on_hook dispatch"
696 );
697
698 let output_value = match (hook_type, &output) {
703 (_, HookOutput::None) => None,
704 ("preToolUse", HookOutput::PreToolUse(o)) => Some(serde_json::to_value(o)?),
705 ("preMcpToolCall", HookOutput::PreMcpToolCall(o)) => Some(serde_json::to_value(o)?),
706 ("postToolUse", HookOutput::PostToolUse(o)) => Some(serde_json::to_value(o)?),
707 ("postToolUseFailure", HookOutput::PostToolUseFailure(o)) => Some(serde_json::to_value(o)?),
708 ("userPromptSubmitted", HookOutput::UserPromptSubmitted(o)) => {
709 Some(serde_json::to_value(o)?)
710 }
711 ("sessionStart", HookOutput::SessionStart(o)) => Some(serde_json::to_value(o)?),
712 ("sessionEnd", HookOutput::SessionEnd(o)) => Some(serde_json::to_value(o)?),
713 ("errorOccurred", HookOutput::ErrorOccurred(o)) => Some(serde_json::to_value(o)?),
714 ("agentStop", HookOutput::AgentStop(o)) => Some(serde_json::to_value(o)?),
715 _ => {
716 tracing::warn!(
717 hook_type = hook_type,
718 session_id = %session_id,
719 output_variant = output.variant_name(),
720 "hook returned mismatched output variant, treating as unregistered"
721 );
722 None
723 }
724 };
725
726 Ok(serde_json::json!({ "output": output_value.unwrap_or(Value::Object(Default::default())) }))
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732
733 struct TestHooks;
734
735 #[async_trait]
736 impl SessionHooks for TestHooks {
737 async fn on_hook(&self, event: HookEvent) -> HookOutput {
738 match event {
739 HookEvent::PreToolUse { input, .. } => {
740 if input.tool_name == "dangerous_tool" {
741 HookOutput::PreToolUse(PreToolUseOutput {
742 permission_decision: Some("deny".to_string()),
743 permission_decision_reason: Some("blocked by policy".to_string()),
744 ..Default::default()
745 })
746 } else {
747 HookOutput::None
748 }
749 }
750 HookEvent::UserPromptSubmitted { input, .. } => {
751 HookOutput::UserPromptSubmitted(UserPromptSubmittedOutput {
752 modified_prompt: Some(format!("[prefixed] {}", input.prompt)),
753 ..Default::default()
754 })
755 }
756 _ => HookOutput::None,
757 }
758 }
759 }
760
761 #[tokio::test]
762 async fn dispatch_pre_tool_use_deny() {
763 let hooks = TestHooks;
764 let input = serde_json::json!({
765 "sessionId": "sess-1",
766 "timestamp": 1234567890,
767 "cwd": "/tmp",
768 "toolName": "dangerous_tool",
769 "toolArgs": {}
770 });
771 let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "preToolUse", input)
772 .await
773 .unwrap();
774 let output = &result["output"];
775 assert_eq!(output["permissionDecision"], "deny");
776 assert_eq!(output["permissionDecisionReason"], "blocked by policy");
777 }
778
779 #[tokio::test]
780 async fn dispatch_pre_tool_use_passthrough() {
781 let hooks = TestHooks;
782 let input = serde_json::json!({
783 "sessionId": "sess-1",
784 "timestamp": 1234567890,
785 "cwd": "/tmp",
786 "toolName": "safe_tool",
787 "toolArgs": {"key": "value"}
788 });
789 let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "preToolUse", input)
790 .await
791 .unwrap();
792 assert_eq!(result["output"], serde_json::json!({}));
794 }
795
796 #[tokio::test]
797 async fn dispatch_user_prompt_submitted() {
798 let hooks = TestHooks;
799 let input = serde_json::json!({
800 "sessionId": "sess-1",
801 "timestamp": 1234567890,
802 "cwd": "/tmp",
803 "prompt": "hello world"
804 });
805 let result = dispatch_hook(
806 &hooks,
807 &SessionId::new("sess-1"),
808 "userPromptSubmitted",
809 input,
810 )
811 .await
812 .unwrap();
813 assert_eq!(result["output"]["modifiedPrompt"], "[prefixed] hello world");
814 }
815
816 #[tokio::test]
817 async fn dispatch_unregistered_hook_returns_empty() {
818 let hooks = TestHooks;
819 let input = serde_json::json!({
820 "sessionId": "sess-1",
821 "timestamp": 1234567890,
822 "cwd": "/tmp",
823 "reason": "complete"
824 });
825 let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "sessionEnd", input)
827 .await
828 .unwrap();
829 assert_eq!(result["output"], serde_json::json!({}));
830 }
831
832 #[tokio::test]
833 async fn dispatch_unknown_hook_type() {
834 let hooks = TestHooks;
835 let input = serde_json::json!({});
836 let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "unknownHook", input)
837 .await
838 .unwrap();
839 assert_eq!(result["output"], serde_json::json!({}));
840 }
841
842 #[tokio::test]
843 async fn dispatch_mismatched_output_returns_empty() {
844 struct MismatchHooks;
845 #[async_trait]
846 impl SessionHooks for MismatchHooks {
847 async fn on_hook(&self, _event: HookEvent) -> HookOutput {
848 HookOutput::SessionEnd(SessionEndOutput {
850 session_summary: Some("oops".to_string()),
851 ..Default::default()
852 })
853 }
854 }
855
856 let hooks = MismatchHooks;
857 let input = serde_json::json!({
858 "sessionId": "sess-1",
859 "timestamp": 1234567890,
860 "cwd": "/tmp",
861 "toolName": "some_tool",
862 "toolArgs": {}
863 });
864 let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "preToolUse", input)
866 .await
867 .unwrap();
868 assert_eq!(result["output"], serde_json::json!({}));
869 }
870
871 #[tokio::test]
872 async fn dispatch_post_tool_use_default() {
873 let hooks = TestHooks;
874 let input = serde_json::json!({
875 "sessionId": "sess-1",
876 "timestamp": 1234567890,
877 "cwd": "/tmp",
878 "toolName": "some_tool",
879 "toolArgs": {},
880 "toolResult": "success"
881 });
882 let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "postToolUse", input)
883 .await
884 .unwrap();
885 assert_eq!(result["output"], serde_json::json!({}));
886 }
887
888 #[tokio::test]
889 async fn dispatch_post_tool_use_failure_default() {
890 let hooks = TestHooks;
892 let input = serde_json::json!({
893 "sessionId": "sess-1",
894 "timestamp": 1234567890,
895 "cwd": "/tmp",
896 "toolName": "some_tool",
897 "toolArgs": {"key": "value"},
898 "error": "boom"
899 });
900 let result = dispatch_hook(
901 &hooks,
902 &SessionId::new("sess-1"),
903 "postToolUseFailure",
904 input,
905 )
906 .await
907 .unwrap();
908 assert_eq!(result["output"], serde_json::json!({}));
909 }
910
911 #[tokio::test]
912 async fn dispatch_post_tool_use_failure_returns_additional_context() {
913 struct FailureHooks;
914 #[async_trait]
915 impl SessionHooks for FailureHooks {
916 async fn on_post_tool_use_failure(
917 &self,
918 input: PostToolUseFailureInput,
919 _ctx: HookContext,
920 ) -> Option<PostToolUseFailureOutput> {
921 assert_eq!(input.session_id, "sess-1");
922 assert_eq!(input.tool_name, "some_tool");
923 assert_eq!(input.error, "boom");
924 assert_eq!(input.working_directory, PathBuf::from("/tmp"));
925 Some(PostToolUseFailureOutput {
926 additional_context: Some(format!(
927 "tool {} failed: {}",
928 input.tool_name, input.error
929 )),
930 })
931 }
932 }
933
934 let input = serde_json::json!({
935 "sessionId": "sess-1",
936 "timestamp": 1234567890,
937 "cwd": "/tmp",
938 "toolName": "some_tool",
939 "toolArgs": {},
940 "error": "boom"
941 });
942 let result = dispatch_hook(
943 &FailureHooks,
944 &SessionId::new("sess-1"),
945 "postToolUseFailure",
946 input,
947 )
948 .await
949 .unwrap();
950 assert_eq!(
951 result["output"]["additionalContext"],
952 "tool some_tool failed: boom"
953 );
954 }
955
956 #[tokio::test]
957 async fn dispatch_post_tool_use_failure_invalid_input_errors() {
958 let hooks = TestHooks;
961 let input = serde_json::json!({
962 "sessionId": "sess-1",
963 "timestamp": 1234567890,
964 "cwd": "/tmp",
965 "toolName": "some_tool",
966 "toolArgs": {}
967 });
968 let err = dispatch_hook(
969 &hooks,
970 &SessionId::new("sess-1"),
971 "postToolUseFailure",
972 input,
973 )
974 .await
975 .unwrap_err();
976 let msg = err.to_string().to_ascii_lowercase();
977 assert!(
978 msg.contains("error") || msg.contains("missing field"),
979 "unexpected error: {msg}"
980 );
981 }
982
983 #[tokio::test]
984 async fn dispatch_session_start() {
985 struct StartHooks;
986 #[async_trait]
987 impl SessionHooks for StartHooks {
988 async fn on_hook(&self, event: HookEvent) -> HookOutput {
989 match event {
990 HookEvent::SessionStart { .. } => {
991 HookOutput::SessionStart(SessionStartOutput {
992 additional_context: Some("extra context".to_string()),
993 ..Default::default()
994 })
995 }
996 _ => HookOutput::None,
997 }
998 }
999 }
1000
1001 let hooks = StartHooks;
1002 let input = serde_json::json!({
1003 "sessionId": "sess-1",
1004 "timestamp": 1234567890,
1005 "cwd": "/tmp",
1006 "source": "new"
1007 });
1008 let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "sessionStart", input)
1009 .await
1010 .unwrap();
1011 assert_eq!(result["output"]["additionalContext"], "extra context");
1012 }
1013
1014 #[tokio::test]
1015 async fn dispatch_error_occurred() {
1016 struct ErrorHooks;
1017 #[async_trait]
1018 impl SessionHooks for ErrorHooks {
1019 async fn on_hook(&self, event: HookEvent) -> HookOutput {
1020 match event {
1021 HookEvent::ErrorOccurred { .. } => {
1022 HookOutput::ErrorOccurred(ErrorOccurredOutput {
1023 error_handling: Some("retry".to_string()),
1024 retry_count: Some(3),
1025 ..Default::default()
1026 })
1027 }
1028 _ => HookOutput::None,
1029 }
1030 }
1031 }
1032
1033 let hooks = ErrorHooks;
1034 let input = serde_json::json!({
1035 "sessionId": "sess-1",
1036 "timestamp": 1234567890,
1037 "cwd": "/tmp",
1038 "error": "model timeout",
1039 "errorContext": "model_call",
1040 "recoverable": true
1041 });
1042 let result = dispatch_hook(&hooks, &SessionId::new("sess-1"), "errorOccurred", input)
1043 .await
1044 .unwrap();
1045 assert_eq!(result["output"]["errorHandling"], "retry");
1046 assert_eq!(result["output"]["retryCount"], 3);
1047 }
1048
1049 #[tokio::test]
1050 async fn dispatch_agent_stop_block() {
1051 struct AgentStopHooks;
1052 #[async_trait]
1053 impl SessionHooks for AgentStopHooks {
1054 async fn on_agent_stop(
1055 &self,
1056 input: AgentStopInput,
1057 ctx: HookContext,
1058 ) -> Option<AgentStopOutput> {
1059 assert_eq!(ctx.session_id, SessionId::new("sess-1"));
1060 assert_eq!(input.session_id, "sess-1");
1061 assert_eq!(input.stop_reason.as_deref(), Some("end_turn"));
1062 assert_eq!(
1063 input.transcript_path,
1064 Some(PathBuf::from("/tmp/transcript.jsonl"))
1065 );
1066 assert_eq!(input.stop_hook_active, Some(true));
1067 Some(AgentStopOutput {
1068 decision: Some("block".to_string()),
1069 reason: Some("finish the remaining work".to_string()),
1070 })
1071 }
1072 }
1073
1074 let input = serde_json::json!({
1075 "sessionId": "sess-1",
1076 "timestamp": 1234567890,
1077 "cwd": "/tmp",
1078 "stopReason": "end_turn",
1079 "transcriptPath": "/tmp/transcript.jsonl",
1080 "stop_hook_active": true
1081 });
1082 let result = dispatch_hook(
1083 &AgentStopHooks,
1084 &SessionId::new("sess-1"),
1085 "agentStop",
1086 input,
1087 )
1088 .await
1089 .unwrap();
1090
1091 assert_eq!(result["output"]["decision"], "block");
1092 assert_eq!(result["output"]["reason"], "finish the remaining work");
1093 }
1094}