1use std::time::SystemTime;
12
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct HookResult {
18 pub allow: bool,
20 pub message: String,
22}
23
24impl HookResult {
25 #[must_use]
27 pub const fn allow() -> Self {
28 Self {
29 allow: true,
30 message: String::new(),
31 }
32 }
33
34 #[must_use]
36 pub fn allow_with_message(message: impl Into<String>) -> Self {
37 Self {
38 allow: true,
39 message: message.into(),
40 }
41 }
42
43 #[must_use]
45 pub fn deny(reason: impl Into<String>) -> Self {
46 Self {
47 allow: false,
48 message: reason.into(),
49 }
50 }
51}
52
53#[non_exhaustive]
61#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
62pub struct SessionContext {
63 pub session_id: String,
65 pub agent_id: u64,
67 #[serde(default = "SessionContext::default_started_at")]
72 pub started_at: SystemTime,
73}
74
75impl SessionContext {
76 fn default_started_at() -> SystemTime {
78 SystemTime::UNIX_EPOCH
79 }
80}
81
82#[non_exhaustive]
84#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
85pub struct OnSessionStartContext {
86 pub session: SessionContext,
88}
89
90#[non_exhaustive]
92#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
93pub struct OnSessionEndContext {
94 pub session: SessionContext,
96}
97
98#[non_exhaustive]
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct OnCompactionContext {}
102
103#[non_exhaustive]
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct OnInteractionContext {
107 pub message: String,
109}
110
111#[non_exhaustive]
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct PreTurnContext {
115 pub prompt: String,
117 pub turn_number: u32,
119}
120
121impl PreTurnContext {
122 #[must_use]
124 pub fn new(prompt: impl Into<String>, turn_number: u32) -> Self {
125 Self {
126 prompt: prompt.into(),
127 turn_number,
128 }
129 }
130}
131
132#[non_exhaustive]
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct PostTurnContext {
136 pub response_text: String,
138 pub turn_number: u32,
140}
141
142#[non_exhaustive]
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct PreToolCallDecideContext {
146 #[serde(alias = "name")]
148 pub tool_name: String,
149 #[serde(alias = "args", default)]
151 pub tool_args: serde_json::Value,
152}
153
154impl PreToolCallDecideContext {
155 #[must_use]
157 pub fn new(tool_name: impl Into<String>, tool_args: serde_json::Value) -> Self {
158 Self {
159 tool_name: tool_name.into(),
160 tool_args,
161 }
162 }
163}
164
165#[non_exhaustive]
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct PostToolCallContext {
169 #[serde(alias = "name")]
171 pub tool_name: String,
172 #[serde(alias = "args", default)]
174 pub tool_args: serde_json::Value,
175 pub result: String,
177 #[serde(default)]
179 pub metadata: serde_json::Value,
180}
181
182#[non_exhaustive]
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct OnToolErrorContext {
205 #[serde(alias = "name")]
207 pub tool_name: String,
208 #[serde(alias = "args", default)]
210 pub tool_args: serde_json::Value,
211 pub error: String,
214 #[serde(default)]
223 pub metadata: serde_json::Value,
224}
225
226impl OnToolErrorContext {
227 #[must_use]
237 pub fn is_not_found(&self) -> bool {
238 self.metadata
239 .get(llm_tool::ToolError::ERROR_KIND_KEY)
240 .and_then(serde_json::Value::as_str)
241 == Some(llm_tool::ToolError::KIND_NOT_REGISTERED)
242 }
243}
244
245#[non_exhaustive]
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
248pub enum HookPoint {
249 PreTurn,
251 PostTurn,
253 PreToolCallDecide,
255 PostToolCall,
257 OnCompaction,
259 OnSessionStart,
261 OnSessionEnd,
263 OnToolError,
265 OnInteraction,
267}
268
269impl HookPoint {
270 #[must_use]
272 pub const fn label(self) -> &'static str {
273 match self {
274 Self::PreTurn => "pre_turn",
275 Self::PostTurn => "post_turn",
276 Self::PreToolCallDecide => "pre_tool_call_decide",
277 Self::PostToolCall => "post_tool_call",
278 Self::OnCompaction => "on_compaction",
279 Self::OnSessionStart => "on_session_start",
280 Self::OnSessionEnd => "on_session_end",
281 Self::OnToolError => "on_tool_error",
282 Self::OnInteraction => "on_interaction",
283 }
284 }
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct HookEntry {
300 pub name: String,
302 pub point: HookPoint,
304 pub callback_id: String,
306}
307
308impl HookEntry {
309 pub fn new(
326 name: impl Into<String>,
327 point: HookPoint,
328 callback_id: impl Into<String>,
329 ) -> Result<Self, crate::error::Error> {
330 let entry = Self {
331 name: name.into(),
332 point,
333 callback_id: callback_id.into(),
334 };
335 entry.validate()?;
336 Ok(entry)
337 }
338
339 pub fn validate(&self) -> Result<(), crate::error::Error> {
345 if self.name.trim().is_empty() {
346 return Err(crate::error::Error::InvalidConfig {
347 message: "HookEntry name must not be empty".to_owned(),
348 });
349 }
350 if self.callback_id.trim().is_empty() {
351 return Err(crate::error::Error::InvalidConfig {
352 message: format!("HookEntry '{}' has an empty callback_id", self.name),
353 });
354 }
355 Ok(())
356 }
357}
358
359#[derive(Debug, Clone, Default, Serialize, Deserialize)]
363pub struct HookSet {
364 entries: Vec<HookEntry>,
365}
366
367impl HookSet {
368 #[must_use]
370 pub const fn new() -> Self {
371 Self {
372 entries: Vec::new(),
373 }
374 }
375
376 pub fn push(&mut self, entry: HookEntry) -> Result<(), crate::error::Error> {
385 entry.validate()?;
386 if let Some(pos) = self
387 .entries
388 .iter()
389 .position(|e| e.name == entry.name && e.point == entry.point)
390 {
391 tracing::warn!(
392 hook = %entry.name,
393 point = %entry.point.label(),
394 "duplicate hook name+point in HookSet — replacing previous entry"
395 );
396 self.entries[pos] = entry;
397 } else {
398 self.entries.push(entry);
399 }
400 Ok(())
401 }
402
403 pub fn at_point(&self, point: HookPoint) -> impl Iterator<Item = &HookEntry> {
405 self.entries.iter().filter(move |e| e.point == point)
406 }
407
408 pub fn iter(&self) -> impl Iterator<Item = &HookEntry> {
410 self.entries.iter()
411 }
412
413 #[must_use]
415 pub const fn len(&self) -> usize {
416 self.entries.len()
417 }
418
419 #[must_use]
421 pub const fn is_empty(&self) -> bool {
422 self.entries.is_empty()
423 }
424}
425
426impl From<HookSet> for Vec<HookEntry> {
427 fn from(set: HookSet) -> Self {
428 set.entries
429 }
430}
431
432impl From<&HookSet> for Vec<HookEntry> {
433 fn from(set: &HookSet) -> Self {
434 set.entries.clone()
435 }
436}
437
438impl IntoIterator for HookSet {
439 type Item = HookEntry;
440 type IntoIter = std::vec::IntoIter<Self::Item>;
441
442 fn into_iter(self) -> Self::IntoIter {
443 self.entries.into_iter()
444 }
445}
446
447impl FromIterator<HookEntry> for HookSet {
448 fn from_iter<T: IntoIterator<Item = HookEntry>>(iter: T) -> Self {
449 let mut set = Self::new();
450 for entry in iter {
451 let name = entry.name.clone();
452 if let Err(e) = set.push(entry) {
453 tracing::error!(
454 error = %e,
455 hook = %name,
456 "Failed to push hook entry during from_iter"
457 );
458 }
459 }
460 set
461 }
462}
463
464impl From<Vec<HookEntry>> for HookSet {
465 fn from(entries: Vec<HookEntry>) -> Self {
466 Self::from_iter(entries)
467 }
468}
469
470impl<const N: usize> From<[HookEntry; N]> for HookSet {
471 fn from(entries: [HookEntry; N]) -> Self {
472 Self::from_iter(entries)
473 }
474}
475type TransformToolInputFn =
482 dyn Fn(&PreToolCallDecideContext) -> Option<serde_json::Value> + Send + Sync;
483
484type OnToolErrorFn = dyn Fn(&OnToolErrorContext) -> Option<String> + Send + Sync;
489
490#[non_exhaustive]
501pub enum HookCallback {
502 PreTurn(Box<dyn Fn(&PreTurnContext) -> HookResult + Send + Sync>),
507 PostTurn(Box<dyn Fn(&PostTurnContext) + Send + Sync>),
509 PreToolCallDecide(Box<dyn Fn(&PreToolCallDecideContext) -> HookResult + Send + Sync>),
511 PostToolCall(Box<dyn Fn(&PostToolCallContext) + Send + Sync>),
513 OnToolError(Box<OnToolErrorFn>),
519 OnSessionStart(Box<dyn Fn(&OnSessionStartContext) + Send + Sync>),
521 OnSessionEnd(Box<dyn Fn(&OnSessionEndContext) + Send + Sync>),
523 OnCompaction(Box<dyn Fn(&OnCompactionContext) + Send + Sync>),
525 OnInteraction(Box<dyn Fn(&OnInteractionContext) -> HookResult + Send + Sync>),
527 TransformToolInput(Box<TransformToolInputFn>),
535}
536
537impl HookCallback {
538 #[must_use]
540 pub(crate) const fn hook_point(&self) -> HookPoint {
541 match self {
542 Self::PreTurn(_) => HookPoint::PreTurn,
543 Self::PostTurn(_) => HookPoint::PostTurn,
544 Self::PreToolCallDecide(_) | Self::TransformToolInput(_) => {
545 HookPoint::PreToolCallDecide
546 }
547 Self::PostToolCall(_) => HookPoint::PostToolCall,
548 Self::OnToolError(_) => HookPoint::OnToolError,
549 Self::OnSessionStart(_) => HookPoint::OnSessionStart,
550 Self::OnSessionEnd(_) => HookPoint::OnSessionEnd,
551 Self::OnCompaction(_) => HookPoint::OnCompaction,
552 Self::OnInteraction(_) => HookPoint::OnInteraction,
553 }
554 }
555}
556
557impl std::fmt::Debug for HookCallback {
559 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
560 f.write_str("HookCallback::")?;
561 match self {
562 Self::TransformToolInput(_) => f.write_str("transform_tool_input"),
563 other => f.write_str(other.hook_point().label()),
564 }
565 }
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571
572 #[test]
573 fn hook_result_allow() {
574 let r = HookResult::allow();
575 assert!(r.allow);
576 assert!(r.message.is_empty());
577 }
578
579 #[test]
580 fn hook_result_deny() {
581 let r = HookResult::deny("blocked by policy");
582 assert!(!r.allow);
583 assert_eq!(r.message, "blocked by policy");
584 }
585
586 #[test]
587 fn hook_result_allow_with_message() {
588 let r = HookResult::allow_with_message("proceeding with caution");
589 assert!(r.allow);
590 assert_eq!(r.message, "proceeding with caution");
591 }
592
593 #[test]
594 fn hook_point_labels() {
595 assert_eq!(HookPoint::PreTurn.label(), "pre_turn");
596 assert_eq!(HookPoint::PostTurn.label(), "post_turn");
597 assert_eq!(HookPoint::PreToolCallDecide.label(), "pre_tool_call_decide");
598 assert_eq!(HookPoint::PostToolCall.label(), "post_tool_call");
599 assert_eq!(HookPoint::OnCompaction.label(), "on_compaction");
600 assert_eq!(HookPoint::OnSessionStart.label(), "on_session_start");
601 assert_eq!(HookPoint::OnSessionEnd.label(), "on_session_end");
602 assert_eq!(HookPoint::OnToolError.label(), "on_tool_error");
603 assert_eq!(HookPoint::OnInteraction.label(), "on_interaction");
604 }
605
606 #[test]
607 fn hooks_fire_in_correct_order() {
608 let mut set = HookSet::new();
609 assert!(set.is_empty());
610
611 set.push(HookEntry {
612 name: "pre_turn_1".to_owned(),
613 point: HookPoint::PreTurn,
614 callback_id: "cb_pre1".to_owned(),
615 })
616 .unwrap();
617 set.push(HookEntry {
618 name: "pre_tool_decide".to_owned(),
619 point: HookPoint::PreToolCallDecide,
620 callback_id: "cb_decide".to_owned(),
621 })
622 .unwrap();
623 set.push(HookEntry {
624 name: "pre_turn_2".to_owned(),
625 point: HookPoint::PreTurn,
626 callback_id: "cb_pre2".to_owned(),
627 })
628 .unwrap();
629 set.push(HookEntry {
630 name: "post_turn_1".to_owned(),
631 point: HookPoint::PostTurn,
632 callback_id: "cb_post1".to_owned(),
633 })
634 .unwrap();
635 set.push(HookEntry {
636 name: "post_tool_1".to_owned(),
637 point: HookPoint::PostToolCall,
638 callback_id: "cb_posttool1".to_owned(),
639 })
640 .unwrap();
641
642 assert_eq!(set.len(), 5);
643
644 let pre_turn: Vec<&str> = set
645 .at_point(HookPoint::PreTurn)
646 .map(|e| e.name.as_str())
647 .collect();
648 assert_eq!(pre_turn, vec!["pre_turn_1", "pre_turn_2"]);
649
650 let decide: Vec<&str> = set
651 .at_point(HookPoint::PreToolCallDecide)
652 .map(|e| e.name.as_str())
653 .collect();
654 assert_eq!(decide, vec!["pre_tool_decide"]);
655
656 let post_turn: Vec<&str> = set
657 .at_point(HookPoint::PostTurn)
658 .map(|e| e.name.as_str())
659 .collect();
660 assert_eq!(post_turn, vec!["post_turn_1"]);
661
662 let post_tool: Vec<&str> = set
663 .at_point(HookPoint::PostToolCall)
664 .map(|e| e.name.as_str())
665 .collect();
666 assert_eq!(post_tool, vec!["post_tool_1"]);
667 }
668
669 #[test]
670 fn hook_entry_serde_roundtrip() {
671 let entry = HookEntry {
672 name: "my_hook".to_owned(),
673 point: HookPoint::PreToolCallDecide,
674 callback_id: "cb_123".to_owned(),
675 };
676 let json = serde_json::to_string(&entry).expect("serialize");
677 let parsed: HookEntry = serde_json::from_str(&json).expect("deserialize");
678 assert_eq!(parsed.name, entry.name);
679 assert_eq!(parsed.point, entry.point);
680 assert_eq!(parsed.callback_id, entry.callback_id);
681 }
682
683 #[test]
684 fn hook_result_serde_roundtrip() {
685 let results = vec![
686 HookResult::allow(),
687 HookResult::deny("reason"),
688 HookResult::allow_with_message("ok"),
689 ];
690 for result in &results {
691 let json = serde_json::to_string(result).expect("serialize");
692 let parsed: HookResult = serde_json::from_str(&json).expect("deserialize");
693 assert_eq!(&parsed, result);
694 }
695 }
696
697 #[test]
698 fn hook_set_serde_roundtrip() {
699 let mut set = HookSet::new();
700 set.push(HookEntry {
701 name: "gate".to_owned(),
702 point: HookPoint::PreTurn,
703 callback_id: "cb_1".to_owned(),
704 })
705 .unwrap();
706 set.push(HookEntry {
707 name: "logger".to_owned(),
708 point: HookPoint::PostToolCall,
709 callback_id: "cb_2".to_owned(),
710 })
711 .unwrap();
712 let json = serde_json::to_string(&set).expect("serialize");
713 let parsed: HookSet = serde_json::from_str(&json).expect("deserialize");
714 assert_eq!(parsed.len(), 2);
715 let names: Vec<&str> = parsed.iter().map(|e| e.name.as_str()).collect();
716 assert_eq!(names, vec!["gate", "logger"]);
717 }
718
719 #[test]
720 fn hook_set_from_conversions() {
721 let mut set = HookSet::new();
722 set.push(HookEntry {
723 name: "gate".to_owned(),
724 point: HookPoint::PreTurn,
725 callback_id: "cb_1".to_owned(),
726 })
727 .unwrap();
728
729 let vec_from_owned: Vec<HookEntry> = Vec::from(set.clone());
730 assert_eq!(vec_from_owned.len(), 1);
731 assert_eq!(vec_from_owned[0].name, "gate");
732
733 let vec_from_ref: Vec<HookEntry> = Vec::from(&set);
734 assert_eq!(vec_from_ref.len(), 1);
735 assert_eq!(vec_from_ref[0].name, "gate");
736
737 let entry = HookEntry {
738 name: "gate".to_owned(),
739 point: HookPoint::PreTurn,
740 callback_id: "cb_1".to_owned(),
741 };
742 let set_from_arr = HookSet::from([entry.clone()]);
743 assert_eq!(set_from_arr.len(), 1);
744
745 let set_from_vec = HookSet::from(vec![entry]);
746 assert_eq!(set_from_vec.len(), 1);
747 }
748
749 #[test]
750 fn empty_hook_set_iteration_at_each_point() {
751 let set = HookSet::new();
752 for point in [
753 HookPoint::PreTurn,
754 HookPoint::PostTurn,
755 HookPoint::PreToolCallDecide,
756 HookPoint::PostToolCall,
757 HookPoint::OnCompaction,
758 HookPoint::OnSessionStart,
759 HookPoint::OnSessionEnd,
760 HookPoint::OnToolError,
761 HookPoint::OnInteraction,
762 ] {
763 assert_eq!(
764 set.at_point(point).count(),
765 0,
766 "Empty HookSet should have 0 hooks at {point:?}"
767 );
768 }
769 }
770
771 #[test]
772 fn hook_point_serde_roundtrip() {
773 let points = [
774 HookPoint::PreTurn,
775 HookPoint::PostTurn,
776 HookPoint::PreToolCallDecide,
777 HookPoint::PostToolCall,
778 HookPoint::OnCompaction,
779 HookPoint::OnSessionStart,
780 HookPoint::OnSessionEnd,
781 HookPoint::OnToolError,
782 HookPoint::OnInteraction,
783 ];
784 for point in points {
785 let json = serde_json::to_string(&point).expect("serialize");
786 let parsed: HookPoint = serde_json::from_str(&json).expect("deserialize");
787 assert_eq!(parsed, point);
788 }
789 }
790
791 #[test]
792 fn hook_set_default_is_empty() {
793 let set = HookSet::default();
794 assert!(set.is_empty());
795 assert_eq!(set.len(), 0);
796 }
797
798 #[test]
799 fn hook_set_multiple_hooks_at_same_point() {
800 let mut set = HookSet::new();
801 for i in 0..5 {
802 set.push(HookEntry {
803 name: format!("hook_{i}"),
804 point: HookPoint::PreToolCallDecide,
805 callback_id: format!("cb_{i}"),
806 })
807 .unwrap();
808 }
809 assert_eq!(set.len(), 5);
810 assert_eq!(set.at_point(HookPoint::PreToolCallDecide).count(), 5);
811 assert_eq!(set.at_point(HookPoint::PreTurn).count(), 0);
812 }
813
814 #[test]
815 fn hook_result_deny_with_string_owned() {
816 let reason = String::from("policy violation detected");
817 let r = HookResult::deny(reason.clone());
818 assert!(!r.allow);
819 assert_eq!(r.message, reason);
820 }
821
822 #[test]
823 fn hook_entry_with_new_hook_points() {
824 let new_points = [
825 (HookPoint::OnCompaction, "compaction_hook"),
826 (HookPoint::OnSessionStart, "session_start_hook"),
827 (HookPoint::OnSessionEnd, "session_end_hook"),
828 (HookPoint::OnToolError, "tool_error_hook"),
829 (HookPoint::OnInteraction, "interaction_hook"),
830 ];
831 let mut set = HookSet::new();
832 for (point, name) in &new_points {
833 set.push(HookEntry {
834 name: (*name).to_owned(),
835 point: *point,
836 callback_id: format!("cb_{name}"),
837 })
838 .unwrap();
839 }
840 assert_eq!(set.len(), 5);
841 for (point, name) in &new_points {
842 let hooks: Vec<&str> = set.at_point(*point).map(|e| e.name.as_str()).collect();
843 assert_eq!(hooks, vec![*name], "expected hook at {point:?}");
844 }
845 }
846
847 #[test]
848 fn hook_entry_serde_roundtrip_new_points() {
849 let new_points = [
850 HookPoint::OnCompaction,
851 HookPoint::OnSessionStart,
852 HookPoint::OnSessionEnd,
853 HookPoint::OnToolError,
854 HookPoint::OnInteraction,
855 ];
856 for point in new_points {
857 let entry = HookEntry {
858 name: format!("test_{}", point.label()),
859 point,
860 callback_id: format!("cb_{}", point.label()),
861 };
862 let json = serde_json::to_string(&entry).expect("serialize");
863 let parsed: HookEntry = serde_json::from_str(&json).expect("deserialize");
864 assert_eq!(parsed.name, entry.name);
865 assert_eq!(parsed.point, entry.point);
866 assert_eq!(parsed.callback_id, entry.callback_id);
867 }
868 }
869
870 #[test]
873 fn session_context_clone() {
874 let ctx = SessionContext {
875 session_id: "sess-1".into(),
876 agent_id: 42,
877 started_at: SystemTime::now(),
878 };
879 let cloned = ctx;
880 assert_eq!(cloned.session_id, "sess-1");
881 assert_eq!(cloned.agent_id, 42);
882 }
883
884 #[test]
885 fn session_context_debug_format() {
886 let ctx = SessionContext {
887 session_id: "sess-debug".into(),
888 agent_id: 1,
889 started_at: SystemTime::now(),
890 };
891 let dbg = format!("{ctx:?}");
892 assert!(dbg.contains("sess-debug"));
893 assert!(dbg.contains("agent_id: 1"));
894 }
895
896 #[test]
897 fn session_context_serde_roundtrip_preserves_started_at() {
898 let original = SessionContext {
899 session_id: "sess-rt".into(),
900 agent_id: 99,
901 started_at: SystemTime::now(),
902 };
903 let json = serde_json::to_string(&original).expect("serialize");
904 let parsed: SessionContext = serde_json::from_str(&json).expect("deserialize");
905
906 assert_eq!(parsed.session_id, original.session_id);
907 assert_eq!(parsed.agent_id, original.agent_id);
908 assert_eq!(parsed.started_at, original.started_at);
910 }
911
912 #[test]
915 fn hook_entry_new_valid() {
916 let entry = HookEntry::new("safety_gate", HookPoint::PreToolCallDecide, "cb_safety")
917 .expect("valid entry");
918 assert_eq!(entry.name, "safety_gate");
919 assert_eq!(entry.point, HookPoint::PreToolCallDecide);
920 assert_eq!(entry.callback_id, "cb_safety");
921 }
922
923 #[test]
924 fn hook_entry_new_rejects_empty_name() {
925 let result = HookEntry::new("", HookPoint::PreTurn, "cb_1");
926 assert!(result.is_err(), "should reject empty name");
927 }
928
929 #[test]
930 fn hook_entry_new_rejects_whitespace_name() {
931 let result = HookEntry::new(" ", HookPoint::PreTurn, "cb_1");
932 assert!(result.is_err(), "should reject whitespace-only name");
933 }
934
935 #[test]
936 fn hook_entry_new_rejects_empty_callback_id() {
937 let result = HookEntry::new("my_hook", HookPoint::PreTurn, "");
938 assert!(result.is_err(), "should reject empty callback_id");
939 }
940
941 #[test]
942 fn hook_entry_new_rejects_whitespace_callback_id() {
943 let result = HookEntry::new("my_hook", HookPoint::PostTurn, " ");
944 assert!(result.is_err(), "should reject whitespace-only callback_id");
945 }
946
947 #[test]
948 fn pre_tool_call_decide_context_serde_aliases() {
949 let json_std = r#"{"tool_name":"my_tool","tool_args":{"foo":"bar"}}"#;
950 let parsed_std: PreToolCallDecideContext = serde_json::from_str(json_std).unwrap();
951 assert_eq!(parsed_std.tool_name, "my_tool");
952 assert_eq!(parsed_std.tool_args["foo"], "bar");
953
954 let json_alias = r#"{"name":"my_tool","args":{"foo":"bar"}}"#;
955 let parsed_alias: PreToolCallDecideContext = serde_json::from_str(json_alias).unwrap();
956 assert_eq!(parsed_alias.tool_name, "my_tool");
957 assert_eq!(parsed_alias.tool_args["foo"], "bar");
958 }
959
960 #[test]
961 fn pre_tool_call_decide_context_serde_default() {
962 let json_no_args = r#"{"name":"my_tool"}"#;
963 let parsed_no_args: PreToolCallDecideContext = serde_json::from_str(json_no_args).unwrap();
964 assert_eq!(parsed_no_args.tool_name, "my_tool");
965 assert_eq!(parsed_no_args.tool_args, serde_json::Value::Null);
966 }
967
968 #[test]
969 fn post_tool_call_context_serde_aliases_and_default() {
970 let json_std = r#"{"tool_name":"my_tool","tool_args":{"foo":"bar"},"result":"success"}"#;
971 let parsed_std: PostToolCallContext = serde_json::from_str(json_std).unwrap();
972 assert_eq!(parsed_std.tool_name, "my_tool");
973 assert_eq!(parsed_std.tool_args["foo"], "bar");
974 assert_eq!(parsed_std.result, "success");
975
976 let json_alias = r#"{"name":"my_tool","args":{"foo":"bar"},"result":"success"}"#;
977 let parsed_alias: PostToolCallContext = serde_json::from_str(json_alias).unwrap();
978 assert_eq!(parsed_alias.tool_name, "my_tool");
979 assert_eq!(parsed_alias.tool_args["foo"], "bar");
980 assert_eq!(parsed_alias.result, "success");
981
982 let json_no_args = r#"{"name":"my_tool","result":"success"}"#;
983 let parsed_no_args: PostToolCallContext = serde_json::from_str(json_no_args).unwrap();
984 assert_eq!(parsed_no_args.tool_name, "my_tool");
985 assert_eq!(parsed_no_args.tool_args, serde_json::Value::Null);
986 assert_eq!(parsed_no_args.result, "success");
987 }
988
989 #[test]
990 fn on_tool_error_context_serde_aliases_and_default() {
991 let json_std = r#"{"tool_name":"my_tool","tool_args":{"foo":"bar"},"error":"failed"}"#;
992 let parsed_std: OnToolErrorContext = serde_json::from_str(json_std).unwrap();
993 assert_eq!(parsed_std.tool_name, "my_tool");
994 assert_eq!(parsed_std.tool_args["foo"], "bar");
995 assert_eq!(parsed_std.error, "failed");
996
997 let json_alias = r#"{"name":"my_tool","args":{"foo":"bar"},"error":"failed"}"#;
998 let parsed_alias: OnToolErrorContext = serde_json::from_str(json_alias).unwrap();
999 assert_eq!(parsed_alias.tool_name, "my_tool");
1000 assert_eq!(parsed_alias.tool_args["foo"], "bar");
1001 assert_eq!(parsed_alias.error, "failed");
1002
1003 let json_no_args = r#"{"name":"my_tool","error":"failed"}"#;
1004 let parsed_no_args: OnToolErrorContext = serde_json::from_str(json_no_args).unwrap();
1005 assert_eq!(parsed_no_args.tool_name, "my_tool");
1006 assert_eq!(parsed_no_args.tool_args, serde_json::Value::Null);
1007 assert_eq!(parsed_no_args.error, "failed");
1008
1009 let json_no_name = r#"{"error":"failed"}"#;
1010 let parsed_no_name: Result<OnToolErrorContext, _> = serde_json::from_str(json_no_name);
1011 assert!(parsed_no_name.is_err());
1012 }
1013
1014 #[test]
1015 fn on_tool_error_context_metadata_defaults_to_null() {
1016 let json = r#"{"tool_name":"my_tool","error":"failed"}"#;
1017 let parsed: OnToolErrorContext = serde_json::from_str(json).unwrap();
1018 assert_eq!(parsed.metadata, serde_json::Value::Null);
1019 assert!(!parsed.is_not_found());
1020 }
1021
1022 #[test]
1023 fn on_tool_error_context_metadata_deserialized() {
1024 let json = r#"{"tool_name":"my_tool","error":"failed","metadata":{"status_code":503}}"#;
1025 let parsed: OnToolErrorContext = serde_json::from_str(json).unwrap();
1026 assert_eq!(parsed.metadata["status_code"], 503);
1027 }
1028
1029 #[test]
1030 fn on_tool_error_context_is_not_found_detects_registry_miss() {
1031 let error = llm_tool::ToolError::not_found(llm_tool::RegistryItem::Tool, "add_nummbers");
1033 let ctx = OnToolErrorContext {
1034 tool_name: "add_nummbers".into(),
1035 tool_args: serde_json::Value::Null,
1036 error: error.to_string(),
1037 metadata: serde_json::to_value(error.metadata()).unwrap(),
1038 };
1039 assert!(ctx.is_not_found());
1040 }
1041
1042 #[test]
1043 fn on_tool_error_context_is_not_found_false_for_generic_error() {
1044 let ctx = OnToolErrorContext {
1045 tool_name: "t".into(),
1046 tool_args: serde_json::Value::Null,
1047 error: "handler blew up".into(),
1048 metadata: serde_json::json!({"some": "value"}),
1049 };
1050 assert!(!ctx.is_not_found());
1051 }
1052}