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
484#[non_exhaustive]
491pub enum HookCallback {
492 PreTurn(Box<dyn Fn(&PreTurnContext) + Send + Sync>),
494 PostTurn(Box<dyn Fn(&PostTurnContext) + Send + Sync>),
496 PreToolCallDecide(Box<dyn Fn(&PreToolCallDecideContext) -> HookResult + Send + Sync>),
498 PostToolCall(Box<dyn Fn(&PostToolCallContext) + Send + Sync>),
500 OnToolError(Box<dyn Fn(&OnToolErrorContext) + Send + Sync>),
502 OnSessionStart(Box<dyn Fn(&OnSessionStartContext) + Send + Sync>),
504 OnSessionEnd(Box<dyn Fn(&OnSessionEndContext) + Send + Sync>),
506 OnCompaction(Box<dyn Fn(&OnCompactionContext) + Send + Sync>),
508 OnInteraction(Box<dyn Fn(&OnInteractionContext) -> HookResult + Send + Sync>),
510 TransformToolInput(Box<TransformToolInputFn>),
518}
519
520impl HookCallback {
521 #[must_use]
523 pub(crate) const fn hook_point(&self) -> HookPoint {
524 match self {
525 Self::PreTurn(_) => HookPoint::PreTurn,
526 Self::PostTurn(_) => HookPoint::PostTurn,
527 Self::PreToolCallDecide(_) | Self::TransformToolInput(_) => {
528 HookPoint::PreToolCallDecide
529 }
530 Self::PostToolCall(_) => HookPoint::PostToolCall,
531 Self::OnToolError(_) => HookPoint::OnToolError,
532 Self::OnSessionStart(_) => HookPoint::OnSessionStart,
533 Self::OnSessionEnd(_) => HookPoint::OnSessionEnd,
534 Self::OnCompaction(_) => HookPoint::OnCompaction,
535 Self::OnInteraction(_) => HookPoint::OnInteraction,
536 }
537 }
538}
539
540impl std::fmt::Debug for HookCallback {
542 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
543 f.write_str("HookCallback::")?;
544 match self {
545 Self::TransformToolInput(_) => f.write_str("transform_tool_input"),
546 other => f.write_str(other.hook_point().label()),
547 }
548 }
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554
555 #[test]
556 fn hook_result_allow() {
557 let r = HookResult::allow();
558 assert!(r.allow);
559 assert!(r.message.is_empty());
560 }
561
562 #[test]
563 fn hook_result_deny() {
564 let r = HookResult::deny("blocked by policy");
565 assert!(!r.allow);
566 assert_eq!(r.message, "blocked by policy");
567 }
568
569 #[test]
570 fn hook_result_allow_with_message() {
571 let r = HookResult::allow_with_message("proceeding with caution");
572 assert!(r.allow);
573 assert_eq!(r.message, "proceeding with caution");
574 }
575
576 #[test]
577 fn hook_point_labels() {
578 assert_eq!(HookPoint::PreTurn.label(), "pre_turn");
579 assert_eq!(HookPoint::PostTurn.label(), "post_turn");
580 assert_eq!(HookPoint::PreToolCallDecide.label(), "pre_tool_call_decide");
581 assert_eq!(HookPoint::PostToolCall.label(), "post_tool_call");
582 assert_eq!(HookPoint::OnCompaction.label(), "on_compaction");
583 assert_eq!(HookPoint::OnSessionStart.label(), "on_session_start");
584 assert_eq!(HookPoint::OnSessionEnd.label(), "on_session_end");
585 assert_eq!(HookPoint::OnToolError.label(), "on_tool_error");
586 assert_eq!(HookPoint::OnInteraction.label(), "on_interaction");
587 }
588
589 #[test]
590 fn hooks_fire_in_correct_order() {
591 let mut set = HookSet::new();
592 assert!(set.is_empty());
593
594 set.push(HookEntry {
595 name: "pre_turn_1".to_owned(),
596 point: HookPoint::PreTurn,
597 callback_id: "cb_pre1".to_owned(),
598 })
599 .unwrap();
600 set.push(HookEntry {
601 name: "pre_tool_decide".to_owned(),
602 point: HookPoint::PreToolCallDecide,
603 callback_id: "cb_decide".to_owned(),
604 })
605 .unwrap();
606 set.push(HookEntry {
607 name: "pre_turn_2".to_owned(),
608 point: HookPoint::PreTurn,
609 callback_id: "cb_pre2".to_owned(),
610 })
611 .unwrap();
612 set.push(HookEntry {
613 name: "post_turn_1".to_owned(),
614 point: HookPoint::PostTurn,
615 callback_id: "cb_post1".to_owned(),
616 })
617 .unwrap();
618 set.push(HookEntry {
619 name: "post_tool_1".to_owned(),
620 point: HookPoint::PostToolCall,
621 callback_id: "cb_posttool1".to_owned(),
622 })
623 .unwrap();
624
625 assert_eq!(set.len(), 5);
626
627 let pre_turn: Vec<&str> = set
628 .at_point(HookPoint::PreTurn)
629 .map(|e| e.name.as_str())
630 .collect();
631 assert_eq!(pre_turn, vec!["pre_turn_1", "pre_turn_2"]);
632
633 let decide: Vec<&str> = set
634 .at_point(HookPoint::PreToolCallDecide)
635 .map(|e| e.name.as_str())
636 .collect();
637 assert_eq!(decide, vec!["pre_tool_decide"]);
638
639 let post_turn: Vec<&str> = set
640 .at_point(HookPoint::PostTurn)
641 .map(|e| e.name.as_str())
642 .collect();
643 assert_eq!(post_turn, vec!["post_turn_1"]);
644
645 let post_tool: Vec<&str> = set
646 .at_point(HookPoint::PostToolCall)
647 .map(|e| e.name.as_str())
648 .collect();
649 assert_eq!(post_tool, vec!["post_tool_1"]);
650 }
651
652 #[test]
653 fn hook_entry_serde_roundtrip() {
654 let entry = HookEntry {
655 name: "my_hook".to_owned(),
656 point: HookPoint::PreToolCallDecide,
657 callback_id: "cb_123".to_owned(),
658 };
659 let json = serde_json::to_string(&entry).expect("serialize");
660 let parsed: HookEntry = serde_json::from_str(&json).expect("deserialize");
661 assert_eq!(parsed.name, entry.name);
662 assert_eq!(parsed.point, entry.point);
663 assert_eq!(parsed.callback_id, entry.callback_id);
664 }
665
666 #[test]
667 fn hook_result_serde_roundtrip() {
668 let results = vec![
669 HookResult::allow(),
670 HookResult::deny("reason"),
671 HookResult::allow_with_message("ok"),
672 ];
673 for result in &results {
674 let json = serde_json::to_string(result).expect("serialize");
675 let parsed: HookResult = serde_json::from_str(&json).expect("deserialize");
676 assert_eq!(&parsed, result);
677 }
678 }
679
680 #[test]
681 fn hook_set_serde_roundtrip() {
682 let mut set = HookSet::new();
683 set.push(HookEntry {
684 name: "gate".to_owned(),
685 point: HookPoint::PreTurn,
686 callback_id: "cb_1".to_owned(),
687 })
688 .unwrap();
689 set.push(HookEntry {
690 name: "logger".to_owned(),
691 point: HookPoint::PostToolCall,
692 callback_id: "cb_2".to_owned(),
693 })
694 .unwrap();
695 let json = serde_json::to_string(&set).expect("serialize");
696 let parsed: HookSet = serde_json::from_str(&json).expect("deserialize");
697 assert_eq!(parsed.len(), 2);
698 let names: Vec<&str> = parsed.iter().map(|e| e.name.as_str()).collect();
699 assert_eq!(names, vec!["gate", "logger"]);
700 }
701
702 #[test]
703 fn hook_set_from_conversions() {
704 let mut set = HookSet::new();
705 set.push(HookEntry {
706 name: "gate".to_owned(),
707 point: HookPoint::PreTurn,
708 callback_id: "cb_1".to_owned(),
709 })
710 .unwrap();
711
712 let vec_from_owned: Vec<HookEntry> = Vec::from(set.clone());
713 assert_eq!(vec_from_owned.len(), 1);
714 assert_eq!(vec_from_owned[0].name, "gate");
715
716 let vec_from_ref: Vec<HookEntry> = Vec::from(&set);
717 assert_eq!(vec_from_ref.len(), 1);
718 assert_eq!(vec_from_ref[0].name, "gate");
719
720 let entry = HookEntry {
721 name: "gate".to_owned(),
722 point: HookPoint::PreTurn,
723 callback_id: "cb_1".to_owned(),
724 };
725 let set_from_arr = HookSet::from([entry.clone()]);
726 assert_eq!(set_from_arr.len(), 1);
727
728 let set_from_vec = HookSet::from(vec![entry]);
729 assert_eq!(set_from_vec.len(), 1);
730 }
731
732 #[test]
733 fn empty_hook_set_iteration_at_each_point() {
734 let set = HookSet::new();
735 for point in [
736 HookPoint::PreTurn,
737 HookPoint::PostTurn,
738 HookPoint::PreToolCallDecide,
739 HookPoint::PostToolCall,
740 HookPoint::OnCompaction,
741 HookPoint::OnSessionStart,
742 HookPoint::OnSessionEnd,
743 HookPoint::OnToolError,
744 HookPoint::OnInteraction,
745 ] {
746 assert_eq!(
747 set.at_point(point).count(),
748 0,
749 "Empty HookSet should have 0 hooks at {point:?}"
750 );
751 }
752 }
753
754 #[test]
755 fn hook_point_serde_roundtrip() {
756 let points = [
757 HookPoint::PreTurn,
758 HookPoint::PostTurn,
759 HookPoint::PreToolCallDecide,
760 HookPoint::PostToolCall,
761 HookPoint::OnCompaction,
762 HookPoint::OnSessionStart,
763 HookPoint::OnSessionEnd,
764 HookPoint::OnToolError,
765 HookPoint::OnInteraction,
766 ];
767 for point in points {
768 let json = serde_json::to_string(&point).expect("serialize");
769 let parsed: HookPoint = serde_json::from_str(&json).expect("deserialize");
770 assert_eq!(parsed, point);
771 }
772 }
773
774 #[test]
775 fn hook_set_default_is_empty() {
776 let set = HookSet::default();
777 assert!(set.is_empty());
778 assert_eq!(set.len(), 0);
779 }
780
781 #[test]
782 fn hook_set_multiple_hooks_at_same_point() {
783 let mut set = HookSet::new();
784 for i in 0..5 {
785 set.push(HookEntry {
786 name: format!("hook_{i}"),
787 point: HookPoint::PreToolCallDecide,
788 callback_id: format!("cb_{i}"),
789 })
790 .unwrap();
791 }
792 assert_eq!(set.len(), 5);
793 assert_eq!(set.at_point(HookPoint::PreToolCallDecide).count(), 5);
794 assert_eq!(set.at_point(HookPoint::PreTurn).count(), 0);
795 }
796
797 #[test]
798 fn hook_result_deny_with_string_owned() {
799 let reason = String::from("policy violation detected");
800 let r = HookResult::deny(reason.clone());
801 assert!(!r.allow);
802 assert_eq!(r.message, reason);
803 }
804
805 #[test]
806 fn hook_entry_with_new_hook_points() {
807 let new_points = [
808 (HookPoint::OnCompaction, "compaction_hook"),
809 (HookPoint::OnSessionStart, "session_start_hook"),
810 (HookPoint::OnSessionEnd, "session_end_hook"),
811 (HookPoint::OnToolError, "tool_error_hook"),
812 (HookPoint::OnInteraction, "interaction_hook"),
813 ];
814 let mut set = HookSet::new();
815 for (point, name) in &new_points {
816 set.push(HookEntry {
817 name: (*name).to_owned(),
818 point: *point,
819 callback_id: format!("cb_{name}"),
820 })
821 .unwrap();
822 }
823 assert_eq!(set.len(), 5);
824 for (point, name) in &new_points {
825 let hooks: Vec<&str> = set.at_point(*point).map(|e| e.name.as_str()).collect();
826 assert_eq!(hooks, vec![*name], "expected hook at {point:?}");
827 }
828 }
829
830 #[test]
831 fn hook_entry_serde_roundtrip_new_points() {
832 let new_points = [
833 HookPoint::OnCompaction,
834 HookPoint::OnSessionStart,
835 HookPoint::OnSessionEnd,
836 HookPoint::OnToolError,
837 HookPoint::OnInteraction,
838 ];
839 for point in new_points {
840 let entry = HookEntry {
841 name: format!("test_{}", point.label()),
842 point,
843 callback_id: format!("cb_{}", point.label()),
844 };
845 let json = serde_json::to_string(&entry).expect("serialize");
846 let parsed: HookEntry = serde_json::from_str(&json).expect("deserialize");
847 assert_eq!(parsed.name, entry.name);
848 assert_eq!(parsed.point, entry.point);
849 assert_eq!(parsed.callback_id, entry.callback_id);
850 }
851 }
852
853 #[test]
856 fn session_context_clone() {
857 let ctx = SessionContext {
858 session_id: "sess-1".into(),
859 agent_id: 42,
860 started_at: SystemTime::now(),
861 };
862 let cloned = ctx;
863 assert_eq!(cloned.session_id, "sess-1");
864 assert_eq!(cloned.agent_id, 42);
865 }
866
867 #[test]
868 fn session_context_debug_format() {
869 let ctx = SessionContext {
870 session_id: "sess-debug".into(),
871 agent_id: 1,
872 started_at: SystemTime::now(),
873 };
874 let dbg = format!("{ctx:?}");
875 assert!(dbg.contains("sess-debug"));
876 assert!(dbg.contains("agent_id: 1"));
877 }
878
879 #[test]
880 fn session_context_serde_roundtrip_preserves_started_at() {
881 let original = SessionContext {
882 session_id: "sess-rt".into(),
883 agent_id: 99,
884 started_at: SystemTime::now(),
885 };
886 let json = serde_json::to_string(&original).expect("serialize");
887 let parsed: SessionContext = serde_json::from_str(&json).expect("deserialize");
888
889 assert_eq!(parsed.session_id, original.session_id);
890 assert_eq!(parsed.agent_id, original.agent_id);
891 assert_eq!(parsed.started_at, original.started_at);
893 }
894
895 #[test]
898 fn hook_entry_new_valid() {
899 let entry = HookEntry::new("safety_gate", HookPoint::PreToolCallDecide, "cb_safety")
900 .expect("valid entry");
901 assert_eq!(entry.name, "safety_gate");
902 assert_eq!(entry.point, HookPoint::PreToolCallDecide);
903 assert_eq!(entry.callback_id, "cb_safety");
904 }
905
906 #[test]
907 fn hook_entry_new_rejects_empty_name() {
908 let result = HookEntry::new("", HookPoint::PreTurn, "cb_1");
909 assert!(result.is_err(), "should reject empty name");
910 }
911
912 #[test]
913 fn hook_entry_new_rejects_whitespace_name() {
914 let result = HookEntry::new(" ", HookPoint::PreTurn, "cb_1");
915 assert!(result.is_err(), "should reject whitespace-only name");
916 }
917
918 #[test]
919 fn hook_entry_new_rejects_empty_callback_id() {
920 let result = HookEntry::new("my_hook", HookPoint::PreTurn, "");
921 assert!(result.is_err(), "should reject empty callback_id");
922 }
923
924 #[test]
925 fn hook_entry_new_rejects_whitespace_callback_id() {
926 let result = HookEntry::new("my_hook", HookPoint::PostTurn, " ");
927 assert!(result.is_err(), "should reject whitespace-only callback_id");
928 }
929
930 #[test]
931 fn pre_tool_call_decide_context_serde_aliases() {
932 let json_std = r#"{"tool_name":"my_tool","tool_args":{"foo":"bar"}}"#;
933 let parsed_std: PreToolCallDecideContext = serde_json::from_str(json_std).unwrap();
934 assert_eq!(parsed_std.tool_name, "my_tool");
935 assert_eq!(parsed_std.tool_args["foo"], "bar");
936
937 let json_alias = r#"{"name":"my_tool","args":{"foo":"bar"}}"#;
938 let parsed_alias: PreToolCallDecideContext = serde_json::from_str(json_alias).unwrap();
939 assert_eq!(parsed_alias.tool_name, "my_tool");
940 assert_eq!(parsed_alias.tool_args["foo"], "bar");
941 }
942
943 #[test]
944 fn pre_tool_call_decide_context_serde_default() {
945 let json_no_args = r#"{"name":"my_tool"}"#;
946 let parsed_no_args: PreToolCallDecideContext = serde_json::from_str(json_no_args).unwrap();
947 assert_eq!(parsed_no_args.tool_name, "my_tool");
948 assert_eq!(parsed_no_args.tool_args, serde_json::Value::Null);
949 }
950
951 #[test]
952 fn post_tool_call_context_serde_aliases_and_default() {
953 let json_std = r#"{"tool_name":"my_tool","tool_args":{"foo":"bar"},"result":"success"}"#;
954 let parsed_std: PostToolCallContext = serde_json::from_str(json_std).unwrap();
955 assert_eq!(parsed_std.tool_name, "my_tool");
956 assert_eq!(parsed_std.tool_args["foo"], "bar");
957 assert_eq!(parsed_std.result, "success");
958
959 let json_alias = r#"{"name":"my_tool","args":{"foo":"bar"},"result":"success"}"#;
960 let parsed_alias: PostToolCallContext = serde_json::from_str(json_alias).unwrap();
961 assert_eq!(parsed_alias.tool_name, "my_tool");
962 assert_eq!(parsed_alias.tool_args["foo"], "bar");
963 assert_eq!(parsed_alias.result, "success");
964
965 let json_no_args = r#"{"name":"my_tool","result":"success"}"#;
966 let parsed_no_args: PostToolCallContext = serde_json::from_str(json_no_args).unwrap();
967 assert_eq!(parsed_no_args.tool_name, "my_tool");
968 assert_eq!(parsed_no_args.tool_args, serde_json::Value::Null);
969 assert_eq!(parsed_no_args.result, "success");
970 }
971
972 #[test]
973 fn on_tool_error_context_serde_aliases_and_default() {
974 let json_std = r#"{"tool_name":"my_tool","tool_args":{"foo":"bar"},"error":"failed"}"#;
975 let parsed_std: OnToolErrorContext = serde_json::from_str(json_std).unwrap();
976 assert_eq!(parsed_std.tool_name, "my_tool");
977 assert_eq!(parsed_std.tool_args["foo"], "bar");
978 assert_eq!(parsed_std.error, "failed");
979
980 let json_alias = r#"{"name":"my_tool","args":{"foo":"bar"},"error":"failed"}"#;
981 let parsed_alias: OnToolErrorContext = serde_json::from_str(json_alias).unwrap();
982 assert_eq!(parsed_alias.tool_name, "my_tool");
983 assert_eq!(parsed_alias.tool_args["foo"], "bar");
984 assert_eq!(parsed_alias.error, "failed");
985
986 let json_no_args = r#"{"name":"my_tool","error":"failed"}"#;
987 let parsed_no_args: OnToolErrorContext = serde_json::from_str(json_no_args).unwrap();
988 assert_eq!(parsed_no_args.tool_name, "my_tool");
989 assert_eq!(parsed_no_args.tool_args, serde_json::Value::Null);
990 assert_eq!(parsed_no_args.error, "failed");
991
992 let json_no_name = r#"{"error":"failed"}"#;
993 let parsed_no_name: Result<OnToolErrorContext, _> = serde_json::from_str(json_no_name);
994 assert!(parsed_no_name.is_err());
995 }
996
997 #[test]
998 fn on_tool_error_context_metadata_defaults_to_null() {
999 let json = r#"{"tool_name":"my_tool","error":"failed"}"#;
1000 let parsed: OnToolErrorContext = serde_json::from_str(json).unwrap();
1001 assert_eq!(parsed.metadata, serde_json::Value::Null);
1002 assert!(!parsed.is_not_found());
1003 }
1004
1005 #[test]
1006 fn on_tool_error_context_metadata_deserialized() {
1007 let json = r#"{"tool_name":"my_tool","error":"failed","metadata":{"status_code":503}}"#;
1008 let parsed: OnToolErrorContext = serde_json::from_str(json).unwrap();
1009 assert_eq!(parsed.metadata["status_code"], 503);
1010 }
1011
1012 #[test]
1013 fn on_tool_error_context_is_not_found_detects_registry_miss() {
1014 let error = llm_tool::ToolError::not_found(llm_tool::RegistryItem::Tool, "add_nummbers");
1016 let ctx = OnToolErrorContext {
1017 tool_name: "add_nummbers".into(),
1018 tool_args: serde_json::Value::Null,
1019 error: error.to_string(),
1020 metadata: serde_json::to_value(error.metadata()).unwrap(),
1021 };
1022 assert!(ctx.is_not_found());
1023 }
1024
1025 #[test]
1026 fn on_tool_error_context_is_not_found_false_for_generic_error() {
1027 let ctx = OnToolErrorContext {
1028 tool_name: "t".into(),
1029 tool_args: serde_json::Value::Null,
1030 error: "handler blew up".into(),
1031 metadata: serde_json::json!({"some": "value"}),
1032 };
1033 assert!(!ctx.is_not_found());
1034 }
1035}