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