1use std::any;
12use std::fmt;
13use std::time::Duration;
14
15use thiserror::Error;
16
17use crate::provider::DebugMessage;
18
19#[derive(Debug, Error)]
23pub enum OperationError {
24 #[error("shell exited with code {exit_code}: {stderr}")]
26 Shell {
27 exit_code: i32,
29 stderr: String,
31 },
32
33 #[error("agent error: {0}")]
37 Agent(#[from] AgentError),
38
39 #[error("step '{step}' timed out after {limit:?}")]
41 Timeout {
42 step: String,
44 limit: Duration,
46 },
47
48 #[error("{}", match status {
51 Some(code) => format!("http error (status {code}): {message}"),
52 None => format!("http error: {message}"),
53 })]
54 Http {
55 status: Option<u16>,
57 message: String,
59 },
60
61 #[error("failed to deserialize into {target_type}: {reason}")]
66 Deserialize {
67 target_type: String,
69 reason: String,
71 },
72
73 #[error("secret error: {message}")]
78 Secret {
79 message: String,
81 },
82
83 #[error("{origin} error: {message}")]
100 External {
101 origin: String,
103 message: String,
105 },
106}
107
108impl OperationError {
109 pub fn deserialize<T>(error: impl fmt::Display) -> Self {
111 Self::Deserialize {
112 target_type: any::type_name::<T>().to_string(),
113 reason: error.to_string(),
114 }
115 }
116}
117
118#[derive(Debug, Default)]
124pub struct PartialUsage {
125 pub cost_usd: Option<f64>,
127 pub duration_ms: Option<u64>,
129 pub input_tokens: Option<u64>,
131 pub cache_read_input_tokens: Option<u64>,
133 pub cache_creation_input_tokens: Option<u64>,
135 pub output_tokens: Option<u64>,
137}
138
139#[derive(Debug, Error)]
144pub enum AgentError {
145 #[error("claude process exited with code {exit_code}: {stderr}")]
147 ProcessFailed {
148 exit_code: i32,
150 stderr: String,
152 },
153
154 #[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())]
156 SchemaValidation {
157 expected: String,
159 got: String,
161 debug_messages: Vec<DebugMessage>,
166 partial_usage: Box<PartialUsage>,
170 raw_response: Option<String>,
176 },
177
178 #[error("agent budget exceeded: spent ${spent_usd:.4} of ${limit_usd:.4} limit")]
185 BudgetExceeded {
186 spent_usd: f64,
188 limit_usd: f64,
190 debug_messages: Vec<DebugMessage>,
192 partial_usage: Box<PartialUsage>,
195 },
196
197 #[error("claude api error ({}): {message}", api_error_context(*status, code.as_deref()))]
219 Api {
220 status: Option<u16>,
223 code: Option<String>,
226 message: String,
228 },
229
230 #[error(
239 "prompt too large: {chars} chars (~{estimated_tokens} tokens) exceeds model limit of {model_limit} tokens"
240 )]
241 PromptTooLarge {
242 chars: usize,
244 estimated_tokens: usize,
246 model_limit: usize,
248 },
249
250 #[error("agent timed out after {limit:?}")]
252 Timeout {
253 limit: Duration,
255 },
256
257 #[error("rate limited by {provider}, retry after {retry_after_secs:?}s")]
259 RateLimited {
260 provider: String,
262 retry_after_secs: Option<u64>,
264 },
265
266 #[error("{provider} HTTP {status_code}: {message}")]
271 HttpProvider {
272 provider: String,
274 status_code: u16,
276 message: String,
278 },
279
280 #[error(
285 "unknown tool profile '{profile}' (registered profiles: {})",
286 list_or_none(available)
287 )]
288 UnknownToolProfile {
289 profile: String,
291 available: Vec<String>,
293 },
294
295 #[error(
300 "{provider} does not support tool profiles (step asked for '{profile}'): pick its tools with allow_tool or an MCP config"
301 )]
302 ToolProfileUnsupported {
303 provider: String,
305 profile: String,
307 },
308}
309
310fn api_error_context(status: Option<u16>, code: Option<&str>) -> String {
311 let status = status.map_or_else(|| "no status".to_string(), |s| format!("status {s}"));
312 match code {
313 Some(code) => format!("{status}, api_error_code {code}"),
314 None => status,
315 }
316}
317
318fn list_or_none(names: &[String]) -> String {
319 if names.is_empty() {
320 "none".to_string()
321 } else {
322 names.join(", ")
323 }
324}
325
326#[derive(Debug, Error)]
341pub enum DecisionError {
342 #[error("no decision answer named '{0}'")]
344 NotFound(String),
345
346 #[error("decision answer '{name}' is a {actual}, not a {expected}")]
348 TypeMismatch {
349 name: String,
351 expected: &'static str,
353 actual: &'static str,
355 },
356
357 #[error("decision answer '{name}' picked '{choice}', which is not one of its options")]
359 UnknownChoice {
360 name: String,
362 choice: String,
364 },
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370
371 #[test]
372 fn shell_display_format() {
373 let err = OperationError::Shell {
374 exit_code: 127,
375 stderr: "command not found".to_string(),
376 };
377 assert_eq!(
378 err.to_string(),
379 "shell exited with code 127: command not found"
380 );
381 }
382
383 #[test]
384 fn agent_display_delegates_to_agent_error() {
385 let inner = AgentError::ProcessFailed {
386 exit_code: 1,
387 stderr: "boom".to_string(),
388 };
389 let err = OperationError::Agent(inner);
390 assert_eq!(
391 err.to_string(),
392 "agent error: claude process exited with code 1: boom"
393 );
394 }
395
396 #[test]
397 fn timeout_display_format() {
398 let err = OperationError::Timeout {
399 step: "build".to_string(),
400 limit: Duration::from_secs(30),
401 };
402 assert_eq!(err.to_string(), "step 'build' timed out after 30s");
403 }
404
405 #[test]
406 fn agent_error_process_failed_display_zero_exit_code() {
407 let err = AgentError::ProcessFailed {
408 exit_code: 0,
409 stderr: "unexpected".to_string(),
410 };
411 assert_eq!(
412 err.to_string(),
413 "claude process exited with code 0: unexpected"
414 );
415 }
416
417 #[test]
418 fn agent_error_process_failed_display_negative_exit_code() {
419 let err = AgentError::ProcessFailed {
420 exit_code: -1,
421 stderr: "killed".to_string(),
422 };
423 assert!(err.to_string().contains("-1"));
424 }
425
426 #[test]
427 fn agent_error_schema_validation_display() {
428 let err = AgentError::SchemaValidation {
429 expected: "object".to_string(),
430 got: "string".to_string(),
431 debug_messages: Vec::new(),
432 partial_usage: Box::default(),
433 raw_response: None,
434 };
435 assert_eq!(
436 err.to_string(),
437 "schema validation failed: expected object, got string"
438 );
439 }
440
441 #[test]
442 fn api_error_display_with_status_and_code() {
443 let err = AgentError::Api {
444 status: Some(400),
445 code: Some("claude_code_version_too_old".to_string()),
446 message: "API Error: 400 too old".to_string(),
447 };
448 assert_eq!(
449 err.to_string(),
450 "claude api error (status 400, api_error_code claude_code_version_too_old): API Error: 400 too old"
451 );
452 }
453
454 #[test]
455 fn api_error_display_without_status_or_code() {
456 let err = AgentError::Api {
457 status: None,
458 code: None,
459 message: "API Error: Connection error.".to_string(),
460 };
461 assert_eq!(
462 err.to_string(),
463 "claude api error (no status): API Error: Connection error."
464 );
465 }
466
467 #[test]
468 fn agent_error_timeout_display() {
469 let err = AgentError::Timeout {
470 limit: Duration::from_secs(300),
471 };
472 assert_eq!(err.to_string(), "agent timed out after 300s");
473 }
474
475 #[test]
476 fn from_agent_error_process_failed() {
477 let agent_err = AgentError::ProcessFailed {
478 exit_code: 42,
479 stderr: "fail".to_string(),
480 };
481 let op_err: OperationError = agent_err.into();
482 assert!(matches!(
483 op_err,
484 OperationError::Agent(AgentError::ProcessFailed { exit_code: 42, .. })
485 ));
486 }
487
488 #[test]
489 fn from_agent_error_schema_validation() {
490 let agent_err = AgentError::SchemaValidation {
491 expected: "a".to_string(),
492 got: "b".to_string(),
493 debug_messages: Vec::new(),
494 partial_usage: Box::default(),
495 raw_response: None,
496 };
497 let op_err: OperationError = agent_err.into();
498 assert!(matches!(
499 op_err,
500 OperationError::Agent(AgentError::SchemaValidation { .. })
501 ));
502 }
503
504 #[test]
505 fn from_agent_error_timeout() {
506 let agent_err = AgentError::Timeout {
507 limit: Duration::from_secs(60),
508 };
509 let op_err: OperationError = agent_err.into();
510 assert!(matches!(
511 op_err,
512 OperationError::Agent(AgentError::Timeout { .. })
513 ));
514 }
515
516 #[test]
517 fn operation_error_implements_std_error() {
518 use std::error::Error;
519 let err = OperationError::Shell {
520 exit_code: 1,
521 stderr: "x".to_string(),
522 };
523 let _: &dyn Error = &err;
524 }
525
526 #[test]
527 fn agent_error_implements_std_error() {
528 use std::error::Error;
529 let err = AgentError::Timeout {
530 limit: Duration::from_secs(60),
531 };
532 let _: &dyn Error = &err;
533 }
534
535 #[test]
536 fn empty_stderr_edge_case() {
537 let err = OperationError::Shell {
538 exit_code: 1,
539 stderr: String::new(),
540 };
541 assert_eq!(err.to_string(), "shell exited with code 1: ");
542 }
543
544 #[test]
545 fn multiline_stderr() {
546 let err = AgentError::ProcessFailed {
547 exit_code: 1,
548 stderr: "line1\nline2\nline3".to_string(),
549 };
550 assert!(err.to_string().contains("line1\nline2\nline3"));
551 }
552
553 #[test]
554 fn unicode_in_stderr() {
555 let err = OperationError::Shell {
556 exit_code: 1,
557 stderr: "erreur: fichier introuvable \u{1F4A5}".to_string(),
558 };
559 assert!(err.to_string().contains("\u{1F4A5}"));
560 }
561
562 #[test]
563 fn http_error_with_status_display() {
564 let err = OperationError::Http {
565 status: Some(500),
566 message: "internal server error".to_string(),
567 };
568 assert_eq!(
569 err.to_string(),
570 "http error (status 500): internal server error"
571 );
572 }
573
574 #[test]
575 fn http_error_without_status_display() {
576 let err = OperationError::Http {
577 status: None,
578 message: "connection refused".to_string(),
579 };
580 assert_eq!(err.to_string(), "http error: connection refused");
581 }
582
583 #[test]
584 fn http_error_empty_message() {
585 let err = OperationError::Http {
586 status: Some(404),
587 message: String::new(),
588 };
589 assert_eq!(err.to_string(), "http error (status 404): ");
590 }
591
592 #[test]
593 fn subsecond_duration_in_timeout_display() {
594 let err = OperationError::Timeout {
595 step: "fast".to_string(),
596 limit: Duration::from_millis(500),
597 };
598 assert_eq!(err.to_string(), "step 'fast' timed out after 500ms");
599 }
600
601 #[test]
602 fn source_chains_agent_error() {
603 use std::error::Error;
604 let err = OperationError::Agent(AgentError::Timeout {
605 limit: Duration::from_secs(60),
606 });
607 assert!(err.source().is_some());
608 }
609
610 #[test]
611 fn source_none_for_shell() {
612 use std::error::Error;
613 let err = OperationError::Shell {
614 exit_code: 1,
615 stderr: "x".to_string(),
616 };
617 assert!(err.source().is_none());
618 }
619
620 #[test]
621 fn deserialize_helper_formats_correctly() {
622 let err = OperationError::deserialize::<Vec<String>>(format_args!("missing field"));
623 match &err {
624 OperationError::Deserialize {
625 target_type,
626 reason,
627 } => {
628 assert!(target_type.contains("Vec"));
629 assert!(target_type.contains("String"));
630 assert_eq!(reason, "missing field");
631 }
632 _ => panic!("expected Deserialize variant"),
633 }
634 }
635
636 #[test]
637 fn deserialize_display_format() {
638 let err = OperationError::Deserialize {
639 target_type: "MyStruct".to_string(),
640 reason: "bad input".to_string(),
641 };
642 assert_eq!(
643 err.to_string(),
644 "failed to deserialize into MyStruct: bad input"
645 );
646 }
647
648 #[test]
649 fn agent_error_prompt_too_large_display() {
650 let err = AgentError::PromptTooLarge {
651 chars: 966_007,
652 estimated_tokens: 241_501,
653 model_limit: 200_000,
654 };
655 let msg = err.to_string();
656 assert!(msg.contains("966007 chars"));
657 assert!(msg.contains("241501 tokens"));
658 assert!(msg.contains("200000 tokens"));
659 }
660
661 #[test]
662 fn from_agent_error_prompt_too_large() {
663 let agent_err = AgentError::PromptTooLarge {
664 chars: 1_000_000,
665 estimated_tokens: 250_000,
666 model_limit: 200_000,
667 };
668 let op_err: OperationError = agent_err.into();
669 assert!(matches!(
670 op_err,
671 OperationError::Agent(AgentError::PromptTooLarge {
672 model_limit: 200_000,
673 ..
674 })
675 ));
676 }
677
678 #[test]
679 fn source_none_for_http_timeout_deserialize() {
680 use std::error::Error;
681 let http = OperationError::Http {
682 status: Some(500),
683 message: "x".to_string(),
684 };
685 assert!(http.source().is_none());
686
687 let timeout = OperationError::Timeout {
688 step: "x".to_string(),
689 limit: Duration::from_secs(1),
690 };
691 assert!(timeout.source().is_none());
692
693 let deser = OperationError::Deserialize {
694 target_type: "T".to_string(),
695 reason: "r".to_string(),
696 };
697 assert!(deser.source().is_none());
698 }
699
700 #[test]
701 fn schema_validation_raw_response_preserved() {
702 let err = AgentError::SchemaValidation {
703 expected: "structured_output field".to_string(),
704 got: "null".to_string(),
705 debug_messages: Vec::new(),
706 partial_usage: Box::default(),
707 raw_response: Some("The model said something useful".to_string()),
708 };
709 match err {
710 AgentError::SchemaValidation { raw_response, .. } => {
711 assert_eq!(
712 raw_response.as_deref(),
713 Some("The model said something useful")
714 );
715 }
716 _ => panic!("expected SchemaValidation"),
717 }
718 }
719
720 #[test]
721 fn external_error_display() {
722 let err = OperationError::External {
723 origin: "git".to_string(),
724 message: "reference not found".to_string(),
725 };
726 assert_eq!(err.to_string(), "git error: reference not found");
727 }
728
729 #[test]
730 fn schema_validation_raw_response_none_by_default() {
731 let err = AgentError::SchemaValidation {
732 expected: "a".to_string(),
733 got: "b".to_string(),
734 debug_messages: Vec::new(),
735 partial_usage: Box::default(),
736 raw_response: None,
737 };
738 match err {
739 AgentError::SchemaValidation { raw_response, .. } => {
740 assert!(raw_response.is_none());
741 }
742 _ => panic!("expected SchemaValidation"),
743 }
744 }
745}