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(
206 "prompt too large: {chars} chars (~{estimated_tokens} tokens) exceeds model limit of {model_limit} tokens"
207 )]
208 PromptTooLarge {
209 chars: usize,
211 estimated_tokens: usize,
213 model_limit: usize,
215 },
216
217 #[error("agent timed out after {limit:?}")]
219 Timeout {
220 limit: Duration,
222 },
223
224 #[error("rate limited by {provider}, retry after {retry_after_secs:?}s")]
226 RateLimited {
227 provider: String,
229 retry_after_secs: Option<u64>,
231 },
232
233 #[error("{provider} HTTP {status_code}: {message}")]
238 HttpProvider {
239 provider: String,
241 status_code: u16,
243 message: String,
245 },
246}
247
248#[derive(Debug, Error)]
263pub enum DecisionError {
264 #[error("no decision answer named '{0}'")]
266 NotFound(String),
267
268 #[error("decision answer '{name}' is a {actual}, not a {expected}")]
270 TypeMismatch {
271 name: String,
273 expected: &'static str,
275 actual: &'static str,
277 },
278
279 #[error("decision answer '{name}' picked '{choice}', which is not one of its options")]
281 UnknownChoice {
282 name: String,
284 choice: String,
286 },
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[test]
294 fn shell_display_format() {
295 let err = OperationError::Shell {
296 exit_code: 127,
297 stderr: "command not found".to_string(),
298 };
299 assert_eq!(
300 err.to_string(),
301 "shell exited with code 127: command not found"
302 );
303 }
304
305 #[test]
306 fn agent_display_delegates_to_agent_error() {
307 let inner = AgentError::ProcessFailed {
308 exit_code: 1,
309 stderr: "boom".to_string(),
310 };
311 let err = OperationError::Agent(inner);
312 assert_eq!(
313 err.to_string(),
314 "agent error: claude process exited with code 1: boom"
315 );
316 }
317
318 #[test]
319 fn timeout_display_format() {
320 let err = OperationError::Timeout {
321 step: "build".to_string(),
322 limit: Duration::from_secs(30),
323 };
324 assert_eq!(err.to_string(), "step 'build' timed out after 30s");
325 }
326
327 #[test]
328 fn agent_error_process_failed_display_zero_exit_code() {
329 let err = AgentError::ProcessFailed {
330 exit_code: 0,
331 stderr: "unexpected".to_string(),
332 };
333 assert_eq!(
334 err.to_string(),
335 "claude process exited with code 0: unexpected"
336 );
337 }
338
339 #[test]
340 fn agent_error_process_failed_display_negative_exit_code() {
341 let err = AgentError::ProcessFailed {
342 exit_code: -1,
343 stderr: "killed".to_string(),
344 };
345 assert!(err.to_string().contains("-1"));
346 }
347
348 #[test]
349 fn agent_error_schema_validation_display() {
350 let err = AgentError::SchemaValidation {
351 expected: "object".to_string(),
352 got: "string".to_string(),
353 debug_messages: Vec::new(),
354 partial_usage: Box::default(),
355 raw_response: None,
356 };
357 assert_eq!(
358 err.to_string(),
359 "schema validation failed: expected object, got string"
360 );
361 }
362
363 #[test]
364 fn agent_error_timeout_display() {
365 let err = AgentError::Timeout {
366 limit: Duration::from_secs(300),
367 };
368 assert_eq!(err.to_string(), "agent timed out after 300s");
369 }
370
371 #[test]
372 fn from_agent_error_process_failed() {
373 let agent_err = AgentError::ProcessFailed {
374 exit_code: 42,
375 stderr: "fail".to_string(),
376 };
377 let op_err: OperationError = agent_err.into();
378 assert!(matches!(
379 op_err,
380 OperationError::Agent(AgentError::ProcessFailed { exit_code: 42, .. })
381 ));
382 }
383
384 #[test]
385 fn from_agent_error_schema_validation() {
386 let agent_err = AgentError::SchemaValidation {
387 expected: "a".to_string(),
388 got: "b".to_string(),
389 debug_messages: Vec::new(),
390 partial_usage: Box::default(),
391 raw_response: None,
392 };
393 let op_err: OperationError = agent_err.into();
394 assert!(matches!(
395 op_err,
396 OperationError::Agent(AgentError::SchemaValidation { .. })
397 ));
398 }
399
400 #[test]
401 fn from_agent_error_timeout() {
402 let agent_err = AgentError::Timeout {
403 limit: Duration::from_secs(60),
404 };
405 let op_err: OperationError = agent_err.into();
406 assert!(matches!(
407 op_err,
408 OperationError::Agent(AgentError::Timeout { .. })
409 ));
410 }
411
412 #[test]
413 fn operation_error_implements_std_error() {
414 use std::error::Error;
415 let err = OperationError::Shell {
416 exit_code: 1,
417 stderr: "x".to_string(),
418 };
419 let _: &dyn Error = &err;
420 }
421
422 #[test]
423 fn agent_error_implements_std_error() {
424 use std::error::Error;
425 let err = AgentError::Timeout {
426 limit: Duration::from_secs(60),
427 };
428 let _: &dyn Error = &err;
429 }
430
431 #[test]
432 fn empty_stderr_edge_case() {
433 let err = OperationError::Shell {
434 exit_code: 1,
435 stderr: String::new(),
436 };
437 assert_eq!(err.to_string(), "shell exited with code 1: ");
438 }
439
440 #[test]
441 fn multiline_stderr() {
442 let err = AgentError::ProcessFailed {
443 exit_code: 1,
444 stderr: "line1\nline2\nline3".to_string(),
445 };
446 assert!(err.to_string().contains("line1\nline2\nline3"));
447 }
448
449 #[test]
450 fn unicode_in_stderr() {
451 let err = OperationError::Shell {
452 exit_code: 1,
453 stderr: "erreur: fichier introuvable \u{1F4A5}".to_string(),
454 };
455 assert!(err.to_string().contains("\u{1F4A5}"));
456 }
457
458 #[test]
459 fn http_error_with_status_display() {
460 let err = OperationError::Http {
461 status: Some(500),
462 message: "internal server error".to_string(),
463 };
464 assert_eq!(
465 err.to_string(),
466 "http error (status 500): internal server error"
467 );
468 }
469
470 #[test]
471 fn http_error_without_status_display() {
472 let err = OperationError::Http {
473 status: None,
474 message: "connection refused".to_string(),
475 };
476 assert_eq!(err.to_string(), "http error: connection refused");
477 }
478
479 #[test]
480 fn http_error_empty_message() {
481 let err = OperationError::Http {
482 status: Some(404),
483 message: String::new(),
484 };
485 assert_eq!(err.to_string(), "http error (status 404): ");
486 }
487
488 #[test]
489 fn subsecond_duration_in_timeout_display() {
490 let err = OperationError::Timeout {
491 step: "fast".to_string(),
492 limit: Duration::from_millis(500),
493 };
494 assert_eq!(err.to_string(), "step 'fast' timed out after 500ms");
495 }
496
497 #[test]
498 fn source_chains_agent_error() {
499 use std::error::Error;
500 let err = OperationError::Agent(AgentError::Timeout {
501 limit: Duration::from_secs(60),
502 });
503 assert!(err.source().is_some());
504 }
505
506 #[test]
507 fn source_none_for_shell() {
508 use std::error::Error;
509 let err = OperationError::Shell {
510 exit_code: 1,
511 stderr: "x".to_string(),
512 };
513 assert!(err.source().is_none());
514 }
515
516 #[test]
517 fn deserialize_helper_formats_correctly() {
518 let err = OperationError::deserialize::<Vec<String>>(format_args!("missing field"));
519 match &err {
520 OperationError::Deserialize {
521 target_type,
522 reason,
523 } => {
524 assert!(target_type.contains("Vec"));
525 assert!(target_type.contains("String"));
526 assert_eq!(reason, "missing field");
527 }
528 _ => panic!("expected Deserialize variant"),
529 }
530 }
531
532 #[test]
533 fn deserialize_display_format() {
534 let err = OperationError::Deserialize {
535 target_type: "MyStruct".to_string(),
536 reason: "bad input".to_string(),
537 };
538 assert_eq!(
539 err.to_string(),
540 "failed to deserialize into MyStruct: bad input"
541 );
542 }
543
544 #[test]
545 fn agent_error_prompt_too_large_display() {
546 let err = AgentError::PromptTooLarge {
547 chars: 966_007,
548 estimated_tokens: 241_501,
549 model_limit: 200_000,
550 };
551 let msg = err.to_string();
552 assert!(msg.contains("966007 chars"));
553 assert!(msg.contains("241501 tokens"));
554 assert!(msg.contains("200000 tokens"));
555 }
556
557 #[test]
558 fn from_agent_error_prompt_too_large() {
559 let agent_err = AgentError::PromptTooLarge {
560 chars: 1_000_000,
561 estimated_tokens: 250_000,
562 model_limit: 200_000,
563 };
564 let op_err: OperationError = agent_err.into();
565 assert!(matches!(
566 op_err,
567 OperationError::Agent(AgentError::PromptTooLarge {
568 model_limit: 200_000,
569 ..
570 })
571 ));
572 }
573
574 #[test]
575 fn source_none_for_http_timeout_deserialize() {
576 use std::error::Error;
577 let http = OperationError::Http {
578 status: Some(500),
579 message: "x".to_string(),
580 };
581 assert!(http.source().is_none());
582
583 let timeout = OperationError::Timeout {
584 step: "x".to_string(),
585 limit: Duration::from_secs(1),
586 };
587 assert!(timeout.source().is_none());
588
589 let deser = OperationError::Deserialize {
590 target_type: "T".to_string(),
591 reason: "r".to_string(),
592 };
593 assert!(deser.source().is_none());
594 }
595
596 #[test]
597 fn schema_validation_raw_response_preserved() {
598 let err = AgentError::SchemaValidation {
599 expected: "structured_output field".to_string(),
600 got: "null".to_string(),
601 debug_messages: Vec::new(),
602 partial_usage: Box::default(),
603 raw_response: Some("The model said something useful".to_string()),
604 };
605 match err {
606 AgentError::SchemaValidation { raw_response, .. } => {
607 assert_eq!(
608 raw_response.as_deref(),
609 Some("The model said something useful")
610 );
611 }
612 _ => panic!("expected SchemaValidation"),
613 }
614 }
615
616 #[test]
617 fn external_error_display() {
618 let err = OperationError::External {
619 origin: "git".to_string(),
620 message: "reference not found".to_string(),
621 };
622 assert_eq!(err.to_string(), "git error: reference not found");
623 }
624
625 #[test]
626 fn schema_validation_raw_response_none_by_default() {
627 let err = AgentError::SchemaValidation {
628 expected: "a".to_string(),
629 got: "b".to_string(),
630 debug_messages: Vec::new(),
631 partial_usage: Box::default(),
632 raw_response: None,
633 };
634 match err {
635 AgentError::SchemaValidation { raw_response, .. } => {
636 assert!(raw_response.is_none());
637 }
638 _ => panic!("expected SchemaValidation"),
639 }
640 }
641}