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 },
289 OutputChunk {
291 tool_name: ToolName,
292 command: String,
293 chunk: String,
294 },
295 Completed {
297 tool_name: ToolName,
298 command: String,
299 output: String,
301 success: bool,
303 filter_stats: Option<FilterStats>,
304 diff: Option<DiffData>,
305 },
306 Rollback {
308 tool_name: ToolName,
309 command: String,
310 restored_count: usize,
312 deleted_count: usize,
314 },
315}
316
317pub type ToolEventTx = tokio::sync::mpsc::UnboundedSender<ToolEvent>;
322
323#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
328pub enum ErrorKind {
329 Transient,
330 Permanent,
331}
332
333impl std::fmt::Display for ErrorKind {
334 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335 match self {
336 Self::Transient => f.write_str("transient"),
337 Self::Permanent => f.write_str("permanent"),
338 }
339 }
340}
341
342#[derive(Debug, thiserror::Error)]
344pub enum ToolError {
345 #[error("command blocked by policy: {command}")]
346 Blocked { command: String },
347
348 #[error("path not allowed by sandbox: {path}")]
349 SandboxViolation { path: String },
350
351 #[error("command requires confirmation: {command}")]
352 ConfirmationRequired { command: String },
353
354 #[error("command timed out after {timeout_secs}s")]
355 Timeout { timeout_secs: u64 },
356
357 #[error("operation cancelled")]
358 Cancelled,
359
360 #[error("invalid tool parameters: {message}")]
361 InvalidParams { message: String },
362
363 #[error("execution failed: {0}")]
364 Execution(#[from] std::io::Error),
365
366 #[error("HTTP error {status}: {message}")]
371 Http { status: u16, message: String },
372
373 #[error("shell error (exit {exit_code}): {message}")]
379 Shell {
380 exit_code: i32,
381 category: crate::error_taxonomy::ToolErrorCategory,
382 message: String,
383 },
384
385 #[error("snapshot failed: {reason}")]
386 SnapshotFailed { reason: String },
387}
388
389impl ToolError {
390 #[must_use]
395 pub fn category(&self) -> crate::error_taxonomy::ToolErrorCategory {
396 use crate::error_taxonomy::{ToolErrorCategory, classify_http_status, classify_io_error};
397 match self {
398 Self::Blocked { .. } | Self::SandboxViolation { .. } => {
399 ToolErrorCategory::PolicyBlocked
400 }
401 Self::ConfirmationRequired { .. } => ToolErrorCategory::ConfirmationRequired,
402 Self::Timeout { .. } => ToolErrorCategory::Timeout,
403 Self::Cancelled => ToolErrorCategory::Cancelled,
404 Self::InvalidParams { .. } => ToolErrorCategory::InvalidParameters,
405 Self::Http { status, .. } => classify_http_status(*status),
406 Self::Execution(io_err) => classify_io_error(io_err),
407 Self::Shell { category, .. } => *category,
408 Self::SnapshotFailed { .. } => ToolErrorCategory::PermanentFailure,
409 }
410 }
411
412 #[must_use]
420 pub fn kind(&self) -> ErrorKind {
421 use crate::error_taxonomy::ToolErrorCategoryExt;
422 self.category().error_kind()
423 }
424}
425
426pub fn deserialize_params<T: serde::de::DeserializeOwned>(
432 params: &serde_json::Map<String, serde_json::Value>,
433) -> Result<T, ToolError> {
434 let obj = serde_json::Value::Object(params.clone());
435 serde_json::from_value(obj).map_err(|e| ToolError::InvalidParams {
436 message: e.to_string(),
437 })
438}
439
440pub trait ToolExecutor: Send + Sync {
504 fn execute(
513 &self,
514 response: &str,
515 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send;
516
517 fn execute_confirmed(
526 &self,
527 response: &str,
528 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
529 self.execute(response)
530 }
531
532 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
537 vec![]
538 }
539
540 fn execute_tool_call(
546 &self,
547 _call: &ToolCall,
548 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
549 std::future::ready(Ok(None))
550 }
551
552 fn execute_tool_call_confirmed(
561 &self,
562 call: &ToolCall,
563 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
564 self.execute_tool_call(call)
565 }
566
567 fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
572
573 fn set_effective_trust(&self, _level: crate::SkillTrustLevel) {}
577
578 fn is_tool_retryable(&self, _tool_id: &str) -> bool {
584 false
585 }
586}
587
588pub trait ErasedToolExecutor: Send + Sync {
597 fn execute_erased<'a>(
598 &'a self,
599 response: &'a str,
600 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
601
602 fn execute_confirmed_erased<'a>(
603 &'a self,
604 response: &'a str,
605 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
606
607 fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef>;
608
609 fn execute_tool_call_erased<'a>(
610 &'a self,
611 call: &'a ToolCall,
612 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
613
614 fn execute_tool_call_confirmed_erased<'a>(
615 &'a self,
616 call: &'a ToolCall,
617 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
618 {
619 self.execute_tool_call_erased(call)
623 }
624
625 fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
627
628 fn set_effective_trust(&self, _level: crate::SkillTrustLevel) {}
630
631 fn is_tool_retryable_erased(&self, tool_id: &str) -> bool;
633}
634
635impl<T: ToolExecutor> ErasedToolExecutor for T {
636 fn execute_erased<'a>(
637 &'a self,
638 response: &'a str,
639 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
640 {
641 Box::pin(self.execute(response))
642 }
643
644 fn execute_confirmed_erased<'a>(
645 &'a self,
646 response: &'a str,
647 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
648 {
649 Box::pin(self.execute_confirmed(response))
650 }
651
652 fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef> {
653 self.tool_definitions()
654 }
655
656 fn execute_tool_call_erased<'a>(
657 &'a self,
658 call: &'a ToolCall,
659 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
660 {
661 Box::pin(self.execute_tool_call(call))
662 }
663
664 fn execute_tool_call_confirmed_erased<'a>(
665 &'a self,
666 call: &'a ToolCall,
667 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
668 {
669 Box::pin(self.execute_tool_call_confirmed(call))
670 }
671
672 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
673 ToolExecutor::set_skill_env(self, env);
674 }
675
676 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
677 ToolExecutor::set_effective_trust(self, level);
678 }
679
680 fn is_tool_retryable_erased(&self, tool_id: &str) -> bool {
681 ToolExecutor::is_tool_retryable(self, tool_id)
682 }
683}
684
685pub struct DynExecutor(pub std::sync::Arc<dyn ErasedToolExecutor>);
689
690impl ToolExecutor for DynExecutor {
691 fn execute(
692 &self,
693 response: &str,
694 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
695 let inner = std::sync::Arc::clone(&self.0);
697 let response = response.to_owned();
698 async move { inner.execute_erased(&response).await }
699 }
700
701 fn execute_confirmed(
702 &self,
703 response: &str,
704 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
705 let inner = std::sync::Arc::clone(&self.0);
706 let response = response.to_owned();
707 async move { inner.execute_confirmed_erased(&response).await }
708 }
709
710 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
711 self.0.tool_definitions_erased()
712 }
713
714 fn execute_tool_call(
715 &self,
716 call: &ToolCall,
717 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
718 let inner = std::sync::Arc::clone(&self.0);
719 let call = call.clone();
720 async move { inner.execute_tool_call_erased(&call).await }
721 }
722
723 fn execute_tool_call_confirmed(
724 &self,
725 call: &ToolCall,
726 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
727 let inner = std::sync::Arc::clone(&self.0);
728 let call = call.clone();
729 async move { inner.execute_tool_call_confirmed_erased(&call).await }
730 }
731
732 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
733 ErasedToolExecutor::set_skill_env(self.0.as_ref(), env);
734 }
735
736 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
737 ErasedToolExecutor::set_effective_trust(self.0.as_ref(), level);
738 }
739
740 fn is_tool_retryable(&self, tool_id: &str) -> bool {
741 self.0.is_tool_retryable_erased(tool_id)
742 }
743}
744
745#[must_use]
749pub fn extract_fenced_blocks<'a>(text: &'a str, lang: &str) -> Vec<&'a str> {
750 let marker = format!("```{lang}");
751 let marker_len = marker.len();
752 let mut blocks = Vec::new();
753 let mut rest = text;
754
755 let mut search_from = 0;
756 while let Some(rel) = rest[search_from..].find(&marker) {
757 let start = search_from + rel;
758 let after = &rest[start + marker_len..];
759 let boundary_ok = after
763 .chars()
764 .next()
765 .is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '-');
766 if !boundary_ok {
767 search_from = start + marker_len;
768 continue;
769 }
770 if let Some(end) = after.find("```") {
771 blocks.push(after[..end].trim());
772 rest = &after[end + 3..];
773 search_from = 0;
774 } else {
775 break;
776 }
777 }
778
779 blocks
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785
786 #[test]
787 fn tool_output_display() {
788 let output = ToolOutput {
789 tool_name: ToolName::new("bash"),
790 summary: "$ echo hello\nhello".to_owned(),
791 blocks_executed: 1,
792 filter_stats: None,
793 diff: None,
794 streamed: false,
795 terminal_id: None,
796 locations: None,
797 raw_response: None,
798 claim_source: None,
799 };
800 assert_eq!(output.to_string(), "$ echo hello\nhello");
801 }
802
803 #[test]
804 fn tool_error_blocked_display() {
805 let err = ToolError::Blocked {
806 command: "rm -rf /".to_owned(),
807 };
808 assert_eq!(err.to_string(), "command blocked by policy: rm -rf /");
809 }
810
811 #[test]
812 fn tool_error_sandbox_violation_display() {
813 let err = ToolError::SandboxViolation {
814 path: "/etc/shadow".to_owned(),
815 };
816 assert_eq!(err.to_string(), "path not allowed by sandbox: /etc/shadow");
817 }
818
819 #[test]
820 fn tool_error_confirmation_required_display() {
821 let err = ToolError::ConfirmationRequired {
822 command: "rm -rf /tmp".to_owned(),
823 };
824 assert_eq!(
825 err.to_string(),
826 "command requires confirmation: rm -rf /tmp"
827 );
828 }
829
830 #[test]
831 fn tool_error_timeout_display() {
832 let err = ToolError::Timeout { timeout_secs: 30 };
833 assert_eq!(err.to_string(), "command timed out after 30s");
834 }
835
836 #[test]
837 fn tool_error_invalid_params_display() {
838 let err = ToolError::InvalidParams {
839 message: "missing field `command`".to_owned(),
840 };
841 assert_eq!(
842 err.to_string(),
843 "invalid tool parameters: missing field `command`"
844 );
845 }
846
847 #[test]
848 fn deserialize_params_valid() {
849 #[derive(Debug, serde::Deserialize, PartialEq)]
850 struct P {
851 name: String,
852 count: u32,
853 }
854 let mut map = serde_json::Map::new();
855 map.insert("name".to_owned(), serde_json::json!("test"));
856 map.insert("count".to_owned(), serde_json::json!(42));
857 let p: P = deserialize_params(&map).unwrap();
858 assert_eq!(
859 p,
860 P {
861 name: "test".to_owned(),
862 count: 42
863 }
864 );
865 }
866
867 #[test]
868 fn deserialize_params_missing_required_field() {
869 #[derive(Debug, serde::Deserialize)]
870 #[allow(dead_code)]
871 struct P {
872 name: String,
873 }
874 let map = serde_json::Map::new();
875 let err = deserialize_params::<P>(&map).unwrap_err();
876 assert!(matches!(err, ToolError::InvalidParams { .. }));
877 }
878
879 #[test]
880 fn deserialize_params_wrong_type() {
881 #[derive(Debug, serde::Deserialize)]
882 #[allow(dead_code)]
883 struct P {
884 count: u32,
885 }
886 let mut map = serde_json::Map::new();
887 map.insert("count".to_owned(), serde_json::json!("not a number"));
888 let err = deserialize_params::<P>(&map).unwrap_err();
889 assert!(matches!(err, ToolError::InvalidParams { .. }));
890 }
891
892 #[test]
893 fn deserialize_params_all_optional_empty() {
894 #[derive(Debug, serde::Deserialize, PartialEq)]
895 struct P {
896 name: Option<String>,
897 }
898 let map = serde_json::Map::new();
899 let p: P = deserialize_params(&map).unwrap();
900 assert_eq!(p, P { name: None });
901 }
902
903 #[test]
904 fn deserialize_params_ignores_extra_fields() {
905 #[derive(Debug, serde::Deserialize, PartialEq)]
906 struct P {
907 name: String,
908 }
909 let mut map = serde_json::Map::new();
910 map.insert("name".to_owned(), serde_json::json!("test"));
911 map.insert("extra".to_owned(), serde_json::json!(true));
912 let p: P = deserialize_params(&map).unwrap();
913 assert_eq!(
914 p,
915 P {
916 name: "test".to_owned()
917 }
918 );
919 }
920
921 #[test]
922 fn tool_error_execution_display() {
923 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash not found");
924 let err = ToolError::Execution(io_err);
925 assert!(err.to_string().starts_with("execution failed:"));
926 assert!(err.to_string().contains("bash not found"));
927 }
928
929 #[test]
931 fn error_kind_timeout_is_transient() {
932 let err = ToolError::Timeout { timeout_secs: 30 };
933 assert_eq!(err.kind(), ErrorKind::Transient);
934 }
935
936 #[test]
937 fn error_kind_blocked_is_permanent() {
938 let err = ToolError::Blocked {
939 command: "rm -rf /".to_owned(),
940 };
941 assert_eq!(err.kind(), ErrorKind::Permanent);
942 }
943
944 #[test]
945 fn error_kind_sandbox_violation_is_permanent() {
946 let err = ToolError::SandboxViolation {
947 path: "/etc/shadow".to_owned(),
948 };
949 assert_eq!(err.kind(), ErrorKind::Permanent);
950 }
951
952 #[test]
953 fn error_kind_cancelled_is_permanent() {
954 assert_eq!(ToolError::Cancelled.kind(), ErrorKind::Permanent);
955 }
956
957 #[test]
958 fn error_kind_invalid_params_is_permanent() {
959 let err = ToolError::InvalidParams {
960 message: "bad arg".to_owned(),
961 };
962 assert_eq!(err.kind(), ErrorKind::Permanent);
963 }
964
965 #[test]
966 fn error_kind_confirmation_required_is_permanent() {
967 let err = ToolError::ConfirmationRequired {
968 command: "rm /tmp/x".to_owned(),
969 };
970 assert_eq!(err.kind(), ErrorKind::Permanent);
971 }
972
973 #[test]
974 fn error_kind_execution_timed_out_is_transient() {
975 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
976 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
977 }
978
979 #[test]
980 fn error_kind_execution_interrupted_is_transient() {
981 let io_err = std::io::Error::new(std::io::ErrorKind::Interrupted, "interrupted");
982 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
983 }
984
985 #[test]
986 fn error_kind_execution_connection_reset_is_transient() {
987 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
988 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
989 }
990
991 #[test]
992 fn error_kind_execution_broken_pipe_is_transient() {
993 let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken");
994 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
995 }
996
997 #[test]
998 fn error_kind_execution_would_block_is_transient() {
999 let io_err = std::io::Error::new(std::io::ErrorKind::WouldBlock, "would block");
1000 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1001 }
1002
1003 #[test]
1004 fn error_kind_execution_connection_aborted_is_transient() {
1005 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionAborted, "aborted");
1006 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1007 }
1008
1009 #[test]
1010 fn error_kind_execution_not_found_is_permanent() {
1011 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
1012 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1013 }
1014
1015 #[test]
1016 fn error_kind_execution_permission_denied_is_permanent() {
1017 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
1018 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1019 }
1020
1021 #[test]
1022 fn error_kind_execution_other_is_permanent() {
1023 let io_err = std::io::Error::other("some other error");
1024 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1025 }
1026
1027 #[test]
1028 fn error_kind_execution_already_exists_is_permanent() {
1029 let io_err = std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists");
1030 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1031 }
1032
1033 #[test]
1034 fn error_kind_display() {
1035 assert_eq!(ErrorKind::Transient.to_string(), "transient");
1036 assert_eq!(ErrorKind::Permanent.to_string(), "permanent");
1037 }
1038
1039 #[test]
1040 fn truncate_tool_output_short_passthrough() {
1041 let short = "hello world";
1042 assert_eq!(truncate_tool_output(short), short);
1043 }
1044
1045 #[test]
1046 fn truncate_tool_output_exact_limit() {
1047 let exact = "a".repeat(MAX_TOOL_OUTPUT_CHARS);
1048 assert_eq!(truncate_tool_output(&exact), exact);
1049 }
1050
1051 #[test]
1052 fn truncate_tool_output_long_split() {
1053 let long = "x".repeat(MAX_TOOL_OUTPUT_CHARS + 1000);
1054 let result = truncate_tool_output(&long);
1055 assert!(result.contains("truncated"));
1056 assert!(result.len() < long.len());
1057 }
1058
1059 #[test]
1060 fn truncate_tool_output_notice_contains_count() {
1061 let long = "y".repeat(MAX_TOOL_OUTPUT_CHARS + 2000);
1062 let result = truncate_tool_output(&long);
1063 assert!(result.contains("truncated"));
1064 assert!(result.contains("chars"));
1065 }
1066
1067 #[derive(Debug)]
1068 struct DefaultExecutor;
1069 impl ToolExecutor for DefaultExecutor {
1070 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
1071 Ok(None)
1072 }
1073 }
1074
1075 #[tokio::test]
1076 async fn execute_tool_call_default_returns_none() {
1077 let exec = DefaultExecutor;
1078 let call = ToolCall {
1079 tool_id: ToolName::new("anything"),
1080 params: serde_json::Map::new(),
1081 caller_id: None,
1082 };
1083 let result = exec.execute_tool_call(&call).await.unwrap();
1084 assert!(result.is_none());
1085 }
1086
1087 #[test]
1088 fn filter_stats_savings_pct() {
1089 let fs = FilterStats {
1090 raw_chars: 1000,
1091 filtered_chars: 200,
1092 ..Default::default()
1093 };
1094 assert!((fs.savings_pct() - 80.0).abs() < 0.01);
1095 }
1096
1097 #[test]
1098 fn filter_stats_savings_pct_zero() {
1099 let fs = FilterStats::default();
1100 assert!((fs.savings_pct()).abs() < 0.01);
1101 }
1102
1103 #[test]
1104 fn filter_stats_estimated_tokens_saved() {
1105 let fs = FilterStats {
1106 raw_chars: 1000,
1107 filtered_chars: 200,
1108 ..Default::default()
1109 };
1110 assert_eq!(fs.estimated_tokens_saved(), 200); }
1112
1113 #[test]
1114 fn filter_stats_format_inline() {
1115 let fs = FilterStats {
1116 raw_chars: 1000,
1117 filtered_chars: 200,
1118 raw_lines: 342,
1119 filtered_lines: 28,
1120 ..Default::default()
1121 };
1122 let line = fs.format_inline("shell");
1123 assert_eq!(line, "[shell] 342 lines \u{2192} 28 lines, 80.0% filtered");
1124 }
1125
1126 #[test]
1127 fn filter_stats_format_inline_zero() {
1128 let fs = FilterStats::default();
1129 let line = fs.format_inline("bash");
1130 assert_eq!(line, "[bash] 0 lines \u{2192} 0 lines, 0.0% filtered");
1131 }
1132
1133 struct FixedExecutor {
1136 tool_id: &'static str,
1137 output: &'static str,
1138 }
1139
1140 impl ToolExecutor for FixedExecutor {
1141 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
1142 Ok(Some(ToolOutput {
1143 tool_name: ToolName::new(self.tool_id),
1144 summary: self.output.to_owned(),
1145 blocks_executed: 1,
1146 filter_stats: None,
1147 diff: None,
1148 streamed: false,
1149 terminal_id: None,
1150 locations: None,
1151 raw_response: None,
1152 claim_source: None,
1153 }))
1154 }
1155
1156 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
1157 vec![]
1158 }
1159
1160 async fn execute_tool_call(
1161 &self,
1162 _call: &ToolCall,
1163 ) -> Result<Option<ToolOutput>, ToolError> {
1164 Ok(Some(ToolOutput {
1165 tool_name: ToolName::new(self.tool_id),
1166 summary: self.output.to_owned(),
1167 blocks_executed: 1,
1168 filter_stats: None,
1169 diff: None,
1170 streamed: false,
1171 terminal_id: None,
1172 locations: None,
1173 raw_response: None,
1174 claim_source: None,
1175 }))
1176 }
1177 }
1178
1179 #[tokio::test]
1180 async fn dyn_executor_execute_delegates() {
1181 let inner = std::sync::Arc::new(FixedExecutor {
1182 tool_id: "bash",
1183 output: "hello",
1184 });
1185 let exec = DynExecutor(inner);
1186 let result = exec.execute("```bash\necho hello\n```").await.unwrap();
1187 assert!(result.is_some());
1188 assert_eq!(result.unwrap().summary, "hello");
1189 }
1190
1191 #[tokio::test]
1192 async fn dyn_executor_execute_confirmed_delegates() {
1193 let inner = std::sync::Arc::new(FixedExecutor {
1194 tool_id: "bash",
1195 output: "confirmed",
1196 });
1197 let exec = DynExecutor(inner);
1198 let result = exec.execute_confirmed("...").await.unwrap();
1199 assert!(result.is_some());
1200 assert_eq!(result.unwrap().summary, "confirmed");
1201 }
1202
1203 #[test]
1204 fn dyn_executor_tool_definitions_delegates() {
1205 let inner = std::sync::Arc::new(FixedExecutor {
1206 tool_id: "my_tool",
1207 output: "",
1208 });
1209 let exec = DynExecutor(inner);
1210 let defs = exec.tool_definitions();
1212 assert!(defs.is_empty());
1213 }
1214
1215 #[tokio::test]
1216 async fn dyn_executor_execute_tool_call_delegates() {
1217 let inner = std::sync::Arc::new(FixedExecutor {
1218 tool_id: "bash",
1219 output: "tool_call_result",
1220 });
1221 let exec = DynExecutor(inner);
1222 let call = ToolCall {
1223 tool_id: ToolName::new("bash"),
1224 params: serde_json::Map::new(),
1225 caller_id: None,
1226 };
1227 let result = exec.execute_tool_call(&call).await.unwrap();
1228 assert!(result.is_some());
1229 assert_eq!(result.unwrap().summary, "tool_call_result");
1230 }
1231
1232 #[test]
1233 fn dyn_executor_set_effective_trust_delegates() {
1234 use std::sync::atomic::{AtomicU8, Ordering};
1235
1236 struct TrustCapture(AtomicU8);
1237 impl ToolExecutor for TrustCapture {
1238 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
1239 Ok(None)
1240 }
1241 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
1242 let v = match level {
1244 crate::SkillTrustLevel::Trusted => 0u8,
1245 crate::SkillTrustLevel::Verified => 1,
1246 crate::SkillTrustLevel::Quarantined => 2,
1247 crate::SkillTrustLevel::Blocked => 3,
1248 };
1249 self.0.store(v, Ordering::Relaxed);
1250 }
1251 }
1252
1253 let inner = std::sync::Arc::new(TrustCapture(AtomicU8::new(0)));
1254 let exec =
1255 DynExecutor(std::sync::Arc::clone(&inner) as std::sync::Arc<dyn ErasedToolExecutor>);
1256 ToolExecutor::set_effective_trust(&exec, crate::SkillTrustLevel::Quarantined);
1257 assert_eq!(inner.0.load(Ordering::Relaxed), 2);
1258
1259 ToolExecutor::set_effective_trust(&exec, crate::SkillTrustLevel::Blocked);
1260 assert_eq!(inner.0.load(Ordering::Relaxed), 3);
1261 }
1262
1263 #[test]
1264 fn extract_fenced_blocks_no_prefix_match() {
1265 assert!(extract_fenced_blocks("```bashrc\nfoo\n```", "bash").is_empty());
1267 assert_eq!(
1269 extract_fenced_blocks("```bash\nfoo\n```", "bash"),
1270 vec!["foo"]
1271 );
1272 assert_eq!(
1274 extract_fenced_blocks("```bash \nfoo\n```", "bash"),
1275 vec!["foo"]
1276 );
1277 }
1278
1279 #[test]
1282 fn tool_error_http_400_category_is_invalid_parameters() {
1283 use crate::error_taxonomy::ToolErrorCategory;
1284 let err = ToolError::Http {
1285 status: 400,
1286 message: "bad request".to_owned(),
1287 };
1288 assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
1289 }
1290
1291 #[test]
1292 fn tool_error_http_401_category_is_policy_blocked() {
1293 use crate::error_taxonomy::ToolErrorCategory;
1294 let err = ToolError::Http {
1295 status: 401,
1296 message: "unauthorized".to_owned(),
1297 };
1298 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1299 }
1300
1301 #[test]
1302 fn tool_error_http_403_category_is_policy_blocked() {
1303 use crate::error_taxonomy::ToolErrorCategory;
1304 let err = ToolError::Http {
1305 status: 403,
1306 message: "forbidden".to_owned(),
1307 };
1308 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1309 }
1310
1311 #[test]
1312 fn tool_error_http_404_category_is_permanent_failure() {
1313 use crate::error_taxonomy::ToolErrorCategory;
1314 let err = ToolError::Http {
1315 status: 404,
1316 message: "not found".to_owned(),
1317 };
1318 assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
1319 }
1320
1321 #[test]
1322 fn tool_error_http_429_category_is_rate_limited() {
1323 use crate::error_taxonomy::ToolErrorCategory;
1324 let err = ToolError::Http {
1325 status: 429,
1326 message: "too many requests".to_owned(),
1327 };
1328 assert_eq!(err.category(), ToolErrorCategory::RateLimited);
1329 }
1330
1331 #[test]
1332 fn tool_error_http_500_category_is_server_error() {
1333 use crate::error_taxonomy::ToolErrorCategory;
1334 let err = ToolError::Http {
1335 status: 500,
1336 message: "internal server error".to_owned(),
1337 };
1338 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1339 }
1340
1341 #[test]
1342 fn tool_error_http_502_category_is_server_error() {
1343 use crate::error_taxonomy::ToolErrorCategory;
1344 let err = ToolError::Http {
1345 status: 502,
1346 message: "bad gateway".to_owned(),
1347 };
1348 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1349 }
1350
1351 #[test]
1352 fn tool_error_http_503_category_is_server_error() {
1353 use crate::error_taxonomy::ToolErrorCategory;
1354 let err = ToolError::Http {
1355 status: 503,
1356 message: "service unavailable".to_owned(),
1357 };
1358 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1359 }
1360
1361 #[test]
1362 fn tool_error_http_503_is_transient_triggers_phase2_retry() {
1363 let err = ToolError::Http {
1366 status: 503,
1367 message: "service unavailable".to_owned(),
1368 };
1369 assert_eq!(
1370 err.kind(),
1371 ErrorKind::Transient,
1372 "HTTP 503 must be Transient so Phase 2 retry fires"
1373 );
1374 }
1375
1376 #[test]
1377 fn tool_error_blocked_category_is_policy_blocked() {
1378 use crate::error_taxonomy::ToolErrorCategory;
1379 let err = ToolError::Blocked {
1380 command: "rm -rf /".to_owned(),
1381 };
1382 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1383 }
1384
1385 #[test]
1386 fn tool_error_sandbox_violation_category_is_policy_blocked() {
1387 use crate::error_taxonomy::ToolErrorCategory;
1388 let err = ToolError::SandboxViolation {
1389 path: "/etc/shadow".to_owned(),
1390 };
1391 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1392 }
1393
1394 #[test]
1395 fn tool_error_confirmation_required_category() {
1396 use crate::error_taxonomy::ToolErrorCategory;
1397 let err = ToolError::ConfirmationRequired {
1398 command: "rm /tmp/x".to_owned(),
1399 };
1400 assert_eq!(err.category(), ToolErrorCategory::ConfirmationRequired);
1401 }
1402
1403 #[test]
1404 fn tool_error_timeout_category() {
1405 use crate::error_taxonomy::ToolErrorCategory;
1406 let err = ToolError::Timeout { timeout_secs: 30 };
1407 assert_eq!(err.category(), ToolErrorCategory::Timeout);
1408 }
1409
1410 #[test]
1411 fn tool_error_cancelled_category() {
1412 use crate::error_taxonomy::ToolErrorCategory;
1413 assert_eq!(
1414 ToolError::Cancelled.category(),
1415 ToolErrorCategory::Cancelled
1416 );
1417 }
1418
1419 #[test]
1420 fn tool_error_invalid_params_category() {
1421 use crate::error_taxonomy::ToolErrorCategory;
1422 let err = ToolError::InvalidParams {
1423 message: "missing field".to_owned(),
1424 };
1425 assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
1426 }
1427
1428 #[test]
1430 fn tool_error_execution_not_found_category_is_permanent_failure() {
1431 use crate::error_taxonomy::ToolErrorCategory;
1432 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash: not found");
1433 let err = ToolError::Execution(io_err);
1434 let cat = err.category();
1435 assert_ne!(
1436 cat,
1437 ToolErrorCategory::ToolNotFound,
1438 "Execution(NotFound) must NOT map to ToolNotFound"
1439 );
1440 assert_eq!(cat, ToolErrorCategory::PermanentFailure);
1441 }
1442
1443 #[test]
1444 fn tool_error_execution_timed_out_category_is_timeout() {
1445 use crate::error_taxonomy::ToolErrorCategory;
1446 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
1447 assert_eq!(
1448 ToolError::Execution(io_err).category(),
1449 ToolErrorCategory::Timeout
1450 );
1451 }
1452
1453 #[test]
1454 fn tool_error_execution_connection_refused_category_is_network_error() {
1455 use crate::error_taxonomy::ToolErrorCategory;
1456 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
1457 assert_eq!(
1458 ToolError::Execution(io_err).category(),
1459 ToolErrorCategory::NetworkError
1460 );
1461 }
1462
1463 #[test]
1465 fn b4_tool_error_http_429_not_quality_failure() {
1466 let err = ToolError::Http {
1467 status: 429,
1468 message: "rate limited".to_owned(),
1469 };
1470 assert!(
1471 !err.category().is_quality_failure(),
1472 "RateLimited must not be a quality failure"
1473 );
1474 }
1475
1476 #[test]
1477 fn b4_tool_error_http_503_not_quality_failure() {
1478 let err = ToolError::Http {
1479 status: 503,
1480 message: "service unavailable".to_owned(),
1481 };
1482 assert!(
1483 !err.category().is_quality_failure(),
1484 "ServerError must not be a quality failure"
1485 );
1486 }
1487
1488 #[test]
1489 fn b4_tool_error_execution_timed_out_not_quality_failure() {
1490 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
1491 assert!(
1492 !ToolError::Execution(io_err).category().is_quality_failure(),
1493 "Timeout must not be a quality failure"
1494 );
1495 }
1496
1497 #[test]
1500 fn tool_error_shell_exit126_is_policy_blocked() {
1501 use crate::error_taxonomy::ToolErrorCategory;
1502 let err = ToolError::Shell {
1503 exit_code: 126,
1504 category: ToolErrorCategory::PolicyBlocked,
1505 message: "permission denied".to_owned(),
1506 };
1507 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1508 }
1509
1510 #[test]
1511 fn tool_error_shell_exit127_is_permanent_failure() {
1512 use crate::error_taxonomy::ToolErrorCategory;
1513 let err = ToolError::Shell {
1514 exit_code: 127,
1515 category: ToolErrorCategory::PermanentFailure,
1516 message: "command not found".to_owned(),
1517 };
1518 assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
1519 assert!(!err.category().is_retryable());
1520 }
1521
1522 #[test]
1523 fn tool_error_shell_not_quality_failure() {
1524 use crate::error_taxonomy::ToolErrorCategory;
1525 let err = ToolError::Shell {
1526 exit_code: 127,
1527 category: ToolErrorCategory::PermanentFailure,
1528 message: "command not found".to_owned(),
1529 };
1530 assert!(!err.category().is_quality_failure());
1532 }
1533}