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}