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#[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 #[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 #[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 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 #[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 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 if let Ok(structured) = serde_json::from_value::<Self>(error_payload.clone()) {
403 return Some(structured);
404 }
405
406 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#[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
530impl 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 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}