Skip to main content

ironflow_core/
error.rs

1//! Error types for ironflow operations.
2//!
3//! This module defines two error enums:
4//!
5//! * [`OperationError`] - top-level error returned by both shell and agent operations.
6//! * [`AgentError`] - agent-specific error returned by [`AgentProvider::invoke`](crate::provider::AgentProvider::invoke).
7//!
8//! [`AgentError`] converts into [`OperationError`] via the [`From`] trait, so agent
9//! errors propagate naturally through the `?` operator.
10
11use std::any;
12use std::fmt;
13use std::time::Duration;
14
15use chrono::{DateTime, Utc};
16use thiserror::Error;
17
18use crate::provider::DebugMessage;
19
20/// Top-level error for any workflow operation (shell or agent).
21///
22/// Every public operation in ironflow returns `Result<T, OperationError>`.
23#[derive(Debug, Error)]
24pub enum OperationError {
25    /// A shell command exited with a non-zero status code.
26    #[error("shell exited with code {exit_code}: {stderr}")]
27    Shell {
28        /// Process exit code, or `-1` if the process could not be spawned.
29        exit_code: i32,
30        /// Captured stderr, truncated to [`MAX_OUTPUT_SIZE`](crate::utils::MAX_OUTPUT_SIZE).
31        stderr: String,
32    },
33
34    /// An agent invocation failed.
35    ///
36    /// Wraps an [`AgentError`] with full detail about the failure.
37    #[error("agent error: {0}")]
38    Agent(#[from] AgentError),
39
40    /// An operation exceeded its configured timeout.
41    #[error("step '{step}' timed out after {limit:?}")]
42    Timeout {
43        /// Human-readable description of the timed-out step (usually the command string).
44        step: String,
45        /// The [`Duration`] that was exceeded.
46        limit: Duration,
47    },
48
49    /// An HTTP request failed at the transport layer or the response body
50    /// could not be read.
51    #[error("{}", match status {
52        Some(code) => format!("http error (status {code}): {message}"),
53        None => format!("http error: {message}"),
54    })]
55    Http {
56        /// HTTP status code, if a response was received.
57        status: Option<u16>,
58        /// Human-readable error description.
59        message: String,
60    },
61
62    /// Failed to deserialize a JSON response into the expected Rust type.
63    ///
64    /// Returned by [`AgentResult::json`](crate::operations::agent::AgentResult::json)
65    /// and [`HttpOutput::json`](crate::operations::http::HttpOutput::json).
66    #[error("failed to deserialize into {target_type}: {reason}")]
67    Deserialize {
68        /// The Rust type name that was expected.
69        target_type: String,
70        /// The underlying serde error message.
71        reason: String,
72    },
73
74    /// The secret store failed to read or decrypt a secret.
75    ///
76    /// Returned by [`SecretResolver::get`](crate::operation::SecretResolver::get)
77    /// implementations when the underlying storage or decryption layer errors.
78    #[error("secret error: {message}")]
79    Secret {
80        /// Human-readable error description.
81        message: String,
82    },
83
84    /// An error from an external library (e.g. `git2`, `gitlab`).
85    ///
86    /// Used by `ironflow-ops-*` crates to wrap third-party library errors
87    /// without introducing domain-specific variants into this enum.
88    ///
89    /// # Examples
90    ///
91    /// ```
92    /// use ironflow_core::error::OperationError;
93    ///
94    /// let err = OperationError::External {
95    ///     origin: "git".to_string(),
96    ///     message: "reference not found".to_string(),
97    /// };
98    /// assert!(err.to_string().contains("git error"));
99    /// ```
100    #[error("{origin} error: {message}")]
101    External {
102        /// Short identifier for the library or domain (e.g. `"git"`, `"gitlab"`).
103        origin: String,
104        /// Human-readable error description from the underlying library.
105        message: String,
106    },
107}
108
109impl OperationError {
110    /// Build a [`Deserialize`](OperationError::Deserialize) error for type `T`.
111    pub fn deserialize<T>(error: impl fmt::Display) -> Self {
112        Self::Deserialize {
113            target_type: any::type_name::<T>().to_string(),
114            reason: error.to_string(),
115        }
116    }
117}
118
119/// Partial usage data from a failed agent invocation.
120///
121/// When an agent step fails (e.g. structured output extraction), the CLI
122/// may still report cost, duration, and token counts. This struct carries
123/// those values so callers can persist them even on error paths.
124#[derive(Debug, Default)]
125pub struct PartialUsage {
126    /// Total cost in USD reported by the CLI.
127    pub cost_usd: Option<f64>,
128    /// Wall-clock duration reported by the CLI, in milliseconds.
129    pub duration_ms: Option<u64>,
130    /// Uncached input tokens consumed before the failure.
131    pub input_tokens: Option<u64>,
132    /// Input tokens served from the prompt cache before the failure.
133    pub cache_read_input_tokens: Option<u64>,
134    /// Input tokens written to the prompt cache before the failure.
135    pub cache_creation_input_tokens: Option<u64>,
136    /// Output tokens generated before the failure.
137    pub output_tokens: Option<u64>,
138}
139
140/// Error specific to agent (AI provider) invocations.
141///
142/// Returned by [`AgentProvider::invoke`](crate::provider::AgentProvider::invoke) and
143/// automatically wrapped into [`OperationError::Agent`] when propagated with `?`.
144#[derive(Debug, Error)]
145pub enum AgentError {
146    /// The agent process exited with a non-zero status code.
147    #[error("claude process exited with code {exit_code}: {stderr}")]
148    ProcessFailed {
149        /// Process exit code, or `-1` if spawning failed.
150        exit_code: i32,
151        /// Captured stderr.
152        stderr: String,
153    },
154
155    /// The agent output did not match the expected schema.
156    #[error("schema validation failed: expected {expected}, got {got}{}", raw_response.as_ref().map(|r| { let end = r.floor_char_boundary(200); format!(" (raw response: {}...)", &r[..end]) }).unwrap_or_default())]
157    SchemaValidation {
158        /// What was expected (e.g. `"structured_output field"`).
159        expected: String,
160        /// What was actually received.
161        got: String,
162        /// Verbose conversation trace captured before the validation failure.
163        ///
164        /// Populated when the agent ran in verbose (stream-json) mode so that
165        /// callers can persist the debug trail even on error paths.
166        debug_messages: Vec<DebugMessage>,
167        /// Partial usage data from the CLI response, available even though
168        /// structured output extraction failed. Boxed to keep `AgentError`
169        /// small on the stack.
170        partial_usage: Box<PartialUsage>,
171        /// Raw text response from the agent, truncated to ~4000 bytes.
172        ///
173        /// When structured output extraction fails, the model may still have
174        /// produced useful text in the `result` field. This captures it so
175        /// callers can persist it for debugging (e.g. in the step output).
176        raw_response: Option<String>,
177    },
178
179    /// The agent stopped because it exhausted its configured USD budget.
180    ///
181    /// Distinct from [`SchemaValidation`](AgentError::SchemaValidation): retrying
182    /// costs money and cannot succeed, since the budget is already spent. Treated
183    /// as non-retryable by [`is_retryable`](crate::retry::is_retryable) at the
184    /// operation level and by the engine at the run level.
185    #[error("agent budget exceeded: spent ${spent_usd:.4} of ${limit_usd:.4} limit")]
186    BudgetExceeded {
187        /// Total cost reported by the provider before it stopped, in USD.
188        spent_usd: f64,
189        /// The configured `max_budget_usd` limit, in USD.
190        limit_usd: f64,
191        /// Verbose conversation trace captured before the budget ran out.
192        debug_messages: Vec<DebugMessage>,
193        /// Usage data reported alongside the budget error. Boxed to keep
194        /// `AgentError` small on the stack.
195        partial_usage: Box<PartialUsage>,
196    },
197
198    /// The model API refused the request the Claude CLI sent.
199    ///
200    /// Built from a CLI result with `is_error: true`: the CLI reached the API,
201    /// got an error back and wrote it in place of an answer. Structured output
202    /// is never validated against it. [`is_retryable`](crate::retry::is_retryable)
203    /// retries it only when `status` is absent, 429 or 5xx (529 overloaded
204    /// included): a 4xx such as `claude_code_version_too_old` or an unknown
205    /// model fails the same way on every attempt.
206    ///
207    /// # Examples
208    ///
209    /// ```
210    /// use ironflow_core::error::AgentError;
211    ///
212    /// let err = AgentError::Api {
213    ///     status: Some(400),
214    ///     code: Some("claude_code_version_too_old".to_string()),
215    ///     message: "API Error: 400 Claude Code 2.1.274 does not support this model".to_string(),
216    /// };
217    /// assert!(err.to_string().contains("claude_code_version_too_old"));
218    /// ```
219    #[error("claude api error ({}): {message}", api_error_context(*status, code.as_deref()))]
220    Api {
221        /// HTTP status the API answered with (`api_error_status`), absent when
222        /// the CLI did not report one.
223        status: Option<u16>,
224        /// API error code reported by the CLI (`api_error_code`), e.g.
225        /// `"claude_code_version_too_old"`.
226        code: Option<String>,
227        /// Error message printed by the CLI (its `result` field).
228        message: String,
229    },
230
231    /// The prompt exceeds the model's context window.
232    ///
233    /// Returned before spawning the process when the estimated token count
234    /// exceeds the model's known limit.
235    ///
236    /// * `chars` - number of characters in the combined prompt (system + user).
237    /// * `estimated_tokens` - approximate token count (chars / 4).
238    /// * `model_limit` - the model's context window in tokens.
239    #[error(
240        "prompt too large: {chars} chars (~{estimated_tokens} tokens) exceeds model limit of {model_limit} tokens"
241    )]
242    PromptTooLarge {
243        /// Number of characters in the prompt.
244        chars: usize,
245        /// Estimated token count (chars / 4 heuristic).
246        estimated_tokens: usize,
247        /// Model's context window in tokens.
248        model_limit: usize,
249    },
250
251    /// The agent did not complete within the configured timeout.
252    #[error("agent timed out after {limit:?}")]
253    Timeout {
254        /// The [`Duration`] that was exceeded.
255        limit: Duration,
256    },
257
258    /// The provider returned HTTP 429 Too Many Requests.
259    #[error("rate limited by {provider}, retry after {retry_after_secs:?}s")]
260    RateLimited {
261        /// Provider name (e.g. `"openai"`, `"anthropic"`).
262        provider: String,
263        /// Value from the `Retry-After` header, if present.
264        retry_after_secs: Option<u64>,
265    },
266
267    /// The provider returned an unexpected HTTP error or a transport-level failure.
268    ///
269    /// When `status_code` is `0`, no HTTP response was received (connection failure,
270    /// DNS resolution error, TLS handshake failure, or response body read error).
271    #[error("{provider} HTTP {status_code}: {message}")]
272    HttpProvider {
273        /// Provider name (e.g. `"openai"`, `"nvidia"`).
274        provider: String,
275        /// HTTP status code, or `0` for transport-level failures.
276        status_code: u16,
277        /// Error message from the provider response body, or transport error description.
278        message: String,
279    },
280
281    /// The step asked for a tool profile the provider does not have.
282    ///
283    /// Never falls back to other tools: a typo must not widen what the model
284    /// can do. Deterministic, so never retried.
285    #[error(
286        "unknown tool profile '{profile}' (registered profiles: {})",
287        list_or_none(available)
288    )]
289    UnknownToolProfile {
290        /// The profile the step asked for.
291        profile: String,
292        /// Profiles registered on the provider, sorted.
293        available: Vec<String>,
294    },
295
296    /// The step asked for a tool profile, but the provider cannot apply one.
297    ///
298    /// Claude CLI providers pick their tools with `allowed_tools` and MCP
299    /// configuration; they refuse a profile rather than ignore it.
300    #[error(
301        "{provider} does not support tool profiles (step asked for '{profile}'): pick its tools with allow_tool or an MCP config"
302    )]
303    ToolProfileUnsupported {
304        /// Provider that refused the profile (e.g. `"claude-code"`).
305        provider: String,
306        /// The profile the step asked for.
307        profile: String,
308    },
309
310    /// No provider account can run the step, and the run will not wait for one.
311    ///
312    /// Returned when every targeted account (or the worker's own token) is
313    /// rate limited and the next reset is unknown or further away than the
314    /// step's `max_capacity_wait`, or when that wait is `Duration::ZERO`
315    /// (fast fail). Never retried: replaying it at once hits the same limits.
316    ///
317    /// # Examples
318    ///
319    /// ```
320    /// use ironflow_core::error::AgentError;
321    ///
322    /// let err = AgentError::NoCapacity {
323    ///     kind: "claude".to_string(),
324    ///     next_reset: None,
325    /// };
326    /// assert!(err.to_string().contains("no capacity for claude"));
327    /// ```
328    #[error(
329        "no capacity for {kind}: every targeted account is rate limited (next reset {})",
330        next_reset.map_or_else(|| "unknown".to_string(), |at| at.to_rfc3339())
331    )]
332    NoCapacity {
333        /// Provider kind the step needed (e.g. `"claude"`).
334        kind: String,
335        /// Earliest moment a targeted rate limit window resets, when known.
336        next_reset: Option<DateTime<Utc>>,
337    },
338
339    /// Not a failure: asks the engine to suspend the run until `wake_at`.
340    ///
341    /// Returned by the account-aware provider when every targeted account is
342    /// rate limited but capacity comes back within the step's
343    /// `max_capacity_wait`. The engine puts the run to sleep and re-executes
344    /// the step from zero when it wakes, or earlier when an account of `kind`
345    /// is added, re-enabled or gets a new token. Never retried.
346    ///
347    /// # Examples
348    ///
349    /// ```
350    /// use chrono::Utc;
351    /// use ironflow_core::error::AgentError;
352    ///
353    /// let err = AgentError::CapacityWait {
354    ///     kind: "claude".to_string(),
355    ///     wake_at: Utc::now(),
356    /// };
357    /// assert!(err.to_string().contains("waiting for claude capacity"));
358    /// ```
359    #[error("waiting for {kind} capacity until {}", wake_at.to_rfc3339())]
360    CapacityWait {
361        /// Provider kind the step needs (e.g. `"claude"`).
362        kind: String,
363        /// Moment the engine should wake the run and retry the step.
364        wake_at: DateTime<Utc>,
365    },
366
367    /// The step asked for a provider account by name and none matches.
368    ///
369    /// Never falls back to another account or to the worker's own token: a
370    /// step pinned to an account must not silently spend another one.
371    ///
372    /// # Examples
373    ///
374    /// ```
375    /// use ironflow_core::error::AgentError;
376    ///
377    /// let err = AgentError::AccountNotFound {
378    ///     name: "team-a".to_string(),
379    /// };
380    /// assert_eq!(err.to_string(), "provider account 'team-a' not found");
381    /// ```
382    #[error("provider account '{name}' not found")]
383    AccountNotFound {
384        /// The account name the step asked for.
385        name: String,
386    },
387}
388
389fn api_error_context(status: Option<u16>, code: Option<&str>) -> String {
390    let status = status.map_or_else(|| "no status".to_string(), |s| format!("status {s}"));
391    match code {
392        Some(code) => format!("{status}, api_error_code {code}"),
393        None => status,
394    }
395}
396
397fn list_or_none(names: &[String]) -> String {
398    if names.is_empty() {
399        "none".to_string()
400    } else {
401        names.join(", ")
402    }
403}
404
405/// Error raised when accessing a typed answer on a
406/// [`DecisionOutput`](crate::decision::DecisionOutput) by name.
407///
408/// Distinct from [`AgentError`]: this is a lookup error on an already-received
409/// decision result, not a backend failure.
410///
411/// # Examples
412///
413/// ```
414/// use ironflow_core::error::DecisionError;
415///
416/// let err = DecisionError::NotFound("dept".to_string());
417/// assert_eq!(err.to_string(), "no decision answer named 'dept'");
418/// ```
419#[derive(Debug, Error)]
420pub enum DecisionError {
421    /// No answer exists under the requested name.
422    #[error("no decision answer named '{0}'")]
423    NotFound(String),
424
425    /// An answer exists but is a different kind than requested.
426    #[error("decision answer '{name}' is a {actual}, not a {expected}")]
427    TypeMismatch {
428        /// The answer name that was looked up.
429        name: String,
430        /// The kind the caller requested (`"noul"`, `"choice"`, or `"score"`).
431        expected: &'static str,
432        /// The kind the answer actually is.
433        actual: &'static str,
434    },
435
436    /// A choice answer picked an option the question did not offer.
437    #[error("decision answer '{name}' picked '{choice}', which is not one of its options")]
438    UnknownChoice {
439        /// The answer name that was read.
440        name: String,
441        /// The option the provider returned.
442        choice: String,
443    },
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    #[test]
451    fn shell_display_format() {
452        let err = OperationError::Shell {
453            exit_code: 127,
454            stderr: "command not found".to_string(),
455        };
456        assert_eq!(
457            err.to_string(),
458            "shell exited with code 127: command not found"
459        );
460    }
461
462    #[test]
463    fn agent_display_delegates_to_agent_error() {
464        let inner = AgentError::ProcessFailed {
465            exit_code: 1,
466            stderr: "boom".to_string(),
467        };
468        let err = OperationError::Agent(inner);
469        assert_eq!(
470            err.to_string(),
471            "agent error: claude process exited with code 1: boom"
472        );
473    }
474
475    #[test]
476    fn timeout_display_format() {
477        let err = OperationError::Timeout {
478            step: "build".to_string(),
479            limit: Duration::from_secs(30),
480        };
481        assert_eq!(err.to_string(), "step 'build' timed out after 30s");
482    }
483
484    #[test]
485    fn agent_error_process_failed_display_zero_exit_code() {
486        let err = AgentError::ProcessFailed {
487            exit_code: 0,
488            stderr: "unexpected".to_string(),
489        };
490        assert_eq!(
491            err.to_string(),
492            "claude process exited with code 0: unexpected"
493        );
494    }
495
496    #[test]
497    fn agent_error_process_failed_display_negative_exit_code() {
498        let err = AgentError::ProcessFailed {
499            exit_code: -1,
500            stderr: "killed".to_string(),
501        };
502        assert!(err.to_string().contains("-1"));
503    }
504
505    #[test]
506    fn agent_error_schema_validation_display() {
507        let err = AgentError::SchemaValidation {
508            expected: "object".to_string(),
509            got: "string".to_string(),
510            debug_messages: Vec::new(),
511            partial_usage: Box::default(),
512            raw_response: None,
513        };
514        assert_eq!(
515            err.to_string(),
516            "schema validation failed: expected object, got string"
517        );
518    }
519
520    #[test]
521    fn api_error_display_with_status_and_code() {
522        let err = AgentError::Api {
523            status: Some(400),
524            code: Some("claude_code_version_too_old".to_string()),
525            message: "API Error: 400 too old".to_string(),
526        };
527        assert_eq!(
528            err.to_string(),
529            "claude api error (status 400, api_error_code claude_code_version_too_old): API Error: 400 too old"
530        );
531    }
532
533    #[test]
534    fn api_error_display_without_status_or_code() {
535        let err = AgentError::Api {
536            status: None,
537            code: None,
538            message: "API Error: Connection error.".to_string(),
539        };
540        assert_eq!(
541            err.to_string(),
542            "claude api error (no status): API Error: Connection error."
543        );
544    }
545
546    #[test]
547    fn agent_error_timeout_display() {
548        let err = AgentError::Timeout {
549            limit: Duration::from_secs(300),
550        };
551        assert_eq!(err.to_string(), "agent timed out after 300s");
552    }
553
554    #[test]
555    fn from_agent_error_process_failed() {
556        let agent_err = AgentError::ProcessFailed {
557            exit_code: 42,
558            stderr: "fail".to_string(),
559        };
560        let op_err: OperationError = agent_err.into();
561        assert!(matches!(
562            op_err,
563            OperationError::Agent(AgentError::ProcessFailed { exit_code: 42, .. })
564        ));
565    }
566
567    #[test]
568    fn from_agent_error_schema_validation() {
569        let agent_err = AgentError::SchemaValidation {
570            expected: "a".to_string(),
571            got: "b".to_string(),
572            debug_messages: Vec::new(),
573            partial_usage: Box::default(),
574            raw_response: None,
575        };
576        let op_err: OperationError = agent_err.into();
577        assert!(matches!(
578            op_err,
579            OperationError::Agent(AgentError::SchemaValidation { .. })
580        ));
581    }
582
583    #[test]
584    fn from_agent_error_timeout() {
585        let agent_err = AgentError::Timeout {
586            limit: Duration::from_secs(60),
587        };
588        let op_err: OperationError = agent_err.into();
589        assert!(matches!(
590            op_err,
591            OperationError::Agent(AgentError::Timeout { .. })
592        ));
593    }
594
595    #[test]
596    fn operation_error_implements_std_error() {
597        use std::error::Error;
598        let err = OperationError::Shell {
599            exit_code: 1,
600            stderr: "x".to_string(),
601        };
602        let _: &dyn Error = &err;
603    }
604
605    #[test]
606    fn agent_error_implements_std_error() {
607        use std::error::Error;
608        let err = AgentError::Timeout {
609            limit: Duration::from_secs(60),
610        };
611        let _: &dyn Error = &err;
612    }
613
614    #[test]
615    fn empty_stderr_edge_case() {
616        let err = OperationError::Shell {
617            exit_code: 1,
618            stderr: String::new(),
619        };
620        assert_eq!(err.to_string(), "shell exited with code 1: ");
621    }
622
623    #[test]
624    fn multiline_stderr() {
625        let err = AgentError::ProcessFailed {
626            exit_code: 1,
627            stderr: "line1\nline2\nline3".to_string(),
628        };
629        assert!(err.to_string().contains("line1\nline2\nline3"));
630    }
631
632    #[test]
633    fn unicode_in_stderr() {
634        let err = OperationError::Shell {
635            exit_code: 1,
636            stderr: "erreur: fichier introuvable \u{1F4A5}".to_string(),
637        };
638        assert!(err.to_string().contains("\u{1F4A5}"));
639    }
640
641    #[test]
642    fn http_error_with_status_display() {
643        let err = OperationError::Http {
644            status: Some(500),
645            message: "internal server error".to_string(),
646        };
647        assert_eq!(
648            err.to_string(),
649            "http error (status 500): internal server error"
650        );
651    }
652
653    #[test]
654    fn http_error_without_status_display() {
655        let err = OperationError::Http {
656            status: None,
657            message: "connection refused".to_string(),
658        };
659        assert_eq!(err.to_string(), "http error: connection refused");
660    }
661
662    #[test]
663    fn http_error_empty_message() {
664        let err = OperationError::Http {
665            status: Some(404),
666            message: String::new(),
667        };
668        assert_eq!(err.to_string(), "http error (status 404): ");
669    }
670
671    #[test]
672    fn subsecond_duration_in_timeout_display() {
673        let err = OperationError::Timeout {
674            step: "fast".to_string(),
675            limit: Duration::from_millis(500),
676        };
677        assert_eq!(err.to_string(), "step 'fast' timed out after 500ms");
678    }
679
680    #[test]
681    fn source_chains_agent_error() {
682        use std::error::Error;
683        let err = OperationError::Agent(AgentError::Timeout {
684            limit: Duration::from_secs(60),
685        });
686        assert!(err.source().is_some());
687    }
688
689    #[test]
690    fn source_none_for_shell() {
691        use std::error::Error;
692        let err = OperationError::Shell {
693            exit_code: 1,
694            stderr: "x".to_string(),
695        };
696        assert!(err.source().is_none());
697    }
698
699    #[test]
700    fn deserialize_helper_formats_correctly() {
701        let err = OperationError::deserialize::<Vec<String>>(format_args!("missing field"));
702        match &err {
703            OperationError::Deserialize {
704                target_type,
705                reason,
706            } => {
707                assert!(target_type.contains("Vec"));
708                assert!(target_type.contains("String"));
709                assert_eq!(reason, "missing field");
710            }
711            _ => panic!("expected Deserialize variant"),
712        }
713    }
714
715    #[test]
716    fn deserialize_display_format() {
717        let err = OperationError::Deserialize {
718            target_type: "MyStruct".to_string(),
719            reason: "bad input".to_string(),
720        };
721        assert_eq!(
722            err.to_string(),
723            "failed to deserialize into MyStruct: bad input"
724        );
725    }
726
727    #[test]
728    fn agent_error_prompt_too_large_display() {
729        let err = AgentError::PromptTooLarge {
730            chars: 966_007,
731            estimated_tokens: 241_501,
732            model_limit: 200_000,
733        };
734        let msg = err.to_string();
735        assert!(msg.contains("966007 chars"));
736        assert!(msg.contains("241501 tokens"));
737        assert!(msg.contains("200000 tokens"));
738    }
739
740    #[test]
741    fn from_agent_error_prompt_too_large() {
742        let agent_err = AgentError::PromptTooLarge {
743            chars: 1_000_000,
744            estimated_tokens: 250_000,
745            model_limit: 200_000,
746        };
747        let op_err: OperationError = agent_err.into();
748        assert!(matches!(
749            op_err,
750            OperationError::Agent(AgentError::PromptTooLarge {
751                model_limit: 200_000,
752                ..
753            })
754        ));
755    }
756
757    #[test]
758    fn source_none_for_http_timeout_deserialize() {
759        use std::error::Error;
760        let http = OperationError::Http {
761            status: Some(500),
762            message: "x".to_string(),
763        };
764        assert!(http.source().is_none());
765
766        let timeout = OperationError::Timeout {
767            step: "x".to_string(),
768            limit: Duration::from_secs(1),
769        };
770        assert!(timeout.source().is_none());
771
772        let deser = OperationError::Deserialize {
773            target_type: "T".to_string(),
774            reason: "r".to_string(),
775        };
776        assert!(deser.source().is_none());
777    }
778
779    #[test]
780    fn schema_validation_raw_response_preserved() {
781        let err = AgentError::SchemaValidation {
782            expected: "structured_output field".to_string(),
783            got: "null".to_string(),
784            debug_messages: Vec::new(),
785            partial_usage: Box::default(),
786            raw_response: Some("The model said something useful".to_string()),
787        };
788        match err {
789            AgentError::SchemaValidation { raw_response, .. } => {
790                assert_eq!(
791                    raw_response.as_deref(),
792                    Some("The model said something useful")
793                );
794            }
795            _ => panic!("expected SchemaValidation"),
796        }
797    }
798
799    #[test]
800    fn external_error_display() {
801        let err = OperationError::External {
802            origin: "git".to_string(),
803            message: "reference not found".to_string(),
804        };
805        assert_eq!(err.to_string(), "git error: reference not found");
806    }
807
808    #[test]
809    fn schema_validation_raw_response_none_by_default() {
810        let err = AgentError::SchemaValidation {
811            expected: "a".to_string(),
812            got: "b".to_string(),
813            debug_messages: Vec::new(),
814            partial_usage: Box::default(),
815            raw_response: None,
816        };
817        match err {
818            AgentError::SchemaValidation { raw_response, .. } => {
819                assert!(raw_response.is_none());
820            }
821            _ => panic!("expected SchemaValidation"),
822        }
823    }
824}