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}