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