1use std::fmt;
5
6#[derive(Debug, Clone)]
8pub struct DiffData {
9 pub file_path: String,
10 pub old_content: String,
11 pub new_content: String,
12}
13
14#[derive(Debug, Clone)]
16pub struct ToolCall {
17 pub tool_id: String,
18 pub params: serde_json::Map<String, serde_json::Value>,
19}
20
21#[derive(Debug, Clone, Default)]
23pub struct FilterStats {
24 pub raw_chars: usize,
25 pub filtered_chars: usize,
26 pub raw_lines: usize,
27 pub filtered_lines: usize,
28 pub confidence: Option<crate::FilterConfidence>,
29 pub command: Option<String>,
30 pub kept_lines: Vec<usize>,
31}
32
33impl FilterStats {
34 #[must_use]
35 #[allow(clippy::cast_precision_loss)]
36 pub fn savings_pct(&self) -> f64 {
37 if self.raw_chars == 0 {
38 return 0.0;
39 }
40 (1.0 - self.filtered_chars as f64 / self.raw_chars as f64) * 100.0
41 }
42
43 #[must_use]
44 pub fn estimated_tokens_saved(&self) -> usize {
45 self.raw_chars.saturating_sub(self.filtered_chars) / 4
46 }
47
48 #[must_use]
49 pub fn format_inline(&self, tool_name: &str) -> String {
50 let cmd_label = self
51 .command
52 .as_deref()
53 .map(|c| {
54 let trimmed = c.trim();
55 if trimmed.len() > 60 {
56 format!(" `{}…`", &trimmed[..57])
57 } else {
58 format!(" `{trimmed}`")
59 }
60 })
61 .unwrap_or_default();
62 format!(
63 "[{tool_name}]{cmd_label} {} lines \u{2192} {} lines, {:.1}% filtered",
64 self.raw_lines,
65 self.filtered_lines,
66 self.savings_pct()
67 )
68 }
69}
70
71#[derive(Debug, Clone)]
73pub struct ToolOutput {
74 pub tool_name: String,
75 pub summary: String,
76 pub blocks_executed: u32,
77 pub filter_stats: Option<FilterStats>,
78 pub diff: Option<DiffData>,
79 pub streamed: bool,
81 pub terminal_id: Option<String>,
83 pub locations: Option<Vec<String>>,
85 pub raw_response: Option<serde_json::Value>,
87}
88
89impl fmt::Display for ToolOutput {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 f.write_str(&self.summary)
92 }
93}
94
95pub const MAX_TOOL_OUTPUT_CHARS: usize = 30_000;
96
97#[must_use]
99pub fn truncate_tool_output(output: &str) -> String {
100 truncate_tool_output_at(output, MAX_TOOL_OUTPUT_CHARS)
101}
102
103#[must_use]
105pub fn truncate_tool_output_at(output: &str, max_chars: usize) -> String {
106 if output.len() <= max_chars {
107 return output.to_string();
108 }
109
110 let half = max_chars / 2;
111 let head_end = output.floor_char_boundary(half);
112 let tail_start = output.ceil_char_boundary(output.len() - half);
113 let head = &output[..head_end];
114 let tail = &output[tail_start..];
115 let truncated = output.len() - head_end - (output.len() - tail_start);
116
117 format!(
118 "{head}\n\n... [truncated {truncated} chars, showing first and last ~{half} chars] ...\n\n{tail}"
119 )
120}
121
122#[derive(Debug, Clone)]
124pub enum ToolEvent {
125 Started {
126 tool_name: String,
127 command: String,
128 },
129 OutputChunk {
130 tool_name: String,
131 command: String,
132 chunk: String,
133 },
134 Completed {
135 tool_name: String,
136 command: String,
137 output: String,
138 success: bool,
139 filter_stats: Option<FilterStats>,
140 diff: Option<DiffData>,
141 },
142}
143
144pub type ToolEventTx = tokio::sync::mpsc::UnboundedSender<ToolEvent>;
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
151pub enum ErrorKind {
152 Transient,
153 Permanent,
154}
155
156impl std::fmt::Display for ErrorKind {
157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 match self {
159 Self::Transient => f.write_str("transient"),
160 Self::Permanent => f.write_str("permanent"),
161 }
162 }
163}
164
165#[derive(Debug, thiserror::Error)]
167pub enum ToolError {
168 #[error("command blocked by policy: {command}")]
169 Blocked { command: String },
170
171 #[error("path not allowed by sandbox: {path}")]
172 SandboxViolation { path: String },
173
174 #[error("command requires confirmation: {command}")]
175 ConfirmationRequired { command: String },
176
177 #[error("command timed out after {timeout_secs}s")]
178 Timeout { timeout_secs: u64 },
179
180 #[error("operation cancelled")]
181 Cancelled,
182
183 #[error("invalid tool parameters: {message}")]
184 InvalidParams { message: String },
185
186 #[error("execution failed: {0}")]
187 Execution(#[from] std::io::Error),
188
189 #[error("HTTP error {status}: {message}")]
194 Http { status: u16, message: String },
195
196 #[error("shell error (exit {exit_code}): {message}")]
202 Shell {
203 exit_code: i32,
204 category: crate::error_taxonomy::ToolErrorCategory,
205 message: String,
206 },
207}
208
209impl ToolError {
210 #[must_use]
215 pub fn category(&self) -> crate::error_taxonomy::ToolErrorCategory {
216 use crate::error_taxonomy::{ToolErrorCategory, classify_http_status, classify_io_error};
217 match self {
218 Self::Blocked { .. } | Self::SandboxViolation { .. } => {
219 ToolErrorCategory::PolicyBlocked
220 }
221 Self::ConfirmationRequired { .. } => ToolErrorCategory::ConfirmationRequired,
222 Self::Timeout { .. } => ToolErrorCategory::Timeout,
223 Self::Cancelled => ToolErrorCategory::Cancelled,
224 Self::InvalidParams { .. } => ToolErrorCategory::InvalidParameters,
225 Self::Http { status, .. } => classify_http_status(*status),
226 Self::Execution(io_err) => classify_io_error(io_err),
227 Self::Shell { category, .. } => *category,
228 }
229 }
230
231 #[must_use]
239 pub fn kind(&self) -> ErrorKind {
240 self.category().error_kind()
241 }
242}
243
244pub fn deserialize_params<T: serde::de::DeserializeOwned>(
250 params: &serde_json::Map<String, serde_json::Value>,
251) -> Result<T, ToolError> {
252 let obj = serde_json::Value::Object(params.clone());
253 serde_json::from_value(obj).map_err(|e| ToolError::InvalidParams {
254 message: e.to_string(),
255 })
256}
257
258pub trait ToolExecutor: Send + Sync {
263 fn execute(
264 &self,
265 response: &str,
266 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send;
267
268 fn execute_confirmed(
271 &self,
272 response: &str,
273 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
274 self.execute(response)
275 }
276
277 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
279 vec![]
280 }
281
282 fn execute_tool_call(
284 &self,
285 _call: &ToolCall,
286 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
287 std::future::ready(Ok(None))
288 }
289
290 fn execute_tool_call_confirmed(
295 &self,
296 call: &ToolCall,
297 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
298 self.execute_tool_call(call)
299 }
300
301 fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
303
304 fn set_effective_trust(&self, _level: crate::TrustLevel) {}
306
307 fn is_tool_retryable(&self, _tool_id: &str) -> bool {
313 false
314 }
315}
316
317pub trait ErasedToolExecutor: Send + Sync {
322 fn execute_erased<'a>(
323 &'a self,
324 response: &'a str,
325 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
326
327 fn execute_confirmed_erased<'a>(
328 &'a self,
329 response: &'a str,
330 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
331
332 fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef>;
333
334 fn execute_tool_call_erased<'a>(
335 &'a self,
336 call: &'a ToolCall,
337 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
338
339 fn execute_tool_call_confirmed_erased<'a>(
340 &'a self,
341 call: &'a ToolCall,
342 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
343 {
344 self.execute_tool_call_erased(call)
348 }
349
350 fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
352
353 fn set_effective_trust(&self, _level: crate::TrustLevel) {}
355
356 fn is_tool_retryable_erased(&self, tool_id: &str) -> bool;
358}
359
360impl<T: ToolExecutor> ErasedToolExecutor for T {
361 fn execute_erased<'a>(
362 &'a self,
363 response: &'a str,
364 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
365 {
366 Box::pin(self.execute(response))
367 }
368
369 fn execute_confirmed_erased<'a>(
370 &'a self,
371 response: &'a str,
372 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
373 {
374 Box::pin(self.execute_confirmed(response))
375 }
376
377 fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef> {
378 self.tool_definitions()
379 }
380
381 fn execute_tool_call_erased<'a>(
382 &'a self,
383 call: &'a ToolCall,
384 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
385 {
386 Box::pin(self.execute_tool_call(call))
387 }
388
389 fn execute_tool_call_confirmed_erased<'a>(
390 &'a self,
391 call: &'a ToolCall,
392 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
393 {
394 Box::pin(self.execute_tool_call_confirmed(call))
395 }
396
397 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
398 ToolExecutor::set_skill_env(self, env);
399 }
400
401 fn set_effective_trust(&self, level: crate::TrustLevel) {
402 ToolExecutor::set_effective_trust(self, level);
403 }
404
405 fn is_tool_retryable_erased(&self, tool_id: &str) -> bool {
406 ToolExecutor::is_tool_retryable(self, tool_id)
407 }
408}
409
410pub struct DynExecutor(pub std::sync::Arc<dyn ErasedToolExecutor>);
414
415impl ToolExecutor for DynExecutor {
416 fn execute(
417 &self,
418 response: &str,
419 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
420 let inner = std::sync::Arc::clone(&self.0);
422 let response = response.to_owned();
423 async move { inner.execute_erased(&response).await }
424 }
425
426 fn execute_confirmed(
427 &self,
428 response: &str,
429 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
430 let inner = std::sync::Arc::clone(&self.0);
431 let response = response.to_owned();
432 async move { inner.execute_confirmed_erased(&response).await }
433 }
434
435 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
436 self.0.tool_definitions_erased()
437 }
438
439 fn execute_tool_call(
440 &self,
441 call: &ToolCall,
442 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
443 let inner = std::sync::Arc::clone(&self.0);
444 let call = call.clone();
445 async move { inner.execute_tool_call_erased(&call).await }
446 }
447
448 fn execute_tool_call_confirmed(
449 &self,
450 call: &ToolCall,
451 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
452 let inner = std::sync::Arc::clone(&self.0);
453 let call = call.clone();
454 async move { inner.execute_tool_call_confirmed_erased(&call).await }
455 }
456
457 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
458 ErasedToolExecutor::set_skill_env(self.0.as_ref(), env);
459 }
460
461 fn set_effective_trust(&self, level: crate::TrustLevel) {
462 ErasedToolExecutor::set_effective_trust(self.0.as_ref(), level);
463 }
464
465 fn is_tool_retryable(&self, tool_id: &str) -> bool {
466 self.0.is_tool_retryable_erased(tool_id)
467 }
468}
469
470#[must_use]
474pub fn extract_fenced_blocks<'a>(text: &'a str, lang: &str) -> Vec<&'a str> {
475 let marker = format!("```{lang}");
476 let marker_len = marker.len();
477 let mut blocks = Vec::new();
478 let mut rest = text;
479
480 let mut search_from = 0;
481 while let Some(rel) = rest[search_from..].find(&marker) {
482 let start = search_from + rel;
483 let after = &rest[start + marker_len..];
484 let boundary_ok = after
488 .chars()
489 .next()
490 .is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '-');
491 if !boundary_ok {
492 search_from = start + marker_len;
493 continue;
494 }
495 if let Some(end) = after.find("```") {
496 blocks.push(after[..end].trim());
497 rest = &after[end + 3..];
498 search_from = 0;
499 } else {
500 break;
501 }
502 }
503
504 blocks
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[test]
512 fn tool_output_display() {
513 let output = ToolOutput {
514 tool_name: "bash".to_owned(),
515 summary: "$ echo hello\nhello".to_owned(),
516 blocks_executed: 1,
517 filter_stats: None,
518 diff: None,
519 streamed: false,
520 terminal_id: None,
521 locations: None,
522 raw_response: None,
523 };
524 assert_eq!(output.to_string(), "$ echo hello\nhello");
525 }
526
527 #[test]
528 fn tool_error_blocked_display() {
529 let err = ToolError::Blocked {
530 command: "rm -rf /".to_owned(),
531 };
532 assert_eq!(err.to_string(), "command blocked by policy: rm -rf /");
533 }
534
535 #[test]
536 fn tool_error_sandbox_violation_display() {
537 let err = ToolError::SandboxViolation {
538 path: "/etc/shadow".to_owned(),
539 };
540 assert_eq!(err.to_string(), "path not allowed by sandbox: /etc/shadow");
541 }
542
543 #[test]
544 fn tool_error_confirmation_required_display() {
545 let err = ToolError::ConfirmationRequired {
546 command: "rm -rf /tmp".to_owned(),
547 };
548 assert_eq!(
549 err.to_string(),
550 "command requires confirmation: rm -rf /tmp"
551 );
552 }
553
554 #[test]
555 fn tool_error_timeout_display() {
556 let err = ToolError::Timeout { timeout_secs: 30 };
557 assert_eq!(err.to_string(), "command timed out after 30s");
558 }
559
560 #[test]
561 fn tool_error_invalid_params_display() {
562 let err = ToolError::InvalidParams {
563 message: "missing field `command`".to_owned(),
564 };
565 assert_eq!(
566 err.to_string(),
567 "invalid tool parameters: missing field `command`"
568 );
569 }
570
571 #[test]
572 fn deserialize_params_valid() {
573 #[derive(Debug, serde::Deserialize, PartialEq)]
574 struct P {
575 name: String,
576 count: u32,
577 }
578 let mut map = serde_json::Map::new();
579 map.insert("name".to_owned(), serde_json::json!("test"));
580 map.insert("count".to_owned(), serde_json::json!(42));
581 let p: P = deserialize_params(&map).unwrap();
582 assert_eq!(
583 p,
584 P {
585 name: "test".to_owned(),
586 count: 42
587 }
588 );
589 }
590
591 #[test]
592 fn deserialize_params_missing_required_field() {
593 #[derive(Debug, serde::Deserialize)]
594 #[allow(dead_code)]
595 struct P {
596 name: String,
597 }
598 let map = serde_json::Map::new();
599 let err = deserialize_params::<P>(&map).unwrap_err();
600 assert!(matches!(err, ToolError::InvalidParams { .. }));
601 }
602
603 #[test]
604 fn deserialize_params_wrong_type() {
605 #[derive(Debug, serde::Deserialize)]
606 #[allow(dead_code)]
607 struct P {
608 count: u32,
609 }
610 let mut map = serde_json::Map::new();
611 map.insert("count".to_owned(), serde_json::json!("not a number"));
612 let err = deserialize_params::<P>(&map).unwrap_err();
613 assert!(matches!(err, ToolError::InvalidParams { .. }));
614 }
615
616 #[test]
617 fn deserialize_params_all_optional_empty() {
618 #[derive(Debug, serde::Deserialize, PartialEq)]
619 struct P {
620 name: Option<String>,
621 }
622 let map = serde_json::Map::new();
623 let p: P = deserialize_params(&map).unwrap();
624 assert_eq!(p, P { name: None });
625 }
626
627 #[test]
628 fn deserialize_params_ignores_extra_fields() {
629 #[derive(Debug, serde::Deserialize, PartialEq)]
630 struct P {
631 name: String,
632 }
633 let mut map = serde_json::Map::new();
634 map.insert("name".to_owned(), serde_json::json!("test"));
635 map.insert("extra".to_owned(), serde_json::json!(true));
636 let p: P = deserialize_params(&map).unwrap();
637 assert_eq!(
638 p,
639 P {
640 name: "test".to_owned()
641 }
642 );
643 }
644
645 #[test]
646 fn tool_error_execution_display() {
647 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash not found");
648 let err = ToolError::Execution(io_err);
649 assert!(err.to_string().starts_with("execution failed:"));
650 assert!(err.to_string().contains("bash not found"));
651 }
652
653 #[test]
655 fn error_kind_timeout_is_transient() {
656 let err = ToolError::Timeout { timeout_secs: 30 };
657 assert_eq!(err.kind(), ErrorKind::Transient);
658 }
659
660 #[test]
661 fn error_kind_blocked_is_permanent() {
662 let err = ToolError::Blocked {
663 command: "rm -rf /".to_owned(),
664 };
665 assert_eq!(err.kind(), ErrorKind::Permanent);
666 }
667
668 #[test]
669 fn error_kind_sandbox_violation_is_permanent() {
670 let err = ToolError::SandboxViolation {
671 path: "/etc/shadow".to_owned(),
672 };
673 assert_eq!(err.kind(), ErrorKind::Permanent);
674 }
675
676 #[test]
677 fn error_kind_cancelled_is_permanent() {
678 assert_eq!(ToolError::Cancelled.kind(), ErrorKind::Permanent);
679 }
680
681 #[test]
682 fn error_kind_invalid_params_is_permanent() {
683 let err = ToolError::InvalidParams {
684 message: "bad arg".to_owned(),
685 };
686 assert_eq!(err.kind(), ErrorKind::Permanent);
687 }
688
689 #[test]
690 fn error_kind_confirmation_required_is_permanent() {
691 let err = ToolError::ConfirmationRequired {
692 command: "rm /tmp/x".to_owned(),
693 };
694 assert_eq!(err.kind(), ErrorKind::Permanent);
695 }
696
697 #[test]
698 fn error_kind_execution_timed_out_is_transient() {
699 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
700 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
701 }
702
703 #[test]
704 fn error_kind_execution_interrupted_is_transient() {
705 let io_err = std::io::Error::new(std::io::ErrorKind::Interrupted, "interrupted");
706 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
707 }
708
709 #[test]
710 fn error_kind_execution_connection_reset_is_transient() {
711 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
712 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
713 }
714
715 #[test]
716 fn error_kind_execution_broken_pipe_is_transient() {
717 let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken");
718 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
719 }
720
721 #[test]
722 fn error_kind_execution_would_block_is_transient() {
723 let io_err = std::io::Error::new(std::io::ErrorKind::WouldBlock, "would block");
724 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
725 }
726
727 #[test]
728 fn error_kind_execution_connection_aborted_is_transient() {
729 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionAborted, "aborted");
730 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
731 }
732
733 #[test]
734 fn error_kind_execution_not_found_is_permanent() {
735 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
736 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
737 }
738
739 #[test]
740 fn error_kind_execution_permission_denied_is_permanent() {
741 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
742 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
743 }
744
745 #[test]
746 fn error_kind_execution_other_is_permanent() {
747 let io_err = std::io::Error::other("some other error");
748 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
749 }
750
751 #[test]
752 fn error_kind_execution_already_exists_is_permanent() {
753 let io_err = std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists");
754 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
755 }
756
757 #[test]
758 fn error_kind_display() {
759 assert_eq!(ErrorKind::Transient.to_string(), "transient");
760 assert_eq!(ErrorKind::Permanent.to_string(), "permanent");
761 }
762
763 #[test]
764 fn truncate_tool_output_short_passthrough() {
765 let short = "hello world";
766 assert_eq!(truncate_tool_output(short), short);
767 }
768
769 #[test]
770 fn truncate_tool_output_exact_limit() {
771 let exact = "a".repeat(MAX_TOOL_OUTPUT_CHARS);
772 assert_eq!(truncate_tool_output(&exact), exact);
773 }
774
775 #[test]
776 fn truncate_tool_output_long_split() {
777 let long = "x".repeat(MAX_TOOL_OUTPUT_CHARS + 1000);
778 let result = truncate_tool_output(&long);
779 assert!(result.contains("truncated"));
780 assert!(result.len() < long.len());
781 }
782
783 #[test]
784 fn truncate_tool_output_notice_contains_count() {
785 let long = "y".repeat(MAX_TOOL_OUTPUT_CHARS + 2000);
786 let result = truncate_tool_output(&long);
787 assert!(result.contains("truncated"));
788 assert!(result.contains("chars"));
789 }
790
791 #[derive(Debug)]
792 struct DefaultExecutor;
793 impl ToolExecutor for DefaultExecutor {
794 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
795 Ok(None)
796 }
797 }
798
799 #[tokio::test]
800 async fn execute_tool_call_default_returns_none() {
801 let exec = DefaultExecutor;
802 let call = ToolCall {
803 tool_id: "anything".to_owned(),
804 params: serde_json::Map::new(),
805 };
806 let result = exec.execute_tool_call(&call).await.unwrap();
807 assert!(result.is_none());
808 }
809
810 #[test]
811 fn filter_stats_savings_pct() {
812 let fs = FilterStats {
813 raw_chars: 1000,
814 filtered_chars: 200,
815 ..Default::default()
816 };
817 assert!((fs.savings_pct() - 80.0).abs() < 0.01);
818 }
819
820 #[test]
821 fn filter_stats_savings_pct_zero() {
822 let fs = FilterStats::default();
823 assert!((fs.savings_pct()).abs() < 0.01);
824 }
825
826 #[test]
827 fn filter_stats_estimated_tokens_saved() {
828 let fs = FilterStats {
829 raw_chars: 1000,
830 filtered_chars: 200,
831 ..Default::default()
832 };
833 assert_eq!(fs.estimated_tokens_saved(), 200); }
835
836 #[test]
837 fn filter_stats_format_inline() {
838 let fs = FilterStats {
839 raw_chars: 1000,
840 filtered_chars: 200,
841 raw_lines: 342,
842 filtered_lines: 28,
843 ..Default::default()
844 };
845 let line = fs.format_inline("shell");
846 assert_eq!(line, "[shell] 342 lines \u{2192} 28 lines, 80.0% filtered");
847 }
848
849 #[test]
850 fn filter_stats_format_inline_zero() {
851 let fs = FilterStats::default();
852 let line = fs.format_inline("bash");
853 assert_eq!(line, "[bash] 0 lines \u{2192} 0 lines, 0.0% filtered");
854 }
855
856 struct FixedExecutor {
859 tool_id: &'static str,
860 output: &'static str,
861 }
862
863 impl ToolExecutor for FixedExecutor {
864 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
865 Ok(Some(ToolOutput {
866 tool_name: self.tool_id.to_owned(),
867 summary: self.output.to_owned(),
868 blocks_executed: 1,
869 filter_stats: None,
870 diff: None,
871 streamed: false,
872 terminal_id: None,
873 locations: None,
874 raw_response: None,
875 }))
876 }
877
878 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
879 vec![]
880 }
881
882 async fn execute_tool_call(
883 &self,
884 _call: &ToolCall,
885 ) -> Result<Option<ToolOutput>, ToolError> {
886 Ok(Some(ToolOutput {
887 tool_name: self.tool_id.to_owned(),
888 summary: self.output.to_owned(),
889 blocks_executed: 1,
890 filter_stats: None,
891 diff: None,
892 streamed: false,
893 terminal_id: None,
894 locations: None,
895 raw_response: None,
896 }))
897 }
898 }
899
900 #[tokio::test]
901 async fn dyn_executor_execute_delegates() {
902 let inner = std::sync::Arc::new(FixedExecutor {
903 tool_id: "bash",
904 output: "hello",
905 });
906 let exec = DynExecutor(inner);
907 let result = exec.execute("```bash\necho hello\n```").await.unwrap();
908 assert!(result.is_some());
909 assert_eq!(result.unwrap().summary, "hello");
910 }
911
912 #[tokio::test]
913 async fn dyn_executor_execute_confirmed_delegates() {
914 let inner = std::sync::Arc::new(FixedExecutor {
915 tool_id: "bash",
916 output: "confirmed",
917 });
918 let exec = DynExecutor(inner);
919 let result = exec.execute_confirmed("...").await.unwrap();
920 assert!(result.is_some());
921 assert_eq!(result.unwrap().summary, "confirmed");
922 }
923
924 #[test]
925 fn dyn_executor_tool_definitions_delegates() {
926 let inner = std::sync::Arc::new(FixedExecutor {
927 tool_id: "my_tool",
928 output: "",
929 });
930 let exec = DynExecutor(inner);
931 let defs = exec.tool_definitions();
933 assert!(defs.is_empty());
934 }
935
936 #[tokio::test]
937 async fn dyn_executor_execute_tool_call_delegates() {
938 let inner = std::sync::Arc::new(FixedExecutor {
939 tool_id: "bash",
940 output: "tool_call_result",
941 });
942 let exec = DynExecutor(inner);
943 let call = ToolCall {
944 tool_id: "bash".to_owned(),
945 params: serde_json::Map::new(),
946 };
947 let result = exec.execute_tool_call(&call).await.unwrap();
948 assert!(result.is_some());
949 assert_eq!(result.unwrap().summary, "tool_call_result");
950 }
951
952 #[test]
953 fn dyn_executor_set_effective_trust_delegates() {
954 use std::sync::atomic::{AtomicU8, Ordering};
955
956 struct TrustCapture(AtomicU8);
957 impl ToolExecutor for TrustCapture {
958 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
959 Ok(None)
960 }
961 fn set_effective_trust(&self, level: crate::TrustLevel) {
962 let v = match level {
964 crate::TrustLevel::Trusted => 0u8,
965 crate::TrustLevel::Verified => 1,
966 crate::TrustLevel::Quarantined => 2,
967 crate::TrustLevel::Blocked => 3,
968 };
969 self.0.store(v, Ordering::Relaxed);
970 }
971 }
972
973 let inner = std::sync::Arc::new(TrustCapture(AtomicU8::new(0)));
974 let exec =
975 DynExecutor(std::sync::Arc::clone(&inner) as std::sync::Arc<dyn ErasedToolExecutor>);
976 ToolExecutor::set_effective_trust(&exec, crate::TrustLevel::Quarantined);
977 assert_eq!(inner.0.load(Ordering::Relaxed), 2);
978
979 ToolExecutor::set_effective_trust(&exec, crate::TrustLevel::Blocked);
980 assert_eq!(inner.0.load(Ordering::Relaxed), 3);
981 }
982
983 #[test]
984 fn extract_fenced_blocks_no_prefix_match() {
985 assert!(extract_fenced_blocks("```bashrc\nfoo\n```", "bash").is_empty());
987 assert_eq!(
989 extract_fenced_blocks("```bash\nfoo\n```", "bash"),
990 vec!["foo"]
991 );
992 assert_eq!(
994 extract_fenced_blocks("```bash \nfoo\n```", "bash"),
995 vec!["foo"]
996 );
997 }
998
999 #[test]
1002 fn tool_error_http_400_category_is_invalid_parameters() {
1003 use crate::error_taxonomy::ToolErrorCategory;
1004 let err = ToolError::Http {
1005 status: 400,
1006 message: "bad request".to_owned(),
1007 };
1008 assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
1009 }
1010
1011 #[test]
1012 fn tool_error_http_401_category_is_policy_blocked() {
1013 use crate::error_taxonomy::ToolErrorCategory;
1014 let err = ToolError::Http {
1015 status: 401,
1016 message: "unauthorized".to_owned(),
1017 };
1018 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1019 }
1020
1021 #[test]
1022 fn tool_error_http_403_category_is_policy_blocked() {
1023 use crate::error_taxonomy::ToolErrorCategory;
1024 let err = ToolError::Http {
1025 status: 403,
1026 message: "forbidden".to_owned(),
1027 };
1028 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1029 }
1030
1031 #[test]
1032 fn tool_error_http_404_category_is_permanent_failure() {
1033 use crate::error_taxonomy::ToolErrorCategory;
1034 let err = ToolError::Http {
1035 status: 404,
1036 message: "not found".to_owned(),
1037 };
1038 assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
1039 }
1040
1041 #[test]
1042 fn tool_error_http_429_category_is_rate_limited() {
1043 use crate::error_taxonomy::ToolErrorCategory;
1044 let err = ToolError::Http {
1045 status: 429,
1046 message: "too many requests".to_owned(),
1047 };
1048 assert_eq!(err.category(), ToolErrorCategory::RateLimited);
1049 }
1050
1051 #[test]
1052 fn tool_error_http_500_category_is_server_error() {
1053 use crate::error_taxonomy::ToolErrorCategory;
1054 let err = ToolError::Http {
1055 status: 500,
1056 message: "internal server error".to_owned(),
1057 };
1058 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1059 }
1060
1061 #[test]
1062 fn tool_error_http_502_category_is_server_error() {
1063 use crate::error_taxonomy::ToolErrorCategory;
1064 let err = ToolError::Http {
1065 status: 502,
1066 message: "bad gateway".to_owned(),
1067 };
1068 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1069 }
1070
1071 #[test]
1072 fn tool_error_http_503_category_is_server_error() {
1073 use crate::error_taxonomy::ToolErrorCategory;
1074 let err = ToolError::Http {
1075 status: 503,
1076 message: "service unavailable".to_owned(),
1077 };
1078 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1079 }
1080
1081 #[test]
1082 fn tool_error_http_503_is_transient_triggers_phase2_retry() {
1083 let err = ToolError::Http {
1086 status: 503,
1087 message: "service unavailable".to_owned(),
1088 };
1089 assert_eq!(
1090 err.kind(),
1091 ErrorKind::Transient,
1092 "HTTP 503 must be Transient so Phase 2 retry fires"
1093 );
1094 }
1095
1096 #[test]
1097 fn tool_error_blocked_category_is_policy_blocked() {
1098 use crate::error_taxonomy::ToolErrorCategory;
1099 let err = ToolError::Blocked {
1100 command: "rm -rf /".to_owned(),
1101 };
1102 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1103 }
1104
1105 #[test]
1106 fn tool_error_sandbox_violation_category_is_policy_blocked() {
1107 use crate::error_taxonomy::ToolErrorCategory;
1108 let err = ToolError::SandboxViolation {
1109 path: "/etc/shadow".to_owned(),
1110 };
1111 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1112 }
1113
1114 #[test]
1115 fn tool_error_confirmation_required_category() {
1116 use crate::error_taxonomy::ToolErrorCategory;
1117 let err = ToolError::ConfirmationRequired {
1118 command: "rm /tmp/x".to_owned(),
1119 };
1120 assert_eq!(err.category(), ToolErrorCategory::ConfirmationRequired);
1121 }
1122
1123 #[test]
1124 fn tool_error_timeout_category() {
1125 use crate::error_taxonomy::ToolErrorCategory;
1126 let err = ToolError::Timeout { timeout_secs: 30 };
1127 assert_eq!(err.category(), ToolErrorCategory::Timeout);
1128 }
1129
1130 #[test]
1131 fn tool_error_cancelled_category() {
1132 use crate::error_taxonomy::ToolErrorCategory;
1133 assert_eq!(
1134 ToolError::Cancelled.category(),
1135 ToolErrorCategory::Cancelled
1136 );
1137 }
1138
1139 #[test]
1140 fn tool_error_invalid_params_category() {
1141 use crate::error_taxonomy::ToolErrorCategory;
1142 let err = ToolError::InvalidParams {
1143 message: "missing field".to_owned(),
1144 };
1145 assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
1146 }
1147
1148 #[test]
1150 fn tool_error_execution_not_found_category_is_permanent_failure() {
1151 use crate::error_taxonomy::ToolErrorCategory;
1152 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash: not found");
1153 let err = ToolError::Execution(io_err);
1154 let cat = err.category();
1155 assert_ne!(
1156 cat,
1157 ToolErrorCategory::ToolNotFound,
1158 "Execution(NotFound) must NOT map to ToolNotFound"
1159 );
1160 assert_eq!(cat, ToolErrorCategory::PermanentFailure);
1161 }
1162
1163 #[test]
1164 fn tool_error_execution_timed_out_category_is_timeout() {
1165 use crate::error_taxonomy::ToolErrorCategory;
1166 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
1167 assert_eq!(
1168 ToolError::Execution(io_err).category(),
1169 ToolErrorCategory::Timeout
1170 );
1171 }
1172
1173 #[test]
1174 fn tool_error_execution_connection_refused_category_is_network_error() {
1175 use crate::error_taxonomy::ToolErrorCategory;
1176 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
1177 assert_eq!(
1178 ToolError::Execution(io_err).category(),
1179 ToolErrorCategory::NetworkError
1180 );
1181 }
1182
1183 #[test]
1185 fn b4_tool_error_http_429_not_quality_failure() {
1186 let err = ToolError::Http {
1187 status: 429,
1188 message: "rate limited".to_owned(),
1189 };
1190 assert!(
1191 !err.category().is_quality_failure(),
1192 "RateLimited must not be a quality failure"
1193 );
1194 }
1195
1196 #[test]
1197 fn b4_tool_error_http_503_not_quality_failure() {
1198 let err = ToolError::Http {
1199 status: 503,
1200 message: "service unavailable".to_owned(),
1201 };
1202 assert!(
1203 !err.category().is_quality_failure(),
1204 "ServerError must not be a quality failure"
1205 );
1206 }
1207
1208 #[test]
1209 fn b4_tool_error_execution_timed_out_not_quality_failure() {
1210 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
1211 assert!(
1212 !ToolError::Execution(io_err).category().is_quality_failure(),
1213 "Timeout must not be a quality failure"
1214 );
1215 }
1216
1217 #[test]
1220 fn tool_error_shell_exit126_is_policy_blocked() {
1221 use crate::error_taxonomy::ToolErrorCategory;
1222 let err = ToolError::Shell {
1223 exit_code: 126,
1224 category: ToolErrorCategory::PolicyBlocked,
1225 message: "permission denied".to_owned(),
1226 };
1227 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1228 }
1229
1230 #[test]
1231 fn tool_error_shell_exit127_is_permanent_failure() {
1232 use crate::error_taxonomy::ToolErrorCategory;
1233 let err = ToolError::Shell {
1234 exit_code: 127,
1235 category: ToolErrorCategory::PermanentFailure,
1236 message: "command not found".to_owned(),
1237 };
1238 assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
1239 assert!(!err.category().is_retryable());
1240 }
1241
1242 #[test]
1243 fn tool_error_shell_not_quality_failure() {
1244 use crate::error_taxonomy::ToolErrorCategory;
1245 let err = ToolError::Shell {
1246 exit_code: 127,
1247 category: ToolErrorCategory::PermanentFailure,
1248 message: "command not found".to_owned(),
1249 };
1250 assert!(!err.category().is_quality_failure());
1252 }
1253}