Skip to main content

zeph_tools/
executor.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::fmt;
5
6/// Data for rendering file diffs in the TUI.
7#[derive(Debug, Clone)]
8pub struct DiffData {
9    pub file_path: String,
10    pub old_content: String,
11    pub new_content: String,
12}
13
14/// Structured tool invocation from LLM.
15#[derive(Debug, Clone)]
16pub struct ToolCall {
17    pub tool_id: String,
18    pub params: serde_json::Map<String, serde_json::Value>,
19}
20
21/// Cumulative filter statistics for a single tool execution.
22#[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/// Structured result from tool execution.
72#[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    /// Whether this tool already streamed its output via `ToolEvent` channel.
80    pub streamed: bool,
81    /// Terminal ID when the tool was executed via IDE terminal (ACP terminal/* protocol).
82    pub terminal_id: Option<String>,
83    /// File paths touched by this tool call, for IDE follow-along (e.g. `ToolCallLocation`).
84    pub locations: Option<Vec<String>>,
85    /// Structured tool response payload for ACP intermediate `tool_call_update` notifications.
86    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/// Truncate tool output that exceeds `MAX_TOOL_OUTPUT_CHARS` using head+tail split.
98#[must_use]
99pub fn truncate_tool_output(output: &str) -> String {
100    truncate_tool_output_at(output, MAX_TOOL_OUTPUT_CHARS)
101}
102
103/// Truncate tool output that exceeds `max_chars` using head+tail split.
104#[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/// Event emitted during tool execution for real-time UI updates.
123#[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/// Classifies a tool error as transient (retryable) or permanent (abort immediately).
147///
148/// Transient errors may succeed on retry (network blips, race conditions).
149/// Permanent errors will not succeed regardless of retries (policy, bad args, not found).
150#[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/// Errors that can occur during tool execution.
166#[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    /// HTTP or API error with status code for fine-grained classification.
190    ///
191    /// Used by `WebScrapeExecutor` and other HTTP-based tools to preserve the status
192    /// code for taxonomy classification. Scope: HTTP tools only (MCP uses a separate path).
193    #[error("HTTP error {status}: {message}")]
194    Http { status: u16, message: String },
195
196    /// Shell execution error with explicit exit code and pre-classified category.
197    ///
198    /// Used by `ShellExecutor` when the exit code or stderr content maps to a known
199    /// taxonomy category (e.g., exit 126 → `PolicyBlocked`, exit 127 → `PermanentFailure`).
200    /// Preserves the exit code for audit logging and the category for skill evolution.
201    #[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    /// Fine-grained error classification using the 12-category taxonomy.
211    ///
212    /// Prefer `category()` over `kind()` for new code. `kind()` is preserved for
213    /// backward compatibility and delegates to `category().error_kind()`.
214    #[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    /// Coarse classification for backward compatibility. Delegates to `category().error_kind()`.
232    ///
233    /// For `Execution(io::Error)`, the classification inspects `io::Error::kind()`:
234    /// - Transient: `TimedOut`, `WouldBlock`, `Interrupted`, `ConnectionReset`,
235    ///   `ConnectionAborted`, `BrokenPipe` — these may succeed on retry.
236    /// - Permanent: `NotFound`, `PermissionDenied`, `AlreadyExists`, and all other
237    ///   I/O error kinds — retrying would waste time with no benefit.
238    #[must_use]
239    pub fn kind(&self) -> ErrorKind {
240        self.category().error_kind()
241    }
242}
243
244/// Deserialize tool call params from a `serde_json::Map<String, Value>` into a typed struct.
245///
246/// # Errors
247///
248/// Returns `ToolError::InvalidParams` when deserialization fails.
249pub 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
258/// Async trait for tool execution backends (shell, future MCP, A2A).
259///
260/// Accepts the full LLM response and returns an optional output.
261/// Returns `None` when no tool invocation is detected in the response.
262pub trait ToolExecutor: Send + Sync {
263    fn execute(
264        &self,
265        response: &str,
266    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send;
267
268    /// Execute bypassing confirmation checks (called after user approves).
269    /// Default: delegates to `execute`.
270    fn execute_confirmed(
271        &self,
272        response: &str,
273    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
274        self.execute(response)
275    }
276
277    /// Return tool definitions this executor can handle.
278    fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
279        vec![]
280    }
281
282    /// Execute a structured tool call. Returns `None` if `tool_id` is not handled.
283    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    /// Execute a structured tool call bypassing confirmation checks.
291    ///
292    /// Called after the user has explicitly approved the tool invocation.
293    /// Default implementation delegates to `execute_tool_call`.
294    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    /// Inject environment variables for the currently active skill. No-op by default.
302    fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
303
304    /// Set the effective trust level for the currently active skill. No-op by default.
305    fn set_effective_trust(&self, _level: crate::TrustLevel) {}
306
307    /// Whether the executor can safely retry this tool call on a transient error.
308    ///
309    /// Only idempotent operations (e.g. read-only HTTP GET) should return `true`.
310    /// Shell commands and other non-idempotent operations must keep the default `false`
311    /// to prevent double-execution of side-effectful commands.
312    fn is_tool_retryable(&self, _tool_id: &str) -> bool {
313        false
314    }
315}
316
317/// Object-safe erased version of [`ToolExecutor`] using boxed futures.
318///
319/// Implemented automatically for all `T: ToolExecutor + 'static`.
320/// Use `Box<dyn ErasedToolExecutor>` when dynamic dispatch is required.
321pub 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        // TrustGateExecutor overrides ToolExecutor::execute_tool_call_confirmed; the blanket
345        // impl for T: ToolExecutor routes this call through it via execute_tool_call_confirmed_erased.
346        // Other implementors fall back to execute_tool_call_erased (normal enforcement path).
347        self.execute_tool_call_erased(call)
348    }
349
350    /// Inject environment variables for the currently active skill. No-op by default.
351    fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
352
353    /// Set the effective trust level for the currently active skill. No-op by default.
354    fn set_effective_trust(&self, _level: crate::TrustLevel) {}
355
356    /// Whether the executor can safely retry this tool call on a transient error.
357    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
410/// Wraps `Arc<dyn ErasedToolExecutor>` so it can be used as a concrete `ToolExecutor`.
411///
412/// Enables dynamic composition of tool executors at runtime without static type chains.
413pub 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        // Clone data to satisfy the 'static-ish bound: erased futures must not borrow self.
421        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/// Extract fenced code blocks with the given language marker from text.
471///
472/// Searches for `` ```{lang} `` … `` ``` `` pairs, returning trimmed content.
473#[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        // Word-boundary check: the character immediately after the marker must be
485        // whitespace, end-of-string, or a non-word character (not alphanumeric / _ / -).
486        // This prevents "```bash" from matching "```bashrc".
487        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    // ErrorKind classification tests
654    #[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); // (1000 - 200) / 4
834    }
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    // DynExecutor tests
857
858    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        // FixedExecutor returns empty definitions; verify delegation occurs without panic.
932        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                // encode: Trusted=0, Verified=1, Quarantined=2, Blocked=3
963                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        // ```bashrc must NOT match when searching for "bash"
986        assert!(extract_fenced_blocks("```bashrc\nfoo\n```", "bash").is_empty());
987        // exact match
988        assert_eq!(
989            extract_fenced_blocks("```bash\nfoo\n```", "bash"),
990            vec!["foo"]
991        );
992        // trailing space is fine
993        assert_eq!(
994            extract_fenced_blocks("```bash \nfoo\n```", "bash"),
995            vec!["foo"]
996        );
997    }
998
999    // ── ToolError::category() delegation tests ────────────────────────────────
1000
1001    #[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        // Phase 2 retry fires when err.kind() == ErrorKind::Transient.
1084        // Verify the full chain: Http{503} -> ServerError -> is_retryable() -> Transient.
1085        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    // B2 regression: Execution(NotFound) must NOT produce ToolNotFound.
1149    #[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    // B4 regression: Http/network/transient categories must NOT be quality failures.
1184    #[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    // ── ToolError::Shell category tests ──────────────────────────────────────
1218
1219    #[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        // Shell exit errors are not attributable to LLM output quality.
1251        assert!(!err.category().is_quality_failure());
1252    }
1253}