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 {
519 fn execute(
528 &self,
529 response: &str,
530 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send;
531
532 fn execute_confirmed(
541 &self,
542 response: &str,
543 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
544 self.execute(response)
545 }
546
547 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
552 vec![]
553 }
554
555 fn execute_tool_call(
561 &self,
562 _call: &ToolCall,
563 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
564 std::future::ready(Ok(None))
565 }
566
567 fn execute_tool_call_confirmed(
576 &self,
577 call: &ToolCall,
578 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
579 self.execute_tool_call(call)
580 }
581
582 fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
587
588 fn set_effective_trust(&self, _level: crate::SkillTrustLevel) {}
592
593 fn is_tool_retryable(&self, _tool_id: &str) -> bool {
599 false
600 }
601
602 fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
629 false
630 }
631}
632
633pub trait ErasedToolExecutor: Send + Sync {
642 fn execute_erased<'a>(
643 &'a self,
644 response: &'a str,
645 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
646
647 fn execute_confirmed_erased<'a>(
648 &'a self,
649 response: &'a str,
650 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
651
652 fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef>;
653
654 fn execute_tool_call_erased<'a>(
655 &'a self,
656 call: &'a ToolCall,
657 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
658
659 fn execute_tool_call_confirmed_erased<'a>(
660 &'a self,
661 call: &'a ToolCall,
662 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
663 {
664 self.execute_tool_call_erased(call)
668 }
669
670 fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
672
673 fn set_effective_trust(&self, _level: crate::SkillTrustLevel) {}
675
676 fn is_tool_retryable_erased(&self, tool_id: &str) -> bool;
678
679 fn is_tool_speculatable_erased(&self, _tool_id: &str) -> bool {
683 false
684 }
685
686 fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
694 false
695 }
696}
697
698impl<T: ToolExecutor> ErasedToolExecutor for T {
699 fn execute_erased<'a>(
700 &'a self,
701 response: &'a str,
702 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
703 {
704 Box::pin(self.execute(response))
705 }
706
707 fn execute_confirmed_erased<'a>(
708 &'a self,
709 response: &'a str,
710 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
711 {
712 Box::pin(self.execute_confirmed(response))
713 }
714
715 fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef> {
716 self.tool_definitions()
717 }
718
719 fn execute_tool_call_erased<'a>(
720 &'a self,
721 call: &'a ToolCall,
722 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
723 {
724 Box::pin(self.execute_tool_call(call))
725 }
726
727 fn execute_tool_call_confirmed_erased<'a>(
728 &'a self,
729 call: &'a ToolCall,
730 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
731 {
732 Box::pin(self.execute_tool_call_confirmed(call))
733 }
734
735 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
736 ToolExecutor::set_skill_env(self, env);
737 }
738
739 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
740 ToolExecutor::set_effective_trust(self, level);
741 }
742
743 fn is_tool_retryable_erased(&self, tool_id: &str) -> bool {
744 ToolExecutor::is_tool_retryable(self, tool_id)
745 }
746
747 fn is_tool_speculatable_erased(&self, tool_id: &str) -> bool {
748 ToolExecutor::is_tool_speculatable(self, tool_id)
749 }
750}
751
752pub struct DynExecutor(pub std::sync::Arc<dyn ErasedToolExecutor>);
756
757impl ToolExecutor for DynExecutor {
758 fn execute(
759 &self,
760 response: &str,
761 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
762 let inner = std::sync::Arc::clone(&self.0);
764 let response = response.to_owned();
765 async move { inner.execute_erased(&response).await }
766 }
767
768 fn execute_confirmed(
769 &self,
770 response: &str,
771 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
772 let inner = std::sync::Arc::clone(&self.0);
773 let response = response.to_owned();
774 async move { inner.execute_confirmed_erased(&response).await }
775 }
776
777 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
778 self.0.tool_definitions_erased()
779 }
780
781 fn execute_tool_call(
782 &self,
783 call: &ToolCall,
784 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
785 let inner = std::sync::Arc::clone(&self.0);
786 let call = call.clone();
787 async move { inner.execute_tool_call_erased(&call).await }
788 }
789
790 fn execute_tool_call_confirmed(
791 &self,
792 call: &ToolCall,
793 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
794 let inner = std::sync::Arc::clone(&self.0);
795 let call = call.clone();
796 async move { inner.execute_tool_call_confirmed_erased(&call).await }
797 }
798
799 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
800 ErasedToolExecutor::set_skill_env(self.0.as_ref(), env);
801 }
802
803 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
804 ErasedToolExecutor::set_effective_trust(self.0.as_ref(), level);
805 }
806
807 fn is_tool_retryable(&self, tool_id: &str) -> bool {
808 self.0.is_tool_retryable_erased(tool_id)
809 }
810
811 fn is_tool_speculatable(&self, tool_id: &str) -> bool {
812 self.0.is_tool_speculatable_erased(tool_id)
813 }
814}
815
816#[must_use]
820pub fn extract_fenced_blocks<'a>(text: &'a str, lang: &str) -> Vec<&'a str> {
821 let marker = format!("```{lang}");
822 let marker_len = marker.len();
823 let mut blocks = Vec::new();
824 let mut rest = text;
825
826 let mut search_from = 0;
827 while let Some(rel) = rest[search_from..].find(&marker) {
828 let start = search_from + rel;
829 let after = &rest[start + marker_len..];
830 let boundary_ok = after
834 .chars()
835 .next()
836 .is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '-');
837 if !boundary_ok {
838 search_from = start + marker_len;
839 continue;
840 }
841 if let Some(end) = after.find("```") {
842 blocks.push(after[..end].trim());
843 rest = &after[end + 3..];
844 search_from = 0;
845 } else {
846 break;
847 }
848 }
849
850 blocks
851}
852
853#[cfg(test)]
854mod tests {
855 use super::*;
856
857 #[test]
858 fn tool_output_display() {
859 let output = ToolOutput {
860 tool_name: ToolName::new("bash"),
861 summary: "$ echo hello\nhello".to_owned(),
862 blocks_executed: 1,
863 filter_stats: None,
864 diff: None,
865 streamed: false,
866 terminal_id: None,
867 locations: None,
868 raw_response: None,
869 claim_source: None,
870 };
871 assert_eq!(output.to_string(), "$ echo hello\nhello");
872 }
873
874 #[test]
875 fn tool_error_blocked_display() {
876 let err = ToolError::Blocked {
877 command: "rm -rf /".to_owned(),
878 };
879 assert_eq!(err.to_string(), "command blocked by policy: rm -rf /");
880 }
881
882 #[test]
883 fn tool_error_sandbox_violation_display() {
884 let err = ToolError::SandboxViolation {
885 path: "/etc/shadow".to_owned(),
886 };
887 assert_eq!(err.to_string(), "path not allowed by sandbox: /etc/shadow");
888 }
889
890 #[test]
891 fn tool_error_confirmation_required_display() {
892 let err = ToolError::ConfirmationRequired {
893 command: "rm -rf /tmp".to_owned(),
894 };
895 assert_eq!(
896 err.to_string(),
897 "command requires confirmation: rm -rf /tmp"
898 );
899 }
900
901 #[test]
902 fn tool_error_timeout_display() {
903 let err = ToolError::Timeout { timeout_secs: 30 };
904 assert_eq!(err.to_string(), "command timed out after 30s");
905 }
906
907 #[test]
908 fn tool_error_invalid_params_display() {
909 let err = ToolError::InvalidParams {
910 message: "missing field `command`".to_owned(),
911 };
912 assert_eq!(
913 err.to_string(),
914 "invalid tool parameters: missing field `command`"
915 );
916 }
917
918 #[test]
919 fn deserialize_params_valid() {
920 #[derive(Debug, serde::Deserialize, PartialEq)]
921 struct P {
922 name: String,
923 count: u32,
924 }
925 let mut map = serde_json::Map::new();
926 map.insert("name".to_owned(), serde_json::json!("test"));
927 map.insert("count".to_owned(), serde_json::json!(42));
928 let p: P = deserialize_params(&map).unwrap();
929 assert_eq!(
930 p,
931 P {
932 name: "test".to_owned(),
933 count: 42
934 }
935 );
936 }
937
938 #[test]
939 fn deserialize_params_missing_required_field() {
940 #[derive(Debug, serde::Deserialize)]
941 #[allow(dead_code)]
942 struct P {
943 name: String,
944 }
945 let map = serde_json::Map::new();
946 let err = deserialize_params::<P>(&map).unwrap_err();
947 assert!(matches!(err, ToolError::InvalidParams { .. }));
948 }
949
950 #[test]
951 fn deserialize_params_wrong_type() {
952 #[derive(Debug, serde::Deserialize)]
953 #[allow(dead_code)]
954 struct P {
955 count: u32,
956 }
957 let mut map = serde_json::Map::new();
958 map.insert("count".to_owned(), serde_json::json!("not a number"));
959 let err = deserialize_params::<P>(&map).unwrap_err();
960 assert!(matches!(err, ToolError::InvalidParams { .. }));
961 }
962
963 #[test]
964 fn deserialize_params_all_optional_empty() {
965 #[derive(Debug, serde::Deserialize, PartialEq)]
966 struct P {
967 name: Option<String>,
968 }
969 let map = serde_json::Map::new();
970 let p: P = deserialize_params(&map).unwrap();
971 assert_eq!(p, P { name: None });
972 }
973
974 #[test]
975 fn deserialize_params_ignores_extra_fields() {
976 #[derive(Debug, serde::Deserialize, PartialEq)]
977 struct P {
978 name: String,
979 }
980 let mut map = serde_json::Map::new();
981 map.insert("name".to_owned(), serde_json::json!("test"));
982 map.insert("extra".to_owned(), serde_json::json!(true));
983 let p: P = deserialize_params(&map).unwrap();
984 assert_eq!(
985 p,
986 P {
987 name: "test".to_owned()
988 }
989 );
990 }
991
992 #[test]
993 fn tool_error_execution_display() {
994 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash not found");
995 let err = ToolError::Execution(io_err);
996 assert!(err.to_string().starts_with("execution failed:"));
997 assert!(err.to_string().contains("bash not found"));
998 }
999
1000 #[test]
1002 fn error_kind_timeout_is_transient() {
1003 let err = ToolError::Timeout { timeout_secs: 30 };
1004 assert_eq!(err.kind(), ErrorKind::Transient);
1005 }
1006
1007 #[test]
1008 fn error_kind_blocked_is_permanent() {
1009 let err = ToolError::Blocked {
1010 command: "rm -rf /".to_owned(),
1011 };
1012 assert_eq!(err.kind(), ErrorKind::Permanent);
1013 }
1014
1015 #[test]
1016 fn error_kind_sandbox_violation_is_permanent() {
1017 let err = ToolError::SandboxViolation {
1018 path: "/etc/shadow".to_owned(),
1019 };
1020 assert_eq!(err.kind(), ErrorKind::Permanent);
1021 }
1022
1023 #[test]
1024 fn error_kind_cancelled_is_permanent() {
1025 assert_eq!(ToolError::Cancelled.kind(), ErrorKind::Permanent);
1026 }
1027
1028 #[test]
1029 fn error_kind_invalid_params_is_permanent() {
1030 let err = ToolError::InvalidParams {
1031 message: "bad arg".to_owned(),
1032 };
1033 assert_eq!(err.kind(), ErrorKind::Permanent);
1034 }
1035
1036 #[test]
1037 fn error_kind_confirmation_required_is_permanent() {
1038 let err = ToolError::ConfirmationRequired {
1039 command: "rm /tmp/x".to_owned(),
1040 };
1041 assert_eq!(err.kind(), ErrorKind::Permanent);
1042 }
1043
1044 #[test]
1045 fn error_kind_execution_timed_out_is_transient() {
1046 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
1047 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1048 }
1049
1050 #[test]
1051 fn error_kind_execution_interrupted_is_transient() {
1052 let io_err = std::io::Error::new(std::io::ErrorKind::Interrupted, "interrupted");
1053 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1054 }
1055
1056 #[test]
1057 fn error_kind_execution_connection_reset_is_transient() {
1058 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
1059 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1060 }
1061
1062 #[test]
1063 fn error_kind_execution_broken_pipe_is_transient() {
1064 let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken");
1065 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1066 }
1067
1068 #[test]
1069 fn error_kind_execution_would_block_is_transient() {
1070 let io_err = std::io::Error::new(std::io::ErrorKind::WouldBlock, "would block");
1071 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1072 }
1073
1074 #[test]
1075 fn error_kind_execution_connection_aborted_is_transient() {
1076 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionAborted, "aborted");
1077 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1078 }
1079
1080 #[test]
1081 fn error_kind_execution_not_found_is_permanent() {
1082 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
1083 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1084 }
1085
1086 #[test]
1087 fn error_kind_execution_permission_denied_is_permanent() {
1088 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
1089 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1090 }
1091
1092 #[test]
1093 fn error_kind_execution_other_is_permanent() {
1094 let io_err = std::io::Error::other("some other error");
1095 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1096 }
1097
1098 #[test]
1099 fn error_kind_execution_already_exists_is_permanent() {
1100 let io_err = std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists");
1101 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1102 }
1103
1104 #[test]
1105 fn error_kind_display() {
1106 assert_eq!(ErrorKind::Transient.to_string(), "transient");
1107 assert_eq!(ErrorKind::Permanent.to_string(), "permanent");
1108 }
1109
1110 #[test]
1111 fn truncate_tool_output_short_passthrough() {
1112 let short = "hello world";
1113 assert_eq!(truncate_tool_output(short), short);
1114 }
1115
1116 #[test]
1117 fn truncate_tool_output_exact_limit() {
1118 let exact = "a".repeat(MAX_TOOL_OUTPUT_CHARS);
1119 assert_eq!(truncate_tool_output(&exact), exact);
1120 }
1121
1122 #[test]
1123 fn truncate_tool_output_long_split() {
1124 let long = "x".repeat(MAX_TOOL_OUTPUT_CHARS + 1000);
1125 let result = truncate_tool_output(&long);
1126 assert!(result.contains("truncated"));
1127 assert!(result.len() < long.len());
1128 }
1129
1130 #[test]
1131 fn truncate_tool_output_notice_contains_count() {
1132 let long = "y".repeat(MAX_TOOL_OUTPUT_CHARS + 2000);
1133 let result = truncate_tool_output(&long);
1134 assert!(result.contains("truncated"));
1135 assert!(result.contains("chars"));
1136 }
1137
1138 #[derive(Debug)]
1139 struct DefaultExecutor;
1140 impl ToolExecutor for DefaultExecutor {
1141 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
1142 Ok(None)
1143 }
1144 }
1145
1146 #[tokio::test]
1147 async fn execute_tool_call_default_returns_none() {
1148 let exec = DefaultExecutor;
1149 let call = ToolCall {
1150 tool_id: ToolName::new("anything"),
1151 params: serde_json::Map::new(),
1152 caller_id: None,
1153 };
1154 let result = exec.execute_tool_call(&call).await.unwrap();
1155 assert!(result.is_none());
1156 }
1157
1158 #[test]
1159 fn filter_stats_savings_pct() {
1160 let fs = FilterStats {
1161 raw_chars: 1000,
1162 filtered_chars: 200,
1163 ..Default::default()
1164 };
1165 assert!((fs.savings_pct() - 80.0).abs() < 0.01);
1166 }
1167
1168 #[test]
1169 fn filter_stats_savings_pct_zero() {
1170 let fs = FilterStats::default();
1171 assert!((fs.savings_pct()).abs() < 0.01);
1172 }
1173
1174 #[test]
1175 fn filter_stats_estimated_tokens_saved() {
1176 let fs = FilterStats {
1177 raw_chars: 1000,
1178 filtered_chars: 200,
1179 ..Default::default()
1180 };
1181 assert_eq!(fs.estimated_tokens_saved(), 200); }
1183
1184 #[test]
1185 fn filter_stats_format_inline() {
1186 let fs = FilterStats {
1187 raw_chars: 1000,
1188 filtered_chars: 200,
1189 raw_lines: 342,
1190 filtered_lines: 28,
1191 ..Default::default()
1192 };
1193 let line = fs.format_inline("shell");
1194 assert_eq!(line, "[shell] 342 lines \u{2192} 28 lines, 80.0% filtered");
1195 }
1196
1197 #[test]
1198 fn filter_stats_format_inline_zero() {
1199 let fs = FilterStats::default();
1200 let line = fs.format_inline("bash");
1201 assert_eq!(line, "[bash] 0 lines \u{2192} 0 lines, 0.0% filtered");
1202 }
1203
1204 struct FixedExecutor {
1207 tool_id: &'static str,
1208 output: &'static str,
1209 }
1210
1211 impl ToolExecutor for FixedExecutor {
1212 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
1213 Ok(Some(ToolOutput {
1214 tool_name: ToolName::new(self.tool_id),
1215 summary: self.output.to_owned(),
1216 blocks_executed: 1,
1217 filter_stats: None,
1218 diff: None,
1219 streamed: false,
1220 terminal_id: None,
1221 locations: None,
1222 raw_response: None,
1223 claim_source: None,
1224 }))
1225 }
1226
1227 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
1228 vec![]
1229 }
1230
1231 async fn execute_tool_call(
1232 &self,
1233 _call: &ToolCall,
1234 ) -> Result<Option<ToolOutput>, ToolError> {
1235 Ok(Some(ToolOutput {
1236 tool_name: ToolName::new(self.tool_id),
1237 summary: self.output.to_owned(),
1238 blocks_executed: 1,
1239 filter_stats: None,
1240 diff: None,
1241 streamed: false,
1242 terminal_id: None,
1243 locations: None,
1244 raw_response: None,
1245 claim_source: None,
1246 }))
1247 }
1248 }
1249
1250 #[tokio::test]
1251 async fn dyn_executor_execute_delegates() {
1252 let inner = std::sync::Arc::new(FixedExecutor {
1253 tool_id: "bash",
1254 output: "hello",
1255 });
1256 let exec = DynExecutor(inner);
1257 let result = exec.execute("```bash\necho hello\n```").await.unwrap();
1258 assert!(result.is_some());
1259 assert_eq!(result.unwrap().summary, "hello");
1260 }
1261
1262 #[tokio::test]
1263 async fn dyn_executor_execute_confirmed_delegates() {
1264 let inner = std::sync::Arc::new(FixedExecutor {
1265 tool_id: "bash",
1266 output: "confirmed",
1267 });
1268 let exec = DynExecutor(inner);
1269 let result = exec.execute_confirmed("...").await.unwrap();
1270 assert!(result.is_some());
1271 assert_eq!(result.unwrap().summary, "confirmed");
1272 }
1273
1274 #[test]
1275 fn dyn_executor_tool_definitions_delegates() {
1276 let inner = std::sync::Arc::new(FixedExecutor {
1277 tool_id: "my_tool",
1278 output: "",
1279 });
1280 let exec = DynExecutor(inner);
1281 let defs = exec.tool_definitions();
1283 assert!(defs.is_empty());
1284 }
1285
1286 #[tokio::test]
1287 async fn dyn_executor_execute_tool_call_delegates() {
1288 let inner = std::sync::Arc::new(FixedExecutor {
1289 tool_id: "bash",
1290 output: "tool_call_result",
1291 });
1292 let exec = DynExecutor(inner);
1293 let call = ToolCall {
1294 tool_id: ToolName::new("bash"),
1295 params: serde_json::Map::new(),
1296 caller_id: None,
1297 };
1298 let result = exec.execute_tool_call(&call).await.unwrap();
1299 assert!(result.is_some());
1300 assert_eq!(result.unwrap().summary, "tool_call_result");
1301 }
1302
1303 #[test]
1304 fn dyn_executor_set_effective_trust_delegates() {
1305 use std::sync::atomic::{AtomicU8, Ordering};
1306
1307 struct TrustCapture(AtomicU8);
1308 impl ToolExecutor for TrustCapture {
1309 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
1310 Ok(None)
1311 }
1312 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
1313 let v = match level {
1315 crate::SkillTrustLevel::Trusted => 0u8,
1316 crate::SkillTrustLevel::Verified => 1,
1317 crate::SkillTrustLevel::Quarantined => 2,
1318 crate::SkillTrustLevel::Blocked => 3,
1319 };
1320 self.0.store(v, Ordering::Relaxed);
1321 }
1322 }
1323
1324 let inner = std::sync::Arc::new(TrustCapture(AtomicU8::new(0)));
1325 let exec =
1326 DynExecutor(std::sync::Arc::clone(&inner) as std::sync::Arc<dyn ErasedToolExecutor>);
1327 ToolExecutor::set_effective_trust(&exec, crate::SkillTrustLevel::Quarantined);
1328 assert_eq!(inner.0.load(Ordering::Relaxed), 2);
1329
1330 ToolExecutor::set_effective_trust(&exec, crate::SkillTrustLevel::Blocked);
1331 assert_eq!(inner.0.load(Ordering::Relaxed), 3);
1332 }
1333
1334 #[test]
1335 fn extract_fenced_blocks_no_prefix_match() {
1336 assert!(extract_fenced_blocks("```bashrc\nfoo\n```", "bash").is_empty());
1338 assert_eq!(
1340 extract_fenced_blocks("```bash\nfoo\n```", "bash"),
1341 vec!["foo"]
1342 );
1343 assert_eq!(
1345 extract_fenced_blocks("```bash \nfoo\n```", "bash"),
1346 vec!["foo"]
1347 );
1348 }
1349
1350 #[test]
1353 fn tool_error_http_400_category_is_invalid_parameters() {
1354 use crate::error_taxonomy::ToolErrorCategory;
1355 let err = ToolError::Http {
1356 status: 400,
1357 message: "bad request".to_owned(),
1358 };
1359 assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
1360 }
1361
1362 #[test]
1363 fn tool_error_http_401_category_is_policy_blocked() {
1364 use crate::error_taxonomy::ToolErrorCategory;
1365 let err = ToolError::Http {
1366 status: 401,
1367 message: "unauthorized".to_owned(),
1368 };
1369 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1370 }
1371
1372 #[test]
1373 fn tool_error_http_403_category_is_policy_blocked() {
1374 use crate::error_taxonomy::ToolErrorCategory;
1375 let err = ToolError::Http {
1376 status: 403,
1377 message: "forbidden".to_owned(),
1378 };
1379 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1380 }
1381
1382 #[test]
1383 fn tool_error_http_404_category_is_permanent_failure() {
1384 use crate::error_taxonomy::ToolErrorCategory;
1385 let err = ToolError::Http {
1386 status: 404,
1387 message: "not found".to_owned(),
1388 };
1389 assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
1390 }
1391
1392 #[test]
1393 fn tool_error_http_429_category_is_rate_limited() {
1394 use crate::error_taxonomy::ToolErrorCategory;
1395 let err = ToolError::Http {
1396 status: 429,
1397 message: "too many requests".to_owned(),
1398 };
1399 assert_eq!(err.category(), ToolErrorCategory::RateLimited);
1400 }
1401
1402 #[test]
1403 fn tool_error_http_500_category_is_server_error() {
1404 use crate::error_taxonomy::ToolErrorCategory;
1405 let err = ToolError::Http {
1406 status: 500,
1407 message: "internal server error".to_owned(),
1408 };
1409 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1410 }
1411
1412 #[test]
1413 fn tool_error_http_502_category_is_server_error() {
1414 use crate::error_taxonomy::ToolErrorCategory;
1415 let err = ToolError::Http {
1416 status: 502,
1417 message: "bad gateway".to_owned(),
1418 };
1419 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1420 }
1421
1422 #[test]
1423 fn tool_error_http_503_category_is_server_error() {
1424 use crate::error_taxonomy::ToolErrorCategory;
1425 let err = ToolError::Http {
1426 status: 503,
1427 message: "service unavailable".to_owned(),
1428 };
1429 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1430 }
1431
1432 #[test]
1433 fn tool_error_http_503_is_transient_triggers_phase2_retry() {
1434 let err = ToolError::Http {
1437 status: 503,
1438 message: "service unavailable".to_owned(),
1439 };
1440 assert_eq!(
1441 err.kind(),
1442 ErrorKind::Transient,
1443 "HTTP 503 must be Transient so Phase 2 retry fires"
1444 );
1445 }
1446
1447 #[test]
1448 fn tool_error_blocked_category_is_policy_blocked() {
1449 use crate::error_taxonomy::ToolErrorCategory;
1450 let err = ToolError::Blocked {
1451 command: "rm -rf /".to_owned(),
1452 };
1453 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1454 }
1455
1456 #[test]
1457 fn tool_error_sandbox_violation_category_is_policy_blocked() {
1458 use crate::error_taxonomy::ToolErrorCategory;
1459 let err = ToolError::SandboxViolation {
1460 path: "/etc/shadow".to_owned(),
1461 };
1462 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1463 }
1464
1465 #[test]
1466 fn tool_error_confirmation_required_category() {
1467 use crate::error_taxonomy::ToolErrorCategory;
1468 let err = ToolError::ConfirmationRequired {
1469 command: "rm /tmp/x".to_owned(),
1470 };
1471 assert_eq!(err.category(), ToolErrorCategory::ConfirmationRequired);
1472 }
1473
1474 #[test]
1475 fn tool_error_timeout_category() {
1476 use crate::error_taxonomy::ToolErrorCategory;
1477 let err = ToolError::Timeout { timeout_secs: 30 };
1478 assert_eq!(err.category(), ToolErrorCategory::Timeout);
1479 }
1480
1481 #[test]
1482 fn tool_error_cancelled_category() {
1483 use crate::error_taxonomy::ToolErrorCategory;
1484 assert_eq!(
1485 ToolError::Cancelled.category(),
1486 ToolErrorCategory::Cancelled
1487 );
1488 }
1489
1490 #[test]
1491 fn tool_error_invalid_params_category() {
1492 use crate::error_taxonomy::ToolErrorCategory;
1493 let err = ToolError::InvalidParams {
1494 message: "missing field".to_owned(),
1495 };
1496 assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
1497 }
1498
1499 #[test]
1501 fn tool_error_execution_not_found_category_is_permanent_failure() {
1502 use crate::error_taxonomy::ToolErrorCategory;
1503 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash: not found");
1504 let err = ToolError::Execution(io_err);
1505 let cat = err.category();
1506 assert_ne!(
1507 cat,
1508 ToolErrorCategory::ToolNotFound,
1509 "Execution(NotFound) must NOT map to ToolNotFound"
1510 );
1511 assert_eq!(cat, ToolErrorCategory::PermanentFailure);
1512 }
1513
1514 #[test]
1515 fn tool_error_execution_timed_out_category_is_timeout() {
1516 use crate::error_taxonomy::ToolErrorCategory;
1517 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
1518 assert_eq!(
1519 ToolError::Execution(io_err).category(),
1520 ToolErrorCategory::Timeout
1521 );
1522 }
1523
1524 #[test]
1525 fn tool_error_execution_connection_refused_category_is_network_error() {
1526 use crate::error_taxonomy::ToolErrorCategory;
1527 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
1528 assert_eq!(
1529 ToolError::Execution(io_err).category(),
1530 ToolErrorCategory::NetworkError
1531 );
1532 }
1533
1534 #[test]
1536 fn b4_tool_error_http_429_not_quality_failure() {
1537 let err = ToolError::Http {
1538 status: 429,
1539 message: "rate limited".to_owned(),
1540 };
1541 assert!(
1542 !err.category().is_quality_failure(),
1543 "RateLimited must not be a quality failure"
1544 );
1545 }
1546
1547 #[test]
1548 fn b4_tool_error_http_503_not_quality_failure() {
1549 let err = ToolError::Http {
1550 status: 503,
1551 message: "service unavailable".to_owned(),
1552 };
1553 assert!(
1554 !err.category().is_quality_failure(),
1555 "ServerError must not be a quality failure"
1556 );
1557 }
1558
1559 #[test]
1560 fn b4_tool_error_execution_timed_out_not_quality_failure() {
1561 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
1562 assert!(
1563 !ToolError::Execution(io_err).category().is_quality_failure(),
1564 "Timeout must not be a quality failure"
1565 );
1566 }
1567
1568 #[test]
1571 fn tool_error_shell_exit126_is_policy_blocked() {
1572 use crate::error_taxonomy::ToolErrorCategory;
1573 let err = ToolError::Shell {
1574 exit_code: 126,
1575 category: ToolErrorCategory::PolicyBlocked,
1576 message: "permission denied".to_owned(),
1577 };
1578 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1579 }
1580
1581 #[test]
1582 fn tool_error_shell_exit127_is_permanent_failure() {
1583 use crate::error_taxonomy::ToolErrorCategory;
1584 let err = ToolError::Shell {
1585 exit_code: 127,
1586 category: ToolErrorCategory::PermanentFailure,
1587 message: "command not found".to_owned(),
1588 };
1589 assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
1590 assert!(!err.category().is_retryable());
1591 }
1592
1593 #[test]
1594 fn tool_error_shell_not_quality_failure() {
1595 use crate::error_taxonomy::ToolErrorCategory;
1596 let err = ToolError::Shell {
1597 exit_code: 127,
1598 category: ToolErrorCategory::PermanentFailure,
1599 message: "command not found".to_owned(),
1600 };
1601 assert!(!err.category().is_quality_failure());
1603 }
1604}