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
190impl ToolError {
191    /// Classify this error as transient (retryable) or permanent.
192    ///
193    /// For `Execution(io::Error)`, the classification inspects `io::Error::kind()`:
194    /// - Transient: `TimedOut`, `WouldBlock`, `Interrupted`, `ConnectionReset`,
195    ///   `ConnectionAborted`, `BrokenPipe` — these may succeed on retry.
196    /// - Permanent: `NotFound`, `PermissionDenied`, `AlreadyExists`, and all other
197    ///   I/O error kinds — retrying would waste time with no benefit.
198    #[must_use]
199    pub fn kind(&self) -> ErrorKind {
200        match self {
201            Self::Timeout { .. } => ErrorKind::Transient,
202            Self::Execution(io_err) => match io_err.kind() {
203                std::io::ErrorKind::TimedOut
204                | std::io::ErrorKind::WouldBlock
205                | std::io::ErrorKind::Interrupted
206                | std::io::ErrorKind::ConnectionReset
207                | std::io::ErrorKind::ConnectionAborted
208                | std::io::ErrorKind::BrokenPipe => ErrorKind::Transient,
209                // NotFound, PermissionDenied, AlreadyExists, and everything else: permanent.
210                _ => ErrorKind::Permanent,
211            },
212            Self::Blocked { .. }
213            | Self::SandboxViolation { .. }
214            | Self::ConfirmationRequired { .. }
215            | Self::Cancelled
216            | Self::InvalidParams { .. } => ErrorKind::Permanent,
217        }
218    }
219}
220
221/// Deserialize tool call params from a `serde_json::Map<String, Value>` into a typed struct.
222///
223/// # Errors
224///
225/// Returns `ToolError::InvalidParams` when deserialization fails.
226pub fn deserialize_params<T: serde::de::DeserializeOwned>(
227    params: &serde_json::Map<String, serde_json::Value>,
228) -> Result<T, ToolError> {
229    let obj = serde_json::Value::Object(params.clone());
230    serde_json::from_value(obj).map_err(|e| ToolError::InvalidParams {
231        message: e.to_string(),
232    })
233}
234
235/// Async trait for tool execution backends (shell, future MCP, A2A).
236///
237/// Accepts the full LLM response and returns an optional output.
238/// Returns `None` when no tool invocation is detected in the response.
239pub trait ToolExecutor: Send + Sync {
240    fn execute(
241        &self,
242        response: &str,
243    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send;
244
245    /// Execute bypassing confirmation checks (called after user approves).
246    /// Default: delegates to `execute`.
247    fn execute_confirmed(
248        &self,
249        response: &str,
250    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
251        self.execute(response)
252    }
253
254    /// Return tool definitions this executor can handle.
255    fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
256        vec![]
257    }
258
259    /// Execute a structured tool call. Returns `None` if `tool_id` is not handled.
260    fn execute_tool_call(
261        &self,
262        _call: &ToolCall,
263    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
264        std::future::ready(Ok(None))
265    }
266
267    /// Execute a structured tool call bypassing confirmation checks.
268    ///
269    /// Called after the user has explicitly approved the tool invocation.
270    /// Default implementation delegates to `execute_tool_call`.
271    fn execute_tool_call_confirmed(
272        &self,
273        call: &ToolCall,
274    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
275        self.execute_tool_call(call)
276    }
277
278    /// Inject environment variables for the currently active skill. No-op by default.
279    fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
280
281    /// Set the effective trust level for the currently active skill. No-op by default.
282    fn set_effective_trust(&self, _level: crate::TrustLevel) {}
283
284    /// Whether the executor can safely retry this tool call on a transient error.
285    ///
286    /// Only idempotent operations (e.g. read-only HTTP GET) should return `true`.
287    /// Shell commands and other non-idempotent operations must keep the default `false`
288    /// to prevent double-execution of side-effectful commands.
289    fn is_tool_retryable(&self, _tool_id: &str) -> bool {
290        false
291    }
292}
293
294/// Object-safe erased version of [`ToolExecutor`] using boxed futures.
295///
296/// Implemented automatically for all `T: ToolExecutor + 'static`.
297/// Use `Box<dyn ErasedToolExecutor>` when dynamic dispatch is required.
298pub trait ErasedToolExecutor: Send + Sync {
299    fn execute_erased<'a>(
300        &'a self,
301        response: &'a str,
302    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
303
304    fn execute_confirmed_erased<'a>(
305        &'a self,
306        response: &'a str,
307    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
308
309    fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef>;
310
311    fn execute_tool_call_erased<'a>(
312        &'a self,
313        call: &'a ToolCall,
314    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
315
316    fn execute_tool_call_confirmed_erased<'a>(
317        &'a self,
318        call: &'a ToolCall,
319    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
320    {
321        // TrustGateExecutor overrides ToolExecutor::execute_tool_call_confirmed; the blanket
322        // impl for T: ToolExecutor routes this call through it via execute_tool_call_confirmed_erased.
323        // Other implementors fall back to execute_tool_call_erased (normal enforcement path).
324        self.execute_tool_call_erased(call)
325    }
326
327    /// Inject environment variables for the currently active skill. No-op by default.
328    fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
329
330    /// Set the effective trust level for the currently active skill. No-op by default.
331    fn set_effective_trust(&self, _level: crate::TrustLevel) {}
332
333    /// Whether the executor can safely retry this tool call on a transient error.
334    fn is_tool_retryable_erased(&self, tool_id: &str) -> bool;
335}
336
337impl<T: ToolExecutor> ErasedToolExecutor for T {
338    fn execute_erased<'a>(
339        &'a self,
340        response: &'a str,
341    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
342    {
343        Box::pin(self.execute(response))
344    }
345
346    fn execute_confirmed_erased<'a>(
347        &'a self,
348        response: &'a str,
349    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
350    {
351        Box::pin(self.execute_confirmed(response))
352    }
353
354    fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef> {
355        self.tool_definitions()
356    }
357
358    fn execute_tool_call_erased<'a>(
359        &'a self,
360        call: &'a ToolCall,
361    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
362    {
363        Box::pin(self.execute_tool_call(call))
364    }
365
366    fn execute_tool_call_confirmed_erased<'a>(
367        &'a self,
368        call: &'a ToolCall,
369    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
370    {
371        Box::pin(self.execute_tool_call_confirmed(call))
372    }
373
374    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
375        ToolExecutor::set_skill_env(self, env);
376    }
377
378    fn set_effective_trust(&self, level: crate::TrustLevel) {
379        ToolExecutor::set_effective_trust(self, level);
380    }
381
382    fn is_tool_retryable_erased(&self, tool_id: &str) -> bool {
383        ToolExecutor::is_tool_retryable(self, tool_id)
384    }
385}
386
387/// Wraps `Arc<dyn ErasedToolExecutor>` so it can be used as a concrete `ToolExecutor`.
388///
389/// Enables dynamic composition of tool executors at runtime without static type chains.
390pub struct DynExecutor(pub std::sync::Arc<dyn ErasedToolExecutor>);
391
392impl ToolExecutor for DynExecutor {
393    fn execute(
394        &self,
395        response: &str,
396    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
397        // Clone data to satisfy the 'static-ish bound: erased futures must not borrow self.
398        let inner = std::sync::Arc::clone(&self.0);
399        let response = response.to_owned();
400        async move { inner.execute_erased(&response).await }
401    }
402
403    fn execute_confirmed(
404        &self,
405        response: &str,
406    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
407        let inner = std::sync::Arc::clone(&self.0);
408        let response = response.to_owned();
409        async move { inner.execute_confirmed_erased(&response).await }
410    }
411
412    fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
413        self.0.tool_definitions_erased()
414    }
415
416    fn execute_tool_call(
417        &self,
418        call: &ToolCall,
419    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
420        let inner = std::sync::Arc::clone(&self.0);
421        let call = call.clone();
422        async move { inner.execute_tool_call_erased(&call).await }
423    }
424
425    fn execute_tool_call_confirmed(
426        &self,
427        call: &ToolCall,
428    ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
429        let inner = std::sync::Arc::clone(&self.0);
430        let call = call.clone();
431        async move { inner.execute_tool_call_confirmed_erased(&call).await }
432    }
433
434    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
435        ErasedToolExecutor::set_skill_env(self.0.as_ref(), env);
436    }
437
438    fn set_effective_trust(&self, level: crate::TrustLevel) {
439        ErasedToolExecutor::set_effective_trust(self.0.as_ref(), level);
440    }
441
442    fn is_tool_retryable(&self, tool_id: &str) -> bool {
443        self.0.is_tool_retryable_erased(tool_id)
444    }
445}
446
447/// Extract fenced code blocks with the given language marker from text.
448///
449/// Searches for `` ```{lang} `` … `` ``` `` pairs, returning trimmed content.
450#[must_use]
451pub fn extract_fenced_blocks<'a>(text: &'a str, lang: &str) -> Vec<&'a str> {
452    let marker = format!("```{lang}");
453    let marker_len = marker.len();
454    let mut blocks = Vec::new();
455    let mut rest = text;
456
457    let mut search_from = 0;
458    while let Some(rel) = rest[search_from..].find(&marker) {
459        let start = search_from + rel;
460        let after = &rest[start + marker_len..];
461        // Word-boundary check: the character immediately after the marker must be
462        // whitespace, end-of-string, or a non-word character (not alphanumeric / _ / -).
463        // This prevents "```bash" from matching "```bashrc".
464        let boundary_ok = after
465            .chars()
466            .next()
467            .is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '-');
468        if !boundary_ok {
469            search_from = start + marker_len;
470            continue;
471        }
472        if let Some(end) = after.find("```") {
473            blocks.push(after[..end].trim());
474            rest = &after[end + 3..];
475            search_from = 0;
476        } else {
477            break;
478        }
479    }
480
481    blocks
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    #[test]
489    fn tool_output_display() {
490        let output = ToolOutput {
491            tool_name: "bash".to_owned(),
492            summary: "$ echo hello\nhello".to_owned(),
493            blocks_executed: 1,
494            filter_stats: None,
495            diff: None,
496            streamed: false,
497            terminal_id: None,
498            locations: None,
499            raw_response: None,
500        };
501        assert_eq!(output.to_string(), "$ echo hello\nhello");
502    }
503
504    #[test]
505    fn tool_error_blocked_display() {
506        let err = ToolError::Blocked {
507            command: "rm -rf /".to_owned(),
508        };
509        assert_eq!(err.to_string(), "command blocked by policy: rm -rf /");
510    }
511
512    #[test]
513    fn tool_error_sandbox_violation_display() {
514        let err = ToolError::SandboxViolation {
515            path: "/etc/shadow".to_owned(),
516        };
517        assert_eq!(err.to_string(), "path not allowed by sandbox: /etc/shadow");
518    }
519
520    #[test]
521    fn tool_error_confirmation_required_display() {
522        let err = ToolError::ConfirmationRequired {
523            command: "rm -rf /tmp".to_owned(),
524        };
525        assert_eq!(
526            err.to_string(),
527            "command requires confirmation: rm -rf /tmp"
528        );
529    }
530
531    #[test]
532    fn tool_error_timeout_display() {
533        let err = ToolError::Timeout { timeout_secs: 30 };
534        assert_eq!(err.to_string(), "command timed out after 30s");
535    }
536
537    #[test]
538    fn tool_error_invalid_params_display() {
539        let err = ToolError::InvalidParams {
540            message: "missing field `command`".to_owned(),
541        };
542        assert_eq!(
543            err.to_string(),
544            "invalid tool parameters: missing field `command`"
545        );
546    }
547
548    #[test]
549    fn deserialize_params_valid() {
550        #[derive(Debug, serde::Deserialize, PartialEq)]
551        struct P {
552            name: String,
553            count: u32,
554        }
555        let mut map = serde_json::Map::new();
556        map.insert("name".to_owned(), serde_json::json!("test"));
557        map.insert("count".to_owned(), serde_json::json!(42));
558        let p: P = deserialize_params(&map).unwrap();
559        assert_eq!(
560            p,
561            P {
562                name: "test".to_owned(),
563                count: 42
564            }
565        );
566    }
567
568    #[test]
569    fn deserialize_params_missing_required_field() {
570        #[derive(Debug, serde::Deserialize)]
571        #[allow(dead_code)]
572        struct P {
573            name: String,
574        }
575        let map = serde_json::Map::new();
576        let err = deserialize_params::<P>(&map).unwrap_err();
577        assert!(matches!(err, ToolError::InvalidParams { .. }));
578    }
579
580    #[test]
581    fn deserialize_params_wrong_type() {
582        #[derive(Debug, serde::Deserialize)]
583        #[allow(dead_code)]
584        struct P {
585            count: u32,
586        }
587        let mut map = serde_json::Map::new();
588        map.insert("count".to_owned(), serde_json::json!("not a number"));
589        let err = deserialize_params::<P>(&map).unwrap_err();
590        assert!(matches!(err, ToolError::InvalidParams { .. }));
591    }
592
593    #[test]
594    fn deserialize_params_all_optional_empty() {
595        #[derive(Debug, serde::Deserialize, PartialEq)]
596        struct P {
597            name: Option<String>,
598        }
599        let map = serde_json::Map::new();
600        let p: P = deserialize_params(&map).unwrap();
601        assert_eq!(p, P { name: None });
602    }
603
604    #[test]
605    fn deserialize_params_ignores_extra_fields() {
606        #[derive(Debug, serde::Deserialize, PartialEq)]
607        struct P {
608            name: String,
609        }
610        let mut map = serde_json::Map::new();
611        map.insert("name".to_owned(), serde_json::json!("test"));
612        map.insert("extra".to_owned(), serde_json::json!(true));
613        let p: P = deserialize_params(&map).unwrap();
614        assert_eq!(
615            p,
616            P {
617                name: "test".to_owned()
618            }
619        );
620    }
621
622    #[test]
623    fn tool_error_execution_display() {
624        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash not found");
625        let err = ToolError::Execution(io_err);
626        assert!(err.to_string().starts_with("execution failed:"));
627        assert!(err.to_string().contains("bash not found"));
628    }
629
630    // ErrorKind classification tests
631    #[test]
632    fn error_kind_timeout_is_transient() {
633        let err = ToolError::Timeout { timeout_secs: 30 };
634        assert_eq!(err.kind(), ErrorKind::Transient);
635    }
636
637    #[test]
638    fn error_kind_blocked_is_permanent() {
639        let err = ToolError::Blocked {
640            command: "rm -rf /".to_owned(),
641        };
642        assert_eq!(err.kind(), ErrorKind::Permanent);
643    }
644
645    #[test]
646    fn error_kind_sandbox_violation_is_permanent() {
647        let err = ToolError::SandboxViolation {
648            path: "/etc/shadow".to_owned(),
649        };
650        assert_eq!(err.kind(), ErrorKind::Permanent);
651    }
652
653    #[test]
654    fn error_kind_cancelled_is_permanent() {
655        assert_eq!(ToolError::Cancelled.kind(), ErrorKind::Permanent);
656    }
657
658    #[test]
659    fn error_kind_invalid_params_is_permanent() {
660        let err = ToolError::InvalidParams {
661            message: "bad arg".to_owned(),
662        };
663        assert_eq!(err.kind(), ErrorKind::Permanent);
664    }
665
666    #[test]
667    fn error_kind_confirmation_required_is_permanent() {
668        let err = ToolError::ConfirmationRequired {
669            command: "rm /tmp/x".to_owned(),
670        };
671        assert_eq!(err.kind(), ErrorKind::Permanent);
672    }
673
674    #[test]
675    fn error_kind_execution_timed_out_is_transient() {
676        let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
677        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
678    }
679
680    #[test]
681    fn error_kind_execution_interrupted_is_transient() {
682        let io_err = std::io::Error::new(std::io::ErrorKind::Interrupted, "interrupted");
683        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
684    }
685
686    #[test]
687    fn error_kind_execution_connection_reset_is_transient() {
688        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
689        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
690    }
691
692    #[test]
693    fn error_kind_execution_broken_pipe_is_transient() {
694        let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken");
695        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
696    }
697
698    #[test]
699    fn error_kind_execution_would_block_is_transient() {
700        let io_err = std::io::Error::new(std::io::ErrorKind::WouldBlock, "would block");
701        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
702    }
703
704    #[test]
705    fn error_kind_execution_connection_aborted_is_transient() {
706        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionAborted, "aborted");
707        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
708    }
709
710    #[test]
711    fn error_kind_execution_not_found_is_permanent() {
712        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
713        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
714    }
715
716    #[test]
717    fn error_kind_execution_permission_denied_is_permanent() {
718        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
719        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
720    }
721
722    #[test]
723    fn error_kind_execution_other_is_permanent() {
724        let io_err = std::io::Error::other("some other error");
725        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
726    }
727
728    #[test]
729    fn error_kind_execution_already_exists_is_permanent() {
730        let io_err = std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists");
731        assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
732    }
733
734    #[test]
735    fn error_kind_display() {
736        assert_eq!(ErrorKind::Transient.to_string(), "transient");
737        assert_eq!(ErrorKind::Permanent.to_string(), "permanent");
738    }
739
740    #[test]
741    fn truncate_tool_output_short_passthrough() {
742        let short = "hello world";
743        assert_eq!(truncate_tool_output(short), short);
744    }
745
746    #[test]
747    fn truncate_tool_output_exact_limit() {
748        let exact = "a".repeat(MAX_TOOL_OUTPUT_CHARS);
749        assert_eq!(truncate_tool_output(&exact), exact);
750    }
751
752    #[test]
753    fn truncate_tool_output_long_split() {
754        let long = "x".repeat(MAX_TOOL_OUTPUT_CHARS + 1000);
755        let result = truncate_tool_output(&long);
756        assert!(result.contains("truncated"));
757        assert!(result.len() < long.len());
758    }
759
760    #[test]
761    fn truncate_tool_output_notice_contains_count() {
762        let long = "y".repeat(MAX_TOOL_OUTPUT_CHARS + 2000);
763        let result = truncate_tool_output(&long);
764        assert!(result.contains("truncated"));
765        assert!(result.contains("chars"));
766    }
767
768    #[derive(Debug)]
769    struct DefaultExecutor;
770    impl ToolExecutor for DefaultExecutor {
771        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
772            Ok(None)
773        }
774    }
775
776    #[tokio::test]
777    async fn execute_tool_call_default_returns_none() {
778        let exec = DefaultExecutor;
779        let call = ToolCall {
780            tool_id: "anything".to_owned(),
781            params: serde_json::Map::new(),
782        };
783        let result = exec.execute_tool_call(&call).await.unwrap();
784        assert!(result.is_none());
785    }
786
787    #[test]
788    fn filter_stats_savings_pct() {
789        let fs = FilterStats {
790            raw_chars: 1000,
791            filtered_chars: 200,
792            ..Default::default()
793        };
794        assert!((fs.savings_pct() - 80.0).abs() < 0.01);
795    }
796
797    #[test]
798    fn filter_stats_savings_pct_zero() {
799        let fs = FilterStats::default();
800        assert!((fs.savings_pct()).abs() < 0.01);
801    }
802
803    #[test]
804    fn filter_stats_estimated_tokens_saved() {
805        let fs = FilterStats {
806            raw_chars: 1000,
807            filtered_chars: 200,
808            ..Default::default()
809        };
810        assert_eq!(fs.estimated_tokens_saved(), 200); // (1000 - 200) / 4
811    }
812
813    #[test]
814    fn filter_stats_format_inline() {
815        let fs = FilterStats {
816            raw_chars: 1000,
817            filtered_chars: 200,
818            raw_lines: 342,
819            filtered_lines: 28,
820            ..Default::default()
821        };
822        let line = fs.format_inline("shell");
823        assert_eq!(line, "[shell] 342 lines \u{2192} 28 lines, 80.0% filtered");
824    }
825
826    #[test]
827    fn filter_stats_format_inline_zero() {
828        let fs = FilterStats::default();
829        let line = fs.format_inline("bash");
830        assert_eq!(line, "[bash] 0 lines \u{2192} 0 lines, 0.0% filtered");
831    }
832
833    // DynExecutor tests
834
835    struct FixedExecutor {
836        tool_id: &'static str,
837        output: &'static str,
838    }
839
840    impl ToolExecutor for FixedExecutor {
841        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
842            Ok(Some(ToolOutput {
843                tool_name: self.tool_id.to_owned(),
844                summary: self.output.to_owned(),
845                blocks_executed: 1,
846                filter_stats: None,
847                diff: None,
848                streamed: false,
849                terminal_id: None,
850                locations: None,
851                raw_response: None,
852            }))
853        }
854
855        fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
856            vec![]
857        }
858
859        async fn execute_tool_call(
860            &self,
861            _call: &ToolCall,
862        ) -> Result<Option<ToolOutput>, ToolError> {
863            Ok(Some(ToolOutput {
864                tool_name: self.tool_id.to_owned(),
865                summary: self.output.to_owned(),
866                blocks_executed: 1,
867                filter_stats: None,
868                diff: None,
869                streamed: false,
870                terminal_id: None,
871                locations: None,
872                raw_response: None,
873            }))
874        }
875    }
876
877    #[tokio::test]
878    async fn dyn_executor_execute_delegates() {
879        let inner = std::sync::Arc::new(FixedExecutor {
880            tool_id: "bash",
881            output: "hello",
882        });
883        let exec = DynExecutor(inner);
884        let result = exec.execute("```bash\necho hello\n```").await.unwrap();
885        assert!(result.is_some());
886        assert_eq!(result.unwrap().summary, "hello");
887    }
888
889    #[tokio::test]
890    async fn dyn_executor_execute_confirmed_delegates() {
891        let inner = std::sync::Arc::new(FixedExecutor {
892            tool_id: "bash",
893            output: "confirmed",
894        });
895        let exec = DynExecutor(inner);
896        let result = exec.execute_confirmed("...").await.unwrap();
897        assert!(result.is_some());
898        assert_eq!(result.unwrap().summary, "confirmed");
899    }
900
901    #[test]
902    fn dyn_executor_tool_definitions_delegates() {
903        let inner = std::sync::Arc::new(FixedExecutor {
904            tool_id: "my_tool",
905            output: "",
906        });
907        let exec = DynExecutor(inner);
908        // FixedExecutor returns empty definitions; verify delegation occurs without panic.
909        let defs = exec.tool_definitions();
910        assert!(defs.is_empty());
911    }
912
913    #[tokio::test]
914    async fn dyn_executor_execute_tool_call_delegates() {
915        let inner = std::sync::Arc::new(FixedExecutor {
916            tool_id: "bash",
917            output: "tool_call_result",
918        });
919        let exec = DynExecutor(inner);
920        let call = ToolCall {
921            tool_id: "bash".to_owned(),
922            params: serde_json::Map::new(),
923        };
924        let result = exec.execute_tool_call(&call).await.unwrap();
925        assert!(result.is_some());
926        assert_eq!(result.unwrap().summary, "tool_call_result");
927    }
928
929    #[test]
930    fn dyn_executor_set_effective_trust_delegates() {
931        use std::sync::atomic::{AtomicU8, Ordering};
932
933        struct TrustCapture(AtomicU8);
934        impl ToolExecutor for TrustCapture {
935            async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
936                Ok(None)
937            }
938            fn set_effective_trust(&self, level: crate::TrustLevel) {
939                // encode: Trusted=0, Verified=1, Quarantined=2, Blocked=3
940                let v = match level {
941                    crate::TrustLevel::Trusted => 0u8,
942                    crate::TrustLevel::Verified => 1,
943                    crate::TrustLevel::Quarantined => 2,
944                    crate::TrustLevel::Blocked => 3,
945                };
946                self.0.store(v, Ordering::Relaxed);
947            }
948        }
949
950        let inner = std::sync::Arc::new(TrustCapture(AtomicU8::new(0)));
951        let exec =
952            DynExecutor(std::sync::Arc::clone(&inner) as std::sync::Arc<dyn ErasedToolExecutor>);
953        ToolExecutor::set_effective_trust(&exec, crate::TrustLevel::Quarantined);
954        assert_eq!(inner.0.load(Ordering::Relaxed), 2);
955
956        ToolExecutor::set_effective_trust(&exec, crate::TrustLevel::Blocked);
957        assert_eq!(inner.0.load(Ordering::Relaxed), 3);
958    }
959
960    #[test]
961    fn extract_fenced_blocks_no_prefix_match() {
962        // ```bashrc must NOT match when searching for "bash"
963        assert!(extract_fenced_blocks("```bashrc\nfoo\n```", "bash").is_empty());
964        // exact match
965        assert_eq!(
966            extract_fenced_blocks("```bash\nfoo\n```", "bash"),
967            vec!["foo"]
968        );
969        // trailing space is fine
970        assert_eq!(
971            extract_fenced_blocks("```bash \nfoo\n```", "bash"),
972            vec!["foo"]
973        );
974    }
975}