1use std::fmt;
5
6use zeph_common::ToolName;
7
8#[derive(Debug, Clone)]
13pub struct DiffData {
14 pub file_path: String,
16 pub old_content: String,
18 pub new_content: String,
20}
21
22#[derive(Debug, Clone)]
45pub struct ToolCall {
46 pub tool_id: ToolName,
48 pub params: serde_json::Map<String, serde_json::Value>,
50 pub caller_id: Option<String>,
53}
54
55#[derive(Debug, Clone, Default)]
60pub struct FilterStats {
61 pub raw_chars: usize,
63 pub filtered_chars: usize,
65 pub raw_lines: usize,
67 pub filtered_lines: usize,
69 pub confidence: Option<crate::FilterConfidence>,
71 pub command: Option<String>,
73 pub kept_lines: Vec<usize>,
75}
76
77impl FilterStats {
78 #[must_use]
82 #[allow(clippy::cast_precision_loss)]
83 pub fn savings_pct(&self) -> f64 {
84 if self.raw_chars == 0 {
85 return 0.0;
86 }
87 (1.0 - self.filtered_chars as f64 / self.raw_chars as f64) * 100.0
88 }
89
90 #[must_use]
95 pub fn estimated_tokens_saved(&self) -> usize {
96 self.raw_chars.saturating_sub(self.filtered_chars) / 4
97 }
98
99 #[must_use]
118 pub fn format_inline(&self, tool_name: &str) -> String {
119 let cmd_label = self
120 .command
121 .as_deref()
122 .map(|c| {
123 let trimmed = c.trim();
124 if trimmed.len() > 60 {
125 format!(" `{}…`", &trimmed[..57])
126 } else {
127 format!(" `{trimmed}`")
128 }
129 })
130 .unwrap_or_default();
131 format!(
132 "[{tool_name}]{cmd_label} {} lines \u{2192} {} lines, {:.1}% filtered",
133 self.raw_lines,
134 self.filtered_lines,
135 self.savings_pct()
136 )
137 }
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
146#[serde(rename_all = "snake_case")]
147pub enum ClaimSource {
148 Shell,
150 FileSystem,
152 WebScrape,
154 Mcp,
156 A2a,
158 CodeSearch,
160 Diagnostics,
162 Memory,
164}
165
166#[derive(Debug, Clone)]
192pub struct ToolOutput {
193 pub tool_name: ToolName,
195 pub summary: String,
197 pub blocks_executed: u32,
199 pub filter_stats: Option<FilterStats>,
201 pub diff: Option<DiffData>,
203 pub streamed: bool,
205 pub terminal_id: Option<String>,
207 pub locations: Option<Vec<String>>,
209 pub raw_response: Option<serde_json::Value>,
211 pub claim_source: Option<ClaimSource>,
214}
215
216impl fmt::Display for ToolOutput {
217 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218 f.write_str(&self.summary)
219 }
220}
221
222pub const MAX_TOOL_OUTPUT_CHARS: usize = 30_000;
227
228#[must_use]
241pub fn truncate_tool_output(output: &str) -> String {
242 truncate_tool_output_at(output, MAX_TOOL_OUTPUT_CHARS)
243}
244
245#[must_use]
261pub fn truncate_tool_output_at(output: &str, max_chars: usize) -> String {
262 if output.len() <= max_chars {
263 return output.to_string();
264 }
265
266 let half = max_chars / 2;
267 let head_end = output.floor_char_boundary(half);
268 let tail_start = output.ceil_char_boundary(output.len() - half);
269 let head = &output[..head_end];
270 let tail = &output[tail_start..];
271 let truncated = output.len() - head_end - (output.len() - tail_start);
272
273 format!(
274 "{head}\n\n... [truncated {truncated} chars, showing first and last ~{half} chars] ...\n\n{tail}"
275 )
276}
277
278#[derive(Debug, Clone)]
283pub enum ToolEvent {
284 Started {
286 tool_name: ToolName,
287 command: String,
288 sandbox_profile: Option<String>,
290 },
291 OutputChunk {
293 tool_name: ToolName,
294 command: String,
295 chunk: String,
296 },
297 Completed {
299 tool_name: ToolName,
300 command: String,
301 output: String,
303 success: bool,
305 filter_stats: Option<FilterStats>,
306 diff: Option<DiffData>,
307 },
308 Rollback {
310 tool_name: ToolName,
311 command: String,
312 restored_count: usize,
314 deleted_count: usize,
316 },
317}
318
319pub type ToolEventTx = tokio::sync::mpsc::UnboundedSender<ToolEvent>;
324
325#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
330pub enum ErrorKind {
331 Transient,
332 Permanent,
333}
334
335impl std::fmt::Display for ErrorKind {
336 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337 match self {
338 Self::Transient => f.write_str("transient"),
339 Self::Permanent => f.write_str("permanent"),
340 }
341 }
342}
343
344#[derive(Debug, thiserror::Error)]
346pub enum ToolError {
347 #[error("command blocked by policy: {command}")]
348 Blocked { command: String },
349
350 #[error("path not allowed by sandbox: {path}")]
351 SandboxViolation { path: String },
352
353 #[error("command requires confirmation: {command}")]
354 ConfirmationRequired { command: String },
355
356 #[error("command timed out after {timeout_secs}s")]
357 Timeout { timeout_secs: u64 },
358
359 #[error("operation cancelled")]
360 Cancelled,
361
362 #[error("invalid tool parameters: {message}")]
363 InvalidParams { message: String },
364
365 #[error("execution failed: {0}")]
366 Execution(#[from] std::io::Error),
367
368 #[error("HTTP error {status}: {message}")]
373 Http { status: u16, message: String },
374
375 #[error("shell error (exit {exit_code}): {message}")]
381 Shell {
382 exit_code: i32,
383 category: crate::error_taxonomy::ToolErrorCategory,
384 message: String,
385 },
386
387 #[error("snapshot failed: {reason}")]
388 SnapshotFailed { reason: String },
389}
390
391impl ToolError {
392 #[must_use]
397 pub fn category(&self) -> crate::error_taxonomy::ToolErrorCategory {
398 use crate::error_taxonomy::{ToolErrorCategory, classify_http_status, classify_io_error};
399 match self {
400 Self::Blocked { .. } | Self::SandboxViolation { .. } => {
401 ToolErrorCategory::PolicyBlocked
402 }
403 Self::ConfirmationRequired { .. } => ToolErrorCategory::ConfirmationRequired,
404 Self::Timeout { .. } => ToolErrorCategory::Timeout,
405 Self::Cancelled => ToolErrorCategory::Cancelled,
406 Self::InvalidParams { .. } => ToolErrorCategory::InvalidParameters,
407 Self::Http { status, .. } => classify_http_status(*status),
408 Self::Execution(io_err) => classify_io_error(io_err),
409 Self::Shell { category, .. } => *category,
410 Self::SnapshotFailed { .. } => ToolErrorCategory::PermanentFailure,
411 }
412 }
413
414 #[must_use]
422 pub fn kind(&self) -> ErrorKind {
423 use crate::error_taxonomy::ToolErrorCategoryExt;
424 self.category().error_kind()
425 }
426}
427
428pub fn deserialize_params<T: serde::de::DeserializeOwned>(
434 params: &serde_json::Map<String, serde_json::Value>,
435) -> Result<T, ToolError> {
436 let obj = serde_json::Value::Object(params.clone());
437 serde_json::from_value(obj).map_err(|e| ToolError::InvalidParams {
438 message: e.to_string(),
439 })
440}
441
442pub trait ToolExecutor: Send + Sync {
506 fn execute(
515 &self,
516 response: &str,
517 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send;
518
519 fn execute_confirmed(
528 &self,
529 response: &str,
530 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
531 self.execute(response)
532 }
533
534 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
539 vec![]
540 }
541
542 fn execute_tool_call(
548 &self,
549 _call: &ToolCall,
550 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
551 std::future::ready(Ok(None))
552 }
553
554 fn execute_tool_call_confirmed(
563 &self,
564 call: &ToolCall,
565 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
566 self.execute_tool_call(call)
567 }
568
569 fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
574
575 fn set_effective_trust(&self, _level: crate::SkillTrustLevel) {}
579
580 fn is_tool_retryable(&self, _tool_id: &str) -> bool {
586 false
587 }
588
589 fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
616 false
617 }
618}
619
620pub trait ErasedToolExecutor: Send + Sync {
629 fn execute_erased<'a>(
630 &'a self,
631 response: &'a str,
632 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
633
634 fn execute_confirmed_erased<'a>(
635 &'a self,
636 response: &'a str,
637 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
638
639 fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef>;
640
641 fn execute_tool_call_erased<'a>(
642 &'a self,
643 call: &'a ToolCall,
644 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
645
646 fn execute_tool_call_confirmed_erased<'a>(
647 &'a self,
648 call: &'a ToolCall,
649 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
650 {
651 self.execute_tool_call_erased(call)
655 }
656
657 fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
659
660 fn set_effective_trust(&self, _level: crate::SkillTrustLevel) {}
662
663 fn is_tool_retryable_erased(&self, tool_id: &str) -> bool;
665
666 fn is_tool_speculatable_erased(&self, _tool_id: &str) -> bool {
670 false
671 }
672
673 fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
681 false
682 }
683}
684
685impl<T: ToolExecutor> ErasedToolExecutor for T {
686 fn execute_erased<'a>(
687 &'a self,
688 response: &'a str,
689 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
690 {
691 Box::pin(self.execute(response))
692 }
693
694 fn execute_confirmed_erased<'a>(
695 &'a self,
696 response: &'a str,
697 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
698 {
699 Box::pin(self.execute_confirmed(response))
700 }
701
702 fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef> {
703 self.tool_definitions()
704 }
705
706 fn execute_tool_call_erased<'a>(
707 &'a self,
708 call: &'a ToolCall,
709 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
710 {
711 Box::pin(self.execute_tool_call(call))
712 }
713
714 fn execute_tool_call_confirmed_erased<'a>(
715 &'a self,
716 call: &'a ToolCall,
717 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
718 {
719 Box::pin(self.execute_tool_call_confirmed(call))
720 }
721
722 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
723 ToolExecutor::set_skill_env(self, env);
724 }
725
726 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
727 ToolExecutor::set_effective_trust(self, level);
728 }
729
730 fn is_tool_retryable_erased(&self, tool_id: &str) -> bool {
731 ToolExecutor::is_tool_retryable(self, tool_id)
732 }
733
734 fn is_tool_speculatable_erased(&self, tool_id: &str) -> bool {
735 ToolExecutor::is_tool_speculatable(self, tool_id)
736 }
737}
738
739pub struct DynExecutor(pub std::sync::Arc<dyn ErasedToolExecutor>);
743
744impl ToolExecutor for DynExecutor {
745 fn execute(
746 &self,
747 response: &str,
748 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
749 let inner = std::sync::Arc::clone(&self.0);
751 let response = response.to_owned();
752 async move { inner.execute_erased(&response).await }
753 }
754
755 fn execute_confirmed(
756 &self,
757 response: &str,
758 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
759 let inner = std::sync::Arc::clone(&self.0);
760 let response = response.to_owned();
761 async move { inner.execute_confirmed_erased(&response).await }
762 }
763
764 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
765 self.0.tool_definitions_erased()
766 }
767
768 fn execute_tool_call(
769 &self,
770 call: &ToolCall,
771 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
772 let inner = std::sync::Arc::clone(&self.0);
773 let call = call.clone();
774 async move { inner.execute_tool_call_erased(&call).await }
775 }
776
777 fn execute_tool_call_confirmed(
778 &self,
779 call: &ToolCall,
780 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
781 let inner = std::sync::Arc::clone(&self.0);
782 let call = call.clone();
783 async move { inner.execute_tool_call_confirmed_erased(&call).await }
784 }
785
786 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
787 ErasedToolExecutor::set_skill_env(self.0.as_ref(), env);
788 }
789
790 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
791 ErasedToolExecutor::set_effective_trust(self.0.as_ref(), level);
792 }
793
794 fn is_tool_retryable(&self, tool_id: &str) -> bool {
795 self.0.is_tool_retryable_erased(tool_id)
796 }
797
798 fn is_tool_speculatable(&self, tool_id: &str) -> bool {
799 self.0.is_tool_speculatable_erased(tool_id)
800 }
801}
802
803#[must_use]
807pub fn extract_fenced_blocks<'a>(text: &'a str, lang: &str) -> Vec<&'a str> {
808 let marker = format!("```{lang}");
809 let marker_len = marker.len();
810 let mut blocks = Vec::new();
811 let mut rest = text;
812
813 let mut search_from = 0;
814 while let Some(rel) = rest[search_from..].find(&marker) {
815 let start = search_from + rel;
816 let after = &rest[start + marker_len..];
817 let boundary_ok = after
821 .chars()
822 .next()
823 .is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '-');
824 if !boundary_ok {
825 search_from = start + marker_len;
826 continue;
827 }
828 if let Some(end) = after.find("```") {
829 blocks.push(after[..end].trim());
830 rest = &after[end + 3..];
831 search_from = 0;
832 } else {
833 break;
834 }
835 }
836
837 blocks
838}
839
840#[cfg(test)]
841mod tests {
842 use super::*;
843
844 #[test]
845 fn tool_output_display() {
846 let output = ToolOutput {
847 tool_name: ToolName::new("bash"),
848 summary: "$ echo hello\nhello".to_owned(),
849 blocks_executed: 1,
850 filter_stats: None,
851 diff: None,
852 streamed: false,
853 terminal_id: None,
854 locations: None,
855 raw_response: None,
856 claim_source: None,
857 };
858 assert_eq!(output.to_string(), "$ echo hello\nhello");
859 }
860
861 #[test]
862 fn tool_error_blocked_display() {
863 let err = ToolError::Blocked {
864 command: "rm -rf /".to_owned(),
865 };
866 assert_eq!(err.to_string(), "command blocked by policy: rm -rf /");
867 }
868
869 #[test]
870 fn tool_error_sandbox_violation_display() {
871 let err = ToolError::SandboxViolation {
872 path: "/etc/shadow".to_owned(),
873 };
874 assert_eq!(err.to_string(), "path not allowed by sandbox: /etc/shadow");
875 }
876
877 #[test]
878 fn tool_error_confirmation_required_display() {
879 let err = ToolError::ConfirmationRequired {
880 command: "rm -rf /tmp".to_owned(),
881 };
882 assert_eq!(
883 err.to_string(),
884 "command requires confirmation: rm -rf /tmp"
885 );
886 }
887
888 #[test]
889 fn tool_error_timeout_display() {
890 let err = ToolError::Timeout { timeout_secs: 30 };
891 assert_eq!(err.to_string(), "command timed out after 30s");
892 }
893
894 #[test]
895 fn tool_error_invalid_params_display() {
896 let err = ToolError::InvalidParams {
897 message: "missing field `command`".to_owned(),
898 };
899 assert_eq!(
900 err.to_string(),
901 "invalid tool parameters: missing field `command`"
902 );
903 }
904
905 #[test]
906 fn deserialize_params_valid() {
907 #[derive(Debug, serde::Deserialize, PartialEq)]
908 struct P {
909 name: String,
910 count: u32,
911 }
912 let mut map = serde_json::Map::new();
913 map.insert("name".to_owned(), serde_json::json!("test"));
914 map.insert("count".to_owned(), serde_json::json!(42));
915 let p: P = deserialize_params(&map).unwrap();
916 assert_eq!(
917 p,
918 P {
919 name: "test".to_owned(),
920 count: 42
921 }
922 );
923 }
924
925 #[test]
926 fn deserialize_params_missing_required_field() {
927 #[derive(Debug, serde::Deserialize)]
928 #[allow(dead_code)]
929 struct P {
930 name: String,
931 }
932 let map = serde_json::Map::new();
933 let err = deserialize_params::<P>(&map).unwrap_err();
934 assert!(matches!(err, ToolError::InvalidParams { .. }));
935 }
936
937 #[test]
938 fn deserialize_params_wrong_type() {
939 #[derive(Debug, serde::Deserialize)]
940 #[allow(dead_code)]
941 struct P {
942 count: u32,
943 }
944 let mut map = serde_json::Map::new();
945 map.insert("count".to_owned(), serde_json::json!("not a number"));
946 let err = deserialize_params::<P>(&map).unwrap_err();
947 assert!(matches!(err, ToolError::InvalidParams { .. }));
948 }
949
950 #[test]
951 fn deserialize_params_all_optional_empty() {
952 #[derive(Debug, serde::Deserialize, PartialEq)]
953 struct P {
954 name: Option<String>,
955 }
956 let map = serde_json::Map::new();
957 let p: P = deserialize_params(&map).unwrap();
958 assert_eq!(p, P { name: None });
959 }
960
961 #[test]
962 fn deserialize_params_ignores_extra_fields() {
963 #[derive(Debug, serde::Deserialize, PartialEq)]
964 struct P {
965 name: String,
966 }
967 let mut map = serde_json::Map::new();
968 map.insert("name".to_owned(), serde_json::json!("test"));
969 map.insert("extra".to_owned(), serde_json::json!(true));
970 let p: P = deserialize_params(&map).unwrap();
971 assert_eq!(
972 p,
973 P {
974 name: "test".to_owned()
975 }
976 );
977 }
978
979 #[test]
980 fn tool_error_execution_display() {
981 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash not found");
982 let err = ToolError::Execution(io_err);
983 assert!(err.to_string().starts_with("execution failed:"));
984 assert!(err.to_string().contains("bash not found"));
985 }
986
987 #[test]
989 fn error_kind_timeout_is_transient() {
990 let err = ToolError::Timeout { timeout_secs: 30 };
991 assert_eq!(err.kind(), ErrorKind::Transient);
992 }
993
994 #[test]
995 fn error_kind_blocked_is_permanent() {
996 let err = ToolError::Blocked {
997 command: "rm -rf /".to_owned(),
998 };
999 assert_eq!(err.kind(), ErrorKind::Permanent);
1000 }
1001
1002 #[test]
1003 fn error_kind_sandbox_violation_is_permanent() {
1004 let err = ToolError::SandboxViolation {
1005 path: "/etc/shadow".to_owned(),
1006 };
1007 assert_eq!(err.kind(), ErrorKind::Permanent);
1008 }
1009
1010 #[test]
1011 fn error_kind_cancelled_is_permanent() {
1012 assert_eq!(ToolError::Cancelled.kind(), ErrorKind::Permanent);
1013 }
1014
1015 #[test]
1016 fn error_kind_invalid_params_is_permanent() {
1017 let err = ToolError::InvalidParams {
1018 message: "bad arg".to_owned(),
1019 };
1020 assert_eq!(err.kind(), ErrorKind::Permanent);
1021 }
1022
1023 #[test]
1024 fn error_kind_confirmation_required_is_permanent() {
1025 let err = ToolError::ConfirmationRequired {
1026 command: "rm /tmp/x".to_owned(),
1027 };
1028 assert_eq!(err.kind(), ErrorKind::Permanent);
1029 }
1030
1031 #[test]
1032 fn error_kind_execution_timed_out_is_transient() {
1033 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
1034 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1035 }
1036
1037 #[test]
1038 fn error_kind_execution_interrupted_is_transient() {
1039 let io_err = std::io::Error::new(std::io::ErrorKind::Interrupted, "interrupted");
1040 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1041 }
1042
1043 #[test]
1044 fn error_kind_execution_connection_reset_is_transient() {
1045 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
1046 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1047 }
1048
1049 #[test]
1050 fn error_kind_execution_broken_pipe_is_transient() {
1051 let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken");
1052 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1053 }
1054
1055 #[test]
1056 fn error_kind_execution_would_block_is_transient() {
1057 let io_err = std::io::Error::new(std::io::ErrorKind::WouldBlock, "would block");
1058 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1059 }
1060
1061 #[test]
1062 fn error_kind_execution_connection_aborted_is_transient() {
1063 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionAborted, "aborted");
1064 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1065 }
1066
1067 #[test]
1068 fn error_kind_execution_not_found_is_permanent() {
1069 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
1070 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1071 }
1072
1073 #[test]
1074 fn error_kind_execution_permission_denied_is_permanent() {
1075 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
1076 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1077 }
1078
1079 #[test]
1080 fn error_kind_execution_other_is_permanent() {
1081 let io_err = std::io::Error::other("some other error");
1082 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1083 }
1084
1085 #[test]
1086 fn error_kind_execution_already_exists_is_permanent() {
1087 let io_err = std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists");
1088 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1089 }
1090
1091 #[test]
1092 fn error_kind_display() {
1093 assert_eq!(ErrorKind::Transient.to_string(), "transient");
1094 assert_eq!(ErrorKind::Permanent.to_string(), "permanent");
1095 }
1096
1097 #[test]
1098 fn truncate_tool_output_short_passthrough() {
1099 let short = "hello world";
1100 assert_eq!(truncate_tool_output(short), short);
1101 }
1102
1103 #[test]
1104 fn truncate_tool_output_exact_limit() {
1105 let exact = "a".repeat(MAX_TOOL_OUTPUT_CHARS);
1106 assert_eq!(truncate_tool_output(&exact), exact);
1107 }
1108
1109 #[test]
1110 fn truncate_tool_output_long_split() {
1111 let long = "x".repeat(MAX_TOOL_OUTPUT_CHARS + 1000);
1112 let result = truncate_tool_output(&long);
1113 assert!(result.contains("truncated"));
1114 assert!(result.len() < long.len());
1115 }
1116
1117 #[test]
1118 fn truncate_tool_output_notice_contains_count() {
1119 let long = "y".repeat(MAX_TOOL_OUTPUT_CHARS + 2000);
1120 let result = truncate_tool_output(&long);
1121 assert!(result.contains("truncated"));
1122 assert!(result.contains("chars"));
1123 }
1124
1125 #[derive(Debug)]
1126 struct DefaultExecutor;
1127 impl ToolExecutor for DefaultExecutor {
1128 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
1129 Ok(None)
1130 }
1131 }
1132
1133 #[tokio::test]
1134 async fn execute_tool_call_default_returns_none() {
1135 let exec = DefaultExecutor;
1136 let call = ToolCall {
1137 tool_id: ToolName::new("anything"),
1138 params: serde_json::Map::new(),
1139 caller_id: None,
1140 };
1141 let result = exec.execute_tool_call(&call).await.unwrap();
1142 assert!(result.is_none());
1143 }
1144
1145 #[test]
1146 fn filter_stats_savings_pct() {
1147 let fs = FilterStats {
1148 raw_chars: 1000,
1149 filtered_chars: 200,
1150 ..Default::default()
1151 };
1152 assert!((fs.savings_pct() - 80.0).abs() < 0.01);
1153 }
1154
1155 #[test]
1156 fn filter_stats_savings_pct_zero() {
1157 let fs = FilterStats::default();
1158 assert!((fs.savings_pct()).abs() < 0.01);
1159 }
1160
1161 #[test]
1162 fn filter_stats_estimated_tokens_saved() {
1163 let fs = FilterStats {
1164 raw_chars: 1000,
1165 filtered_chars: 200,
1166 ..Default::default()
1167 };
1168 assert_eq!(fs.estimated_tokens_saved(), 200); }
1170
1171 #[test]
1172 fn filter_stats_format_inline() {
1173 let fs = FilterStats {
1174 raw_chars: 1000,
1175 filtered_chars: 200,
1176 raw_lines: 342,
1177 filtered_lines: 28,
1178 ..Default::default()
1179 };
1180 let line = fs.format_inline("shell");
1181 assert_eq!(line, "[shell] 342 lines \u{2192} 28 lines, 80.0% filtered");
1182 }
1183
1184 #[test]
1185 fn filter_stats_format_inline_zero() {
1186 let fs = FilterStats::default();
1187 let line = fs.format_inline("bash");
1188 assert_eq!(line, "[bash] 0 lines \u{2192} 0 lines, 0.0% filtered");
1189 }
1190
1191 struct FixedExecutor {
1194 tool_id: &'static str,
1195 output: &'static str,
1196 }
1197
1198 impl ToolExecutor for FixedExecutor {
1199 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
1200 Ok(Some(ToolOutput {
1201 tool_name: ToolName::new(self.tool_id),
1202 summary: self.output.to_owned(),
1203 blocks_executed: 1,
1204 filter_stats: None,
1205 diff: None,
1206 streamed: false,
1207 terminal_id: None,
1208 locations: None,
1209 raw_response: None,
1210 claim_source: None,
1211 }))
1212 }
1213
1214 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
1215 vec![]
1216 }
1217
1218 async fn execute_tool_call(
1219 &self,
1220 _call: &ToolCall,
1221 ) -> Result<Option<ToolOutput>, ToolError> {
1222 Ok(Some(ToolOutput {
1223 tool_name: ToolName::new(self.tool_id),
1224 summary: self.output.to_owned(),
1225 blocks_executed: 1,
1226 filter_stats: None,
1227 diff: None,
1228 streamed: false,
1229 terminal_id: None,
1230 locations: None,
1231 raw_response: None,
1232 claim_source: None,
1233 }))
1234 }
1235 }
1236
1237 #[tokio::test]
1238 async fn dyn_executor_execute_delegates() {
1239 let inner = std::sync::Arc::new(FixedExecutor {
1240 tool_id: "bash",
1241 output: "hello",
1242 });
1243 let exec = DynExecutor(inner);
1244 let result = exec.execute("```bash\necho hello\n```").await.unwrap();
1245 assert!(result.is_some());
1246 assert_eq!(result.unwrap().summary, "hello");
1247 }
1248
1249 #[tokio::test]
1250 async fn dyn_executor_execute_confirmed_delegates() {
1251 let inner = std::sync::Arc::new(FixedExecutor {
1252 tool_id: "bash",
1253 output: "confirmed",
1254 });
1255 let exec = DynExecutor(inner);
1256 let result = exec.execute_confirmed("...").await.unwrap();
1257 assert!(result.is_some());
1258 assert_eq!(result.unwrap().summary, "confirmed");
1259 }
1260
1261 #[test]
1262 fn dyn_executor_tool_definitions_delegates() {
1263 let inner = std::sync::Arc::new(FixedExecutor {
1264 tool_id: "my_tool",
1265 output: "",
1266 });
1267 let exec = DynExecutor(inner);
1268 let defs = exec.tool_definitions();
1270 assert!(defs.is_empty());
1271 }
1272
1273 #[tokio::test]
1274 async fn dyn_executor_execute_tool_call_delegates() {
1275 let inner = std::sync::Arc::new(FixedExecutor {
1276 tool_id: "bash",
1277 output: "tool_call_result",
1278 });
1279 let exec = DynExecutor(inner);
1280 let call = ToolCall {
1281 tool_id: ToolName::new("bash"),
1282 params: serde_json::Map::new(),
1283 caller_id: None,
1284 };
1285 let result = exec.execute_tool_call(&call).await.unwrap();
1286 assert!(result.is_some());
1287 assert_eq!(result.unwrap().summary, "tool_call_result");
1288 }
1289
1290 #[test]
1291 fn dyn_executor_set_effective_trust_delegates() {
1292 use std::sync::atomic::{AtomicU8, Ordering};
1293
1294 struct TrustCapture(AtomicU8);
1295 impl ToolExecutor for TrustCapture {
1296 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
1297 Ok(None)
1298 }
1299 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
1300 let v = match level {
1302 crate::SkillTrustLevel::Trusted => 0u8,
1303 crate::SkillTrustLevel::Verified => 1,
1304 crate::SkillTrustLevel::Quarantined => 2,
1305 crate::SkillTrustLevel::Blocked => 3,
1306 };
1307 self.0.store(v, Ordering::Relaxed);
1308 }
1309 }
1310
1311 let inner = std::sync::Arc::new(TrustCapture(AtomicU8::new(0)));
1312 let exec =
1313 DynExecutor(std::sync::Arc::clone(&inner) as std::sync::Arc<dyn ErasedToolExecutor>);
1314 ToolExecutor::set_effective_trust(&exec, crate::SkillTrustLevel::Quarantined);
1315 assert_eq!(inner.0.load(Ordering::Relaxed), 2);
1316
1317 ToolExecutor::set_effective_trust(&exec, crate::SkillTrustLevel::Blocked);
1318 assert_eq!(inner.0.load(Ordering::Relaxed), 3);
1319 }
1320
1321 #[test]
1322 fn extract_fenced_blocks_no_prefix_match() {
1323 assert!(extract_fenced_blocks("```bashrc\nfoo\n```", "bash").is_empty());
1325 assert_eq!(
1327 extract_fenced_blocks("```bash\nfoo\n```", "bash"),
1328 vec!["foo"]
1329 );
1330 assert_eq!(
1332 extract_fenced_blocks("```bash \nfoo\n```", "bash"),
1333 vec!["foo"]
1334 );
1335 }
1336
1337 #[test]
1340 fn tool_error_http_400_category_is_invalid_parameters() {
1341 use crate::error_taxonomy::ToolErrorCategory;
1342 let err = ToolError::Http {
1343 status: 400,
1344 message: "bad request".to_owned(),
1345 };
1346 assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
1347 }
1348
1349 #[test]
1350 fn tool_error_http_401_category_is_policy_blocked() {
1351 use crate::error_taxonomy::ToolErrorCategory;
1352 let err = ToolError::Http {
1353 status: 401,
1354 message: "unauthorized".to_owned(),
1355 };
1356 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1357 }
1358
1359 #[test]
1360 fn tool_error_http_403_category_is_policy_blocked() {
1361 use crate::error_taxonomy::ToolErrorCategory;
1362 let err = ToolError::Http {
1363 status: 403,
1364 message: "forbidden".to_owned(),
1365 };
1366 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1367 }
1368
1369 #[test]
1370 fn tool_error_http_404_category_is_permanent_failure() {
1371 use crate::error_taxonomy::ToolErrorCategory;
1372 let err = ToolError::Http {
1373 status: 404,
1374 message: "not found".to_owned(),
1375 };
1376 assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
1377 }
1378
1379 #[test]
1380 fn tool_error_http_429_category_is_rate_limited() {
1381 use crate::error_taxonomy::ToolErrorCategory;
1382 let err = ToolError::Http {
1383 status: 429,
1384 message: "too many requests".to_owned(),
1385 };
1386 assert_eq!(err.category(), ToolErrorCategory::RateLimited);
1387 }
1388
1389 #[test]
1390 fn tool_error_http_500_category_is_server_error() {
1391 use crate::error_taxonomy::ToolErrorCategory;
1392 let err = ToolError::Http {
1393 status: 500,
1394 message: "internal server error".to_owned(),
1395 };
1396 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1397 }
1398
1399 #[test]
1400 fn tool_error_http_502_category_is_server_error() {
1401 use crate::error_taxonomy::ToolErrorCategory;
1402 let err = ToolError::Http {
1403 status: 502,
1404 message: "bad gateway".to_owned(),
1405 };
1406 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1407 }
1408
1409 #[test]
1410 fn tool_error_http_503_category_is_server_error() {
1411 use crate::error_taxonomy::ToolErrorCategory;
1412 let err = ToolError::Http {
1413 status: 503,
1414 message: "service unavailable".to_owned(),
1415 };
1416 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1417 }
1418
1419 #[test]
1420 fn tool_error_http_503_is_transient_triggers_phase2_retry() {
1421 let err = ToolError::Http {
1424 status: 503,
1425 message: "service unavailable".to_owned(),
1426 };
1427 assert_eq!(
1428 err.kind(),
1429 ErrorKind::Transient,
1430 "HTTP 503 must be Transient so Phase 2 retry fires"
1431 );
1432 }
1433
1434 #[test]
1435 fn tool_error_blocked_category_is_policy_blocked() {
1436 use crate::error_taxonomy::ToolErrorCategory;
1437 let err = ToolError::Blocked {
1438 command: "rm -rf /".to_owned(),
1439 };
1440 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1441 }
1442
1443 #[test]
1444 fn tool_error_sandbox_violation_category_is_policy_blocked() {
1445 use crate::error_taxonomy::ToolErrorCategory;
1446 let err = ToolError::SandboxViolation {
1447 path: "/etc/shadow".to_owned(),
1448 };
1449 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1450 }
1451
1452 #[test]
1453 fn tool_error_confirmation_required_category() {
1454 use crate::error_taxonomy::ToolErrorCategory;
1455 let err = ToolError::ConfirmationRequired {
1456 command: "rm /tmp/x".to_owned(),
1457 };
1458 assert_eq!(err.category(), ToolErrorCategory::ConfirmationRequired);
1459 }
1460
1461 #[test]
1462 fn tool_error_timeout_category() {
1463 use crate::error_taxonomy::ToolErrorCategory;
1464 let err = ToolError::Timeout { timeout_secs: 30 };
1465 assert_eq!(err.category(), ToolErrorCategory::Timeout);
1466 }
1467
1468 #[test]
1469 fn tool_error_cancelled_category() {
1470 use crate::error_taxonomy::ToolErrorCategory;
1471 assert_eq!(
1472 ToolError::Cancelled.category(),
1473 ToolErrorCategory::Cancelled
1474 );
1475 }
1476
1477 #[test]
1478 fn tool_error_invalid_params_category() {
1479 use crate::error_taxonomy::ToolErrorCategory;
1480 let err = ToolError::InvalidParams {
1481 message: "missing field".to_owned(),
1482 };
1483 assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
1484 }
1485
1486 #[test]
1488 fn tool_error_execution_not_found_category_is_permanent_failure() {
1489 use crate::error_taxonomy::ToolErrorCategory;
1490 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash: not found");
1491 let err = ToolError::Execution(io_err);
1492 let cat = err.category();
1493 assert_ne!(
1494 cat,
1495 ToolErrorCategory::ToolNotFound,
1496 "Execution(NotFound) must NOT map to ToolNotFound"
1497 );
1498 assert_eq!(cat, ToolErrorCategory::PermanentFailure);
1499 }
1500
1501 #[test]
1502 fn tool_error_execution_timed_out_category_is_timeout() {
1503 use crate::error_taxonomy::ToolErrorCategory;
1504 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
1505 assert_eq!(
1506 ToolError::Execution(io_err).category(),
1507 ToolErrorCategory::Timeout
1508 );
1509 }
1510
1511 #[test]
1512 fn tool_error_execution_connection_refused_category_is_network_error() {
1513 use crate::error_taxonomy::ToolErrorCategory;
1514 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
1515 assert_eq!(
1516 ToolError::Execution(io_err).category(),
1517 ToolErrorCategory::NetworkError
1518 );
1519 }
1520
1521 #[test]
1523 fn b4_tool_error_http_429_not_quality_failure() {
1524 let err = ToolError::Http {
1525 status: 429,
1526 message: "rate limited".to_owned(),
1527 };
1528 assert!(
1529 !err.category().is_quality_failure(),
1530 "RateLimited must not be a quality failure"
1531 );
1532 }
1533
1534 #[test]
1535 fn b4_tool_error_http_503_not_quality_failure() {
1536 let err = ToolError::Http {
1537 status: 503,
1538 message: "service unavailable".to_owned(),
1539 };
1540 assert!(
1541 !err.category().is_quality_failure(),
1542 "ServerError must not be a quality failure"
1543 );
1544 }
1545
1546 #[test]
1547 fn b4_tool_error_execution_timed_out_not_quality_failure() {
1548 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
1549 assert!(
1550 !ToolError::Execution(io_err).category().is_quality_failure(),
1551 "Timeout must not be a quality failure"
1552 );
1553 }
1554
1555 #[test]
1558 fn tool_error_shell_exit126_is_policy_blocked() {
1559 use crate::error_taxonomy::ToolErrorCategory;
1560 let err = ToolError::Shell {
1561 exit_code: 126,
1562 category: ToolErrorCategory::PolicyBlocked,
1563 message: "permission denied".to_owned(),
1564 };
1565 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1566 }
1567
1568 #[test]
1569 fn tool_error_shell_exit127_is_permanent_failure() {
1570 use crate::error_taxonomy::ToolErrorCategory;
1571 let err = ToolError::Shell {
1572 exit_code: 127,
1573 category: ToolErrorCategory::PermanentFailure,
1574 message: "command not found".to_owned(),
1575 };
1576 assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
1577 assert!(!err.category().is_retryable());
1578 }
1579
1580 #[test]
1581 fn tool_error_shell_not_quality_failure() {
1582 use crate::error_taxonomy::ToolErrorCategory;
1583 let err = ToolError::Shell {
1584 exit_code: 127,
1585 category: ToolErrorCategory::PermanentFailure,
1586 message: "command not found".to_owned(),
1587 };
1588 assert!(!err.category().is_quality_failure());
1590 }
1591}