Skip to main content

vtcode_core/tools/registry/
error.rs

1use anyhow::Error;
2use serde::{Deserialize, Serialize};
3use serde_json::{Value, json};
4use std::borrow::Cow;
5use vtcode_commons::ErrorCategory;
6
7use crate::retry::{RetryDecision, RetryPolicy, RetryPolicyCoreExt};
8use crate::tools::tool_intent::is_command_tool;
9
10const EXEC_SESSION_NOT_FOUND_CODE: &str = "exec_session_not_found";
11
12#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13pub struct ToolErrorDebugContext {
14    pub surface: Option<String>,
15    pub attempt: Option<u32>,
16    pub invocation_id: Option<String>,
17    pub metadata: Vec<(String, String)>,
18}
19
20/// Typed patch diagnostics, independent of words quoted from source files.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub enum PatchFailure {
23    ContextMismatch { path: String, evidence: String },
24    Other,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ToolExecutionError {
29    pub tool_name: String,
30    pub error_type: ToolErrorType,
31    pub category: ErrorCategory,
32    pub message: String,
33    pub retryable: bool,
34    pub is_recoverable: bool,
35    pub recovery_suggestions: Vec<Cow<'static, str>>,
36    pub retry_delay_ms: Option<u64>,
37    pub retry_after_ms: Option<u64>,
38    pub circuit_breaker_impact: bool,
39    pub partial_state_possible: bool,
40    pub rollback_performed: bool,
41    pub debug_context: Option<ToolErrorDebugContext>,
42    pub original_error: Option<String>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub patch_failure: Option<PatchFailure>,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48pub enum ToolErrorType {
49    InvalidParameters,
50    ToolNotFound,
51    PermissionDenied,
52    ResourceNotFound,
53    NetworkError,
54    Timeout,
55    ExecutionError,
56    PolicyViolation,
57}
58
59impl ToolErrorType {
60    /// Return the error type as a static string for serialization.
61    #[must_use]
62    pub const fn as_str(&self) -> &'static str {
63        match self {
64            Self::InvalidParameters => "InvalidParameters",
65            Self::ToolNotFound => "ToolNotFound",
66            Self::PermissionDenied => "PermissionDenied",
67            Self::ResourceNotFound => "ResourceNotFound",
68            Self::NetworkError => "NetworkError",
69            Self::Timeout => "Timeout",
70            Self::ExecutionError => "ExecutionError",
71            Self::PolicyViolation => "PolicyViolation",
72        }
73    }
74}
75
76impl ToolExecutionError {
77    #[inline]
78    #[must_use]
79    pub fn new(tool_name: impl Into<String>, error_type: ToolErrorType, message: impl Into<String>) -> Self {
80        Self::from_parts(tool_name.into(), ErrorCategory::from(error_type), error_type, message.into())
81    }
82
83    /// Construct from a full-fidelity `ErrorCategory`, deriving the lossy
84    /// `error_type` view from it. Prefer this over [`Self::new`] when the
85    /// category came from `vtcode_commons::classify_anyhow_error` /
86    /// `classify_error_message`, so distinctions such as `RateLimit` vs
87    /// `Network` are preserved on the struct.
88    #[must_use]
89    fn from_category(tool_name: impl Into<String>, category: ErrorCategory, message: impl Into<String>) -> Self {
90        Self::from_parts(tool_name.into(), category, ToolErrorType::from(category), message.into())
91    }
92
93    fn from_parts(tool_name: String, category: ErrorCategory, error_type: ToolErrorType, message: String) -> Self {
94        let (retryable, is_recoverable, recovery_suggestions) =
95            generate_recovery_info(tool_name.as_str(), category, error_type);
96
97        Self {
98            tool_name,
99            error_type,
100            category,
101            message,
102            retryable,
103            is_recoverable,
104            recovery_suggestions,
105            retry_delay_ms: None,
106            retry_after_ms: None,
107            circuit_breaker_impact: category.should_trip_circuit_breaker(),
108            partial_state_possible: false,
109            rollback_performed: false,
110            debug_context: None,
111            original_error: None,
112            patch_failure: None,
113        }
114    }
115
116    #[inline]
117    #[must_use]
118    pub fn with_original_error(
119        tool_name: impl Into<String>,
120        error_type: ToolErrorType,
121        message: impl Into<String>,
122        original_error: impl Into<String>,
123    ) -> Self {
124        let mut error = Self::new(tool_name, error_type, message);
125        error.original_error = Some(original_error.into());
126        error
127    }
128
129    #[must_use]
130    pub fn from_anyhow(
131        tool_name: impl Into<String>,
132        error: &Error,
133        attempt_index: u32,
134        partial_state_possible: bool,
135        rollback_performed: bool,
136        surface: Option<&str>,
137    ) -> Self {
138        let tool_name = tool_name.into();
139        // Classify exactly once into the canonical category; the wire-visible
140        // `error_type` is derived from it inside `from_category`.
141        if let Some(session_error) = error.downcast_ref::<crate::tools::exec_session::ExecSessionNotFound>() {
142            let mut structured =
143                Self::from_category(tool_name, ErrorCategory::ResourceNotFound, session_error.to_string())
144                    .with_debug_metadata("failure_code", EXEC_SESSION_NOT_FOUND_CODE);
145            structured.original_error =
146                Some(vtcode_commons::formatting::head_tail_truncate(&format!("{error:#}"), 640, " ... ").0);
147            structured.partial_state_possible = partial_state_possible;
148            structured.rollback_performed = rollback_performed;
149            structured.recovery_suggestions = vec![Cow::Borrowed(
150                "Recover the exact session ID from the original response. Reuse recorded completion output; rerun only if fresh execution is still needed.",
151            )];
152            if let Some(surface) = surface {
153                structured = structured.with_surface(surface);
154            }
155            return structured;
156        }
157        if let Some(patch_error) = error.downcast_ref::<crate::tools::editing::PatchError>() {
158            use crate::tools::editing::PatchError;
159            let category = match patch_error {
160                PatchError::InvalidPath { .. } => ErrorCategory::PolicyViolation,
161                PatchError::MissingFile { .. } => ErrorCategory::ResourceNotFound,
162                PatchError::EmptyInput
163                | PatchError::NoOperations
164                | PatchError::InvalidFormat(_)
165                | PatchError::InvalidHunk { .. } => ErrorCategory::InvalidParameters,
166                PatchError::Io { source, .. } if source.kind() == std::io::ErrorKind::PermissionDenied => {
167                    ErrorCategory::PermissionDenied
168                }
169                _ => ErrorCategory::ExecutionError,
170            };
171            let mut structured = Self::from_category(tool_name.clone(), category, patch_error.to_string());
172            structured.original_error =
173                Some(vtcode_commons::formatting::head_tail_truncate(&format!("{error:#}"), 640, " ... ").0);
174            structured.patch_failure = Some(match patch_error.context_mismatch() {
175                Some((path, evidence)) => PatchFailure::ContextMismatch {
176                    path: path.to_string(),
177                    evidence: vtcode_commons::formatting::head_tail_truncate(evidence, 320, " ... ").0,
178                },
179                None => PatchFailure::Other,
180            });
181            if let Some(path) = structured.patch_context_mismatch_path() {
182                structured.message = format!(
183                    "Patch context mismatch in '{path}': use complete current lines and preserve internal whitespace."
184                );
185                structured.recovery_suggestions = vec![Cow::Borrowed(
186                    "Read the affected path once with a file read limit of 1-200 lines or a single sed -n range, then rebuild apply_patch with complete current context. Never retry the unchanged failed patch. Do not probe matching with scratch edits or inspect the patch implementation. One fresh recovery read per path per turn can pass either read cap; other limits still apply.",
187                )];
188            } else if category == ErrorCategory::InvalidParameters {
189                structured.recovery_suggestions = vec![Cow::Borrowed(
190                    crate::tools::apply_patch::APPLY_PATCH_ARGUMENT_CORRECTION,
191                )];
192            } else {
193                structured.recovery_suggestions = vec![Cow::Borrowed(
194                    "Resolve the reported patch target or filesystem error before retrying; retain permission and workspace boundaries.",
195                )];
196            }
197            structured.retryable = false;
198            structured.is_recoverable =
199                !matches!(category, ErrorCategory::PolicyViolation | ErrorCategory::PermissionDenied);
200            structured.circuit_breaker_impact = false;
201            structured = apply_explicit_error_state(structured, tool_name.as_str(), error);
202            if let Some(surface) = surface {
203                structured = structured.with_surface(surface);
204            }
205            return structured;
206        }
207        let category = vtcode_commons::classify_anyhow_error(error);
208        let mut structured = Self::from_category(tool_name.clone(), category, error.to_string());
209        structured.original_error = Some(format!("{error:#}"));
210        structured =
211            RetryPolicy::default().apply_to_tool_execution_error(structured, attempt_index, Some(tool_name.as_str()));
212        structured.partial_state_possible = partial_state_possible;
213        structured.rollback_performed = rollback_performed;
214        structured = apply_explicit_error_state(structured, tool_name.as_str(), error);
215        if let Some(surface) = surface {
216            structured = structured.with_surface(surface);
217        }
218        structured
219    }
220
221    #[must_use]
222    pub fn patch_context_mismatch_path(&self) -> Option<&str> {
223        match &self.patch_failure {
224            Some(PatchFailure::ContextMismatch { path, .. }) => Some(path),
225            _ => None,
226        }
227    }
228
229    /// Whether a typed runtime lookup failed, independent of quoted text.
230    #[must_use]
231    pub fn is_exec_session_not_found(&self) -> bool {
232        self.category == ErrorCategory::ResourceNotFound
233            && self.debug_context.as_ref().is_some_and(|context| {
234                context
235                    .metadata
236                    .iter()
237                    .any(|(key, value)| key == "failure_code" && value == EXEC_SESSION_NOT_FOUND_CODE)
238            })
239    }
240
241    #[must_use]
242    pub fn policy_violation(tool_name: impl Into<String>, message: impl Into<String>) -> Self {
243        Self::new(tool_name, ToolErrorType::PolicyViolation, message)
244    }
245
246    #[must_use]
247    pub fn with_retry_decision(mut self, decision: RetryDecision) -> Self {
248        self.category = decision.category;
249        // Keep the derived view in lockstep with the authoritative category.
250        // `ToolErrorType::from` is the identity on every ToolErrorType ->
251        // ErrorCategory -> ToolErrorType round trip, so this only changes
252        // `error_type` when the decision actually recategorized the error.
253        self.error_type = ToolErrorType::from(decision.category);
254        self.retryable = decision.retryable;
255        self.retry_delay_ms = decision.delay.map(|delay| delay.as_millis() as u64);
256        self.retry_after_ms = decision.retry_after.map(|delay| delay.as_millis() as u64);
257        self.circuit_breaker_impact = decision.category.should_trip_circuit_breaker();
258        self
259    }
260
261    #[must_use]
262    pub fn with_partial_state(mut self, partial_state_possible: bool, rollback_performed: bool) -> Self {
263        self.partial_state_possible = partial_state_possible;
264        self.rollback_performed = rollback_performed;
265        self
266    }
267
268    #[must_use]
269    pub fn with_surface(mut self, surface: impl Into<String>) -> Self {
270        let debug = self.debug_context.get_or_insert_with(ToolErrorDebugContext::default);
271        debug.surface = Some(surface.into());
272        self
273    }
274
275    #[must_use]
276    pub fn with_attempt(mut self, attempt: u32) -> Self {
277        let debug = self.debug_context.get_or_insert_with(ToolErrorDebugContext::default);
278        debug.attempt = Some(attempt);
279        self
280    }
281
282    #[must_use]
283    pub fn with_invocation_id(mut self, invocation_id: impl Into<String>) -> Self {
284        let debug = self.debug_context.get_or_insert_with(ToolErrorDebugContext::default);
285        debug.invocation_id = Some(invocation_id.into());
286        self
287    }
288
289    #[must_use]
290    pub fn with_debug_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
291        let debug = self.debug_context.get_or_insert_with(ToolErrorDebugContext::default);
292        debug.metadata.push((key.into(), value.into()));
293        self
294    }
295
296    #[must_use]
297    pub fn with_tool_call_context(mut self, tool_name: &str, args: &Value) -> Self {
298        self.tool_name = tool_name.to_string();
299
300        if tool_name == crate::config::constants::tools::APPLY_PATCH {
301            return self;
302        }
303
304        let intent = crate::tools::tool_intent::classify_tool_intent(tool_name, args);
305        if intent.mutating || is_command_tool(tool_name) {
306            self.partial_state_possible = true;
307        }
308
309        self
310    }
311
312    #[must_use]
313    pub fn attempts_made(&self) -> Option<u32> {
314        self.debug_context.as_ref().and_then(|context| context.attempt)
315    }
316
317    #[must_use]
318    pub fn retry_summary(&self) -> Option<String> {
319        let retry_count = self.attempts_made().map(|attempts| attempts.saturating_sub(1)).unwrap_or(0);
320
321        let mut summary = if matches!(self.category, ErrorCategory::CircuitOpen) {
322            Some("The service is pausing new calls after repeated transient failures.".to_string())
323        } else if retry_count > 0 {
324            let suffix = if retry_count == 1 { "" } else { "s" };
325            Some(format!("Retried {retry_count} time{suffix} before failing."))
326        } else {
327            None
328        };
329
330        if let Some(delay_ms) = self.retry_after_ms.or(self.retry_delay_ms) {
331            let delay = format_retry_delay(delay_ms);
332            match summary.as_mut() {
333                Some(existing) => {
334                    existing.push(' ');
335                    existing.push_str("Recommended wait: ");
336                    existing.push_str(&delay);
337                    existing.push('.');
338                }
339                None => {
340                    summary = Some(format!("Recommended wait: {delay}."));
341                }
342            }
343        }
344
345        summary
346    }
347
348    #[must_use]
349    pub fn user_message(&self) -> String {
350        let mut message = format!("[{}] {}", self.category.user_label(), self.message);
351
352        if self.rollback_performed {
353            message.push_str(" Any partial changes were rolled back.");
354        } else if self.partial_state_possible {
355            message.push_str(" Partial changes may still exist.");
356        }
357
358        if let Some(retry_summary) = self.retry_summary() {
359            message.push(' ');
360            message.push_str(&retry_summary);
361        }
362
363        if let Some(next_action) = self.recovery_suggestions.first() {
364            message.push_str(" Next: ");
365            message.push_str(next_action.as_ref());
366        }
367
368        message
369    }
370
371    #[must_use]
372    pub fn retry_delay(&self) -> Option<std::time::Duration> {
373        self.retry_delay_ms.map(std::time::Duration::from_millis)
374    }
375
376    #[must_use]
377    pub fn retry_after(&self) -> Option<std::time::Duration> {
378        self.retry_after_ms.map(std::time::Duration::from_millis)
379    }
380
381    #[must_use]
382    pub fn from_tool_output(output: &Value) -> Option<Self> {
383        let error_payload = output.get("error")?;
384        Self::from_error_payload(error_payload)
385    }
386
387    #[must_use]
388    pub fn from_error_payload(error_payload: &Value) -> Option<Self> {
389        if let Some(inner) = error_payload.get("error") {
390            return Self::from_error_payload(inner);
391        }
392
393        if error_payload.is_object() && error_payload.get("message").is_some() {
394            // Single source of truth: deserialize via serde. The previous
395            // implementation hand-built the error struct from individual
396            // fields, which silently dropped any new field that was added to
397            // `ToolExecutionError` (it would fall through to `Self::new`'s
398            // default for that field, which could be subtly wrong).
399            //
400            // `serde_json::from_value` honors every `#[serde(default)]` on
401            // the struct, so a partial payload still reconstructs safely.
402            if let Ok(structured) = serde_json::from_value::<Self>(error_payload.clone()) {
403                return Some(structured);
404            }
405
406            // Last-resort fallback: pull out the minimum fields needed to
407            // construct a usable error. This branch only fires when the
408            // payload is malformed (e.g. wrong type for `category`).
409            let tool_name = error_payload.get("tool_name").and_then(Value::as_str).unwrap_or("tool");
410            let message = error_payload
411                .get("message")
412                .and_then(Value::as_str)
413                .unwrap_or("Unknown tool execution error");
414            let category = error_payload
415                .get("category")
416                .and_then(|value| serde_json::from_value(value.clone()).ok())
417                .unwrap_or_else(|| vtcode_commons::classify_error_message(message));
418            let error_type = error_payload
419                .get("error_type")
420                .and_then(Value::as_str)
421                .map(parse_error_type)
422                .unwrap_or_else(|| ToolErrorType::from(category));
423            let mut structured = Self::new(tool_name.to_string(), error_type, message.to_string());
424            structured.category = category;
425            structured.original_error = error_payload
426                .get("original_error")
427                .and_then(Value::as_str)
428                .map(ToOwned::to_owned);
429            return Some(structured);
430        }
431
432        error_payload.as_str().map(|message| {
433            let category = vtcode_commons::classify_error_message(message);
434            Self::new("tool".to_string(), ToolErrorType::from(category), message.to_string())
435        })
436    }
437
438    #[must_use]
439    pub fn to_json_value(&self) -> Value {
440        json!({
441            "error": {
442                "tool_name": self.tool_name,
443                "error_type": self.error_type.as_str(),
444                "category": self.category,
445                "message": self.message,
446                "retryable": self.retryable,
447                "is_recoverable": self.is_recoverable,
448                "recovery_suggestions": self.recovery_suggestions,
449                "retry_delay_ms": self.retry_delay_ms,
450                "retry_after_ms": self.retry_after_ms,
451                "circuit_breaker_impact": self.circuit_breaker_impact,
452                "partial_state_possible": self.partial_state_possible,
453                "rollback_performed": self.rollback_performed,
454                "debug_context": self.debug_context,
455                "original_error": self.original_error,
456                "patch_failure": self.patch_failure,
457            }
458        })
459    }
460}
461
462impl std::fmt::Display for ToolExecutionError {
463    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
464        f.write_str(&self.message)
465    }
466}
467
468impl std::error::Error for ToolExecutionError {}
469
470// Use static string slices to avoid allocations for recovery suggestions.
471// Delegates to the shared `ErrorCategory` recovery suggestions where possible.
472#[inline]
473fn generate_recovery_info(
474    tool_name: &str,
475    category: ErrorCategory,
476    error_type: ToolErrorType,
477) -> (bool, bool, Vec<Cow<'static, str>>) {
478    let is_recoverable = category.is_retryable()
479        || matches!(
480            error_type,
481            ToolErrorType::InvalidParameters | ToolErrorType::PermissionDenied | ToolErrorType::ResourceNotFound
482        );
483    let retryable =
484        category.is_retryable() && !crate::retry::is_non_retryable_command_timeout(category, Some(tool_name));
485    (retryable, is_recoverable, category.recovery_suggestions())
486}
487
488fn parse_error_type(raw: &str) -> ToolErrorType {
489    match raw {
490        "InvalidParameters" => ToolErrorType::InvalidParameters,
491        "ToolNotFound" => ToolErrorType::ToolNotFound,
492        "PermissionDenied" => ToolErrorType::PermissionDenied,
493        "ResourceNotFound" => ToolErrorType::ResourceNotFound,
494        "NetworkError" => ToolErrorType::NetworkError,
495        "Timeout" => ToolErrorType::Timeout,
496        "ExecutionError" => ToolErrorType::ExecutionError,
497        "PolicyViolation" => ToolErrorType::PolicyViolation,
498        _ => ToolErrorType::ExecutionError,
499    }
500}
501
502fn format_retry_delay(delay_ms: u64) -> String {
503    if delay_ms >= 1_000 {
504        format!("{:.1}s", delay_ms as f64 / 1_000.0)
505    } else {
506        format!("{delay_ms}ms")
507    }
508}
509
510fn apply_explicit_error_state(mut error: ToolExecutionError, tool_name: &str, source: &Error) -> ToolExecutionError {
511    if tool_name != crate::config::constants::tools::APPLY_PATCH {
512        return error;
513    }
514
515    if let Some(patch_error) = source.downcast_ref::<crate::tools::editing::PatchError>() {
516        match patch_error {
517            crate::tools::editing::PatchError::RolledBack { .. } => {
518                error = error.with_partial_state(false, true);
519            }
520            crate::tools::editing::PatchError::Recovery { .. } => {
521                error = error.with_partial_state(true, false);
522            }
523            _ => {}
524        }
525    }
526
527    error
528}
529
530// === Bridge conversions between ErrorCategory and ToolErrorType ===
531
532impl From<ErrorCategory> for ToolErrorType {
533    fn from(cat: ErrorCategory) -> Self {
534        match cat {
535            ErrorCategory::InvalidParameters => ToolErrorType::InvalidParameters,
536            ErrorCategory::ToolNotFound => ToolErrorType::ToolNotFound,
537            ErrorCategory::ResourceNotFound => ToolErrorType::ResourceNotFound,
538            ErrorCategory::PermissionDenied => ToolErrorType::PermissionDenied,
539            ErrorCategory::Network | ErrorCategory::ServiceUnavailable => ToolErrorType::NetworkError,
540            ErrorCategory::Timeout => ToolErrorType::Timeout,
541            ErrorCategory::PolicyViolation | ErrorCategory::PlanningPolicyViolation => ToolErrorType::PolicyViolation,
542            ErrorCategory::RateLimit => ToolErrorType::NetworkError,
543            ErrorCategory::CircuitOpen => ToolErrorType::ExecutionError,
544            ErrorCategory::Authentication => ToolErrorType::PermissionDenied,
545            ErrorCategory::SandboxFailure => ToolErrorType::PolicyViolation,
546            ErrorCategory::ResourceExhausted => ToolErrorType::ExecutionError,
547            ErrorCategory::Cancelled => ToolErrorType::ExecutionError,
548            ErrorCategory::ExecutionError => ToolErrorType::ExecutionError,
549        }
550    }
551}
552
553impl From<ToolErrorType> for ErrorCategory {
554    fn from(t: ToolErrorType) -> Self {
555        match t {
556            ToolErrorType::InvalidParameters => ErrorCategory::InvalidParameters,
557            ToolErrorType::ToolNotFound => ErrorCategory::ToolNotFound,
558            ToolErrorType::ResourceNotFound => ErrorCategory::ResourceNotFound,
559            ToolErrorType::PermissionDenied => ErrorCategory::PermissionDenied,
560            ToolErrorType::NetworkError => ErrorCategory::Network,
561            ToolErrorType::Timeout => ErrorCategory::Timeout,
562            ToolErrorType::PolicyViolation => ErrorCategory::PolicyViolation,
563            ToolErrorType::ExecutionError => ErrorCategory::ExecutionError,
564        }
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use anyhow::anyhow;
572
573    fn classify(err: &Error) -> ToolErrorType {
574        ToolErrorType::from(vtcode_commons::classify_anyhow_error(err))
575    }
576
577    #[test]
578    fn classify_error_marks_rate_limit_as_network_error() {
579        let err = anyhow!("provider returned 429 Too Many Requests");
580        assert!(matches!(classify(&err), ToolErrorType::NetworkError));
581    }
582
583    #[test]
584    fn classify_error_marks_service_unavailable_as_network_error() {
585        let err = anyhow!("503 Service Unavailable");
586        assert!(matches!(classify(&err), ToolErrorType::NetworkError));
587    }
588
589    #[test]
590    fn classify_error_marks_weekly_usage_limit_as_execution_error() {
591        let err = anyhow!("you have reached your weekly usage limit");
592        assert!(matches!(classify(&err), ToolErrorType::ExecutionError));
593    }
594
595    #[test]
596    fn classify_error_marks_tool_not_found() {
597        let err = anyhow!("unknown tool: ask_questions");
598        assert!(matches!(classify(&err), ToolErrorType::ToolNotFound));
599    }
600
601    #[test]
602    fn classify_error_marks_policy_violation_before_permission() {
603        let err = anyhow!("tool permission denied by policy");
604        assert!(matches!(classify(&err), ToolErrorType::PolicyViolation));
605    }
606
607    const ALL_TOOL_ERROR_TYPES: [ToolErrorType; 8] = [
608        ToolErrorType::InvalidParameters,
609        ToolErrorType::ToolNotFound,
610        ToolErrorType::PermissionDenied,
611        ToolErrorType::ResourceNotFound,
612        ToolErrorType::NetworkError,
613        ToolErrorType::Timeout,
614        ToolErrorType::ExecutionError,
615        ToolErrorType::PolicyViolation,
616    ];
617
618    #[test]
619    fn error_type_wire_string_round_trips() {
620        for error_type in ALL_TOOL_ERROR_TYPES {
621            assert_eq!(parse_error_type(error_type.as_str()), error_type);
622        }
623    }
624
625    #[test]
626    fn error_type_category_round_trip_is_identity() {
627        // Guarantees `with_retry_decision` never changes error_type unless the
628        // decision actually recategorized the error.
629        for error_type in ALL_TOOL_ERROR_TYPES {
630            assert_eq!(ToolErrorType::from(ErrorCategory::from(error_type)), error_type);
631        }
632    }
633
634    #[test]
635    fn from_anyhow_derives_error_type_from_category() {
636        let err = anyhow!("provider returned 429 Too Many Requests");
637        let structured = ToolExecutionError::from_anyhow("grep_search", &err, 0, false, false, None);
638        assert_eq!(structured.category, ErrorCategory::RateLimit);
639        assert_eq!(structured.error_type, ToolErrorType::from(structured.category));
640    }
641
642    #[test]
643    fn missing_exec_session_classification_survives_context_and_round_trip() {
644        let source = Error::new(crate::tools::exec_session::ExecSessionNotFound { session_id: "run-missing".into() })
645            .context("quoted diagnostic: permission denied in vtcode.toml");
646        let structured = ToolExecutionError::from_anyhow("write_stdin", &source, 0, false, false, Some("registry"));
647        assert_eq!(structured.category, ErrorCategory::ResourceNotFound);
648        assert!(structured.is_exec_session_not_found());
649        assert!(!structured.retryable);
650        assert!(!structured.circuit_breaker_impact);
651        assert_eq!(structured.debug_context.as_ref().unwrap().surface.as_deref(), Some("registry"));
652        assert!(structured.message.contains("reuse its output"));
653        assert!(!structured.message.contains("re-run the command instead of waiting"));
654        let decoded = ToolExecutionError::from_error_payload(&structured.to_json_value()).unwrap();
655        assert!(decoded.is_exec_session_not_found());
656
657        let lookalike = anyhow!("exec session 'run-missing' not found: permission denied");
658        let untyped = ToolExecutionError::from_anyhow("write_stdin", &lookalike, 0, false, false, None);
659        assert_eq!(untyped.category, ErrorCategory::PermissionDenied);
660        assert!(!untyped.is_exec_session_not_found());
661    }
662
663    #[test]
664    fn retryable_matches_error_category_predicate() {
665        for error_type in ALL_TOOL_ERROR_TYPES {
666            let category = ErrorCategory::from(error_type);
667            let structured = ToolExecutionError::new("grep_search".to_string(), error_type, "boom".to_string());
668            assert_eq!(structured.retryable, category.is_retryable(), "retryable mismatch for {error_type:?}");
669        }
670    }
671
672    #[test]
673    fn command_tool_timeouts_are_not_retryable() {
674        let structured = ToolExecutionError::new(
675            crate::config::constants::tools::CREATE_PTY_SESSION.to_string(),
676            ToolErrorType::Timeout,
677            "command timed out".to_string(),
678        );
679        assert!(!structured.retryable);
680    }
681
682    #[test]
683    fn tool_call_context_marks_mutating_tools_as_partial_state_possible() {
684        let error = ToolExecutionError::new(
685            "write_file".to_string(),
686            ToolErrorType::ExecutionError,
687            "write failed".to_string(),
688        )
689        .with_tool_call_context(
690            crate::config::constants::tools::WRITE_FILE,
691            &serde_json::json!({"path": "note.txt", "content": "hello"}),
692        );
693
694        assert!(error.partial_state_possible);
695        assert!(!error.rollback_performed);
696    }
697
698    #[test]
699    fn tool_call_context_marks_apply_patch_failures_as_rolled_back() {
700        let source = Error::new(crate::tools::editing::PatchError::RolledBack {
701            original: Box::new(crate::tools::editing::PatchError::SegmentNotFound {
702                path: "src/lib.rs".to_string(),
703                snippet: "fn main()".to_string(),
704            }),
705        });
706        let error = ToolExecutionError::from_anyhow(
707            crate::config::constants::tools::APPLY_PATCH,
708            &source,
709            0,
710            false,
711            false,
712            None,
713        );
714
715        assert!(!error.partial_state_possible);
716        assert!(error.rollback_performed);
717    }
718
719    #[test]
720    fn user_message_includes_retry_summary_and_wait() {
721        let mut error = ToolExecutionError::new(
722            crate::config::constants::tools::READ_FILE.to_string(),
723            ToolErrorType::ExecutionError,
724            "read failed".to_string(),
725        )
726        .with_attempt(2);
727        error.retry_delay_ms = Some(1_500);
728
729        let message = error.user_message();
730
731        assert!(message.contains("Retried 1 time before failing."));
732        assert!(message.contains("Recommended wait: 1.5s."));
733    }
734}