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