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