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
74impl OperationError {
75 pub fn deserialize<T>(error: impl fmt::Display) -> Self {
77 Self::Deserialize {
78 target_type: any::type_name::<T>().to_string(),
79 reason: error.to_string(),
80 }
81 }
82}
83
84#[derive(Debug, Default)]
90pub struct PartialUsage {
91 pub cost_usd: Option<f64>,
93 pub duration_ms: Option<u64>,
95 pub input_tokens: Option<u64>,
97 pub output_tokens: Option<u64>,
99}
100
101#[derive(Debug, Error)]
106pub enum AgentError {
107 #[error("claude process exited with code {exit_code}: {stderr}")]
109 ProcessFailed {
110 exit_code: i32,
112 stderr: String,
114 },
115
116 #[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())]
118 SchemaValidation {
119 expected: String,
121 got: String,
123 debug_messages: Vec<DebugMessage>,
128 partial_usage: Box<PartialUsage>,
132 raw_response: Option<String>,
138 },
139
140 #[error("agent budget exceeded: spent ${spent_usd:.4} of ${limit_usd:.4} limit")]
147 BudgetExceeded {
148 spent_usd: f64,
150 limit_usd: f64,
152 debug_messages: Vec<DebugMessage>,
154 partial_usage: Box<PartialUsage>,
157 },
158
159 #[error(
168 "prompt too large: {chars} chars (~{estimated_tokens} tokens) exceeds model limit of {model_limit} tokens"
169 )]
170 PromptTooLarge {
171 chars: usize,
173 estimated_tokens: usize,
175 model_limit: usize,
177 },
178
179 #[error("agent timed out after {limit:?}")]
181 Timeout {
182 limit: Duration,
184 },
185
186 #[error("rate limited by {provider}, retry after {retry_after_secs:?}s")]
188 RateLimited {
189 provider: String,
191 retry_after_secs: Option<u64>,
193 },
194
195 #[error("{provider} HTTP {status_code}: {message}")]
200 HttpProvider {
201 provider: String,
203 status_code: u16,
205 message: String,
207 },
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn shell_display_format() {
216 let err = OperationError::Shell {
217 exit_code: 127,
218 stderr: "command not found".to_string(),
219 };
220 assert_eq!(
221 err.to_string(),
222 "shell exited with code 127: command not found"
223 );
224 }
225
226 #[test]
227 fn agent_display_delegates_to_agent_error() {
228 let inner = AgentError::ProcessFailed {
229 exit_code: 1,
230 stderr: "boom".to_string(),
231 };
232 let err = OperationError::Agent(inner);
233 assert_eq!(
234 err.to_string(),
235 "agent error: claude process exited with code 1: boom"
236 );
237 }
238
239 #[test]
240 fn timeout_display_format() {
241 let err = OperationError::Timeout {
242 step: "build".to_string(),
243 limit: Duration::from_secs(30),
244 };
245 assert_eq!(err.to_string(), "step 'build' timed out after 30s");
246 }
247
248 #[test]
249 fn agent_error_process_failed_display_zero_exit_code() {
250 let err = AgentError::ProcessFailed {
251 exit_code: 0,
252 stderr: "unexpected".to_string(),
253 };
254 assert_eq!(
255 err.to_string(),
256 "claude process exited with code 0: unexpected"
257 );
258 }
259
260 #[test]
261 fn agent_error_process_failed_display_negative_exit_code() {
262 let err = AgentError::ProcessFailed {
263 exit_code: -1,
264 stderr: "killed".to_string(),
265 };
266 assert!(err.to_string().contains("-1"));
267 }
268
269 #[test]
270 fn agent_error_schema_validation_display() {
271 let err = AgentError::SchemaValidation {
272 expected: "object".to_string(),
273 got: "string".to_string(),
274 debug_messages: Vec::new(),
275 partial_usage: Box::default(),
276 raw_response: None,
277 };
278 assert_eq!(
279 err.to_string(),
280 "schema validation failed: expected object, got string"
281 );
282 }
283
284 #[test]
285 fn agent_error_timeout_display() {
286 let err = AgentError::Timeout {
287 limit: Duration::from_secs(300),
288 };
289 assert_eq!(err.to_string(), "agent timed out after 300s");
290 }
291
292 #[test]
293 fn from_agent_error_process_failed() {
294 let agent_err = AgentError::ProcessFailed {
295 exit_code: 42,
296 stderr: "fail".to_string(),
297 };
298 let op_err: OperationError = agent_err.into();
299 assert!(matches!(
300 op_err,
301 OperationError::Agent(AgentError::ProcessFailed { exit_code: 42, .. })
302 ));
303 }
304
305 #[test]
306 fn from_agent_error_schema_validation() {
307 let agent_err = AgentError::SchemaValidation {
308 expected: "a".to_string(),
309 got: "b".to_string(),
310 debug_messages: Vec::new(),
311 partial_usage: Box::default(),
312 raw_response: None,
313 };
314 let op_err: OperationError = agent_err.into();
315 assert!(matches!(
316 op_err,
317 OperationError::Agent(AgentError::SchemaValidation { .. })
318 ));
319 }
320
321 #[test]
322 fn from_agent_error_timeout() {
323 let agent_err = AgentError::Timeout {
324 limit: Duration::from_secs(60),
325 };
326 let op_err: OperationError = agent_err.into();
327 assert!(matches!(
328 op_err,
329 OperationError::Agent(AgentError::Timeout { .. })
330 ));
331 }
332
333 #[test]
334 fn operation_error_implements_std_error() {
335 use std::error::Error;
336 let err = OperationError::Shell {
337 exit_code: 1,
338 stderr: "x".to_string(),
339 };
340 let _: &dyn Error = &err;
341 }
342
343 #[test]
344 fn agent_error_implements_std_error() {
345 use std::error::Error;
346 let err = AgentError::Timeout {
347 limit: Duration::from_secs(60),
348 };
349 let _: &dyn Error = &err;
350 }
351
352 #[test]
353 fn empty_stderr_edge_case() {
354 let err = OperationError::Shell {
355 exit_code: 1,
356 stderr: String::new(),
357 };
358 assert_eq!(err.to_string(), "shell exited with code 1: ");
359 }
360
361 #[test]
362 fn multiline_stderr() {
363 let err = AgentError::ProcessFailed {
364 exit_code: 1,
365 stderr: "line1\nline2\nline3".to_string(),
366 };
367 assert!(err.to_string().contains("line1\nline2\nline3"));
368 }
369
370 #[test]
371 fn unicode_in_stderr() {
372 let err = OperationError::Shell {
373 exit_code: 1,
374 stderr: "erreur: fichier introuvable \u{1F4A5}".to_string(),
375 };
376 assert!(err.to_string().contains("\u{1F4A5}"));
377 }
378
379 #[test]
380 fn http_error_with_status_display() {
381 let err = OperationError::Http {
382 status: Some(500),
383 message: "internal server error".to_string(),
384 };
385 assert_eq!(
386 err.to_string(),
387 "http error (status 500): internal server error"
388 );
389 }
390
391 #[test]
392 fn http_error_without_status_display() {
393 let err = OperationError::Http {
394 status: None,
395 message: "connection refused".to_string(),
396 };
397 assert_eq!(err.to_string(), "http error: connection refused");
398 }
399
400 #[test]
401 fn http_error_empty_message() {
402 let err = OperationError::Http {
403 status: Some(404),
404 message: String::new(),
405 };
406 assert_eq!(err.to_string(), "http error (status 404): ");
407 }
408
409 #[test]
410 fn subsecond_duration_in_timeout_display() {
411 let err = OperationError::Timeout {
412 step: "fast".to_string(),
413 limit: Duration::from_millis(500),
414 };
415 assert_eq!(err.to_string(), "step 'fast' timed out after 500ms");
416 }
417
418 #[test]
419 fn source_chains_agent_error() {
420 use std::error::Error;
421 let err = OperationError::Agent(AgentError::Timeout {
422 limit: Duration::from_secs(60),
423 });
424 assert!(err.source().is_some());
425 }
426
427 #[test]
428 fn source_none_for_shell() {
429 use std::error::Error;
430 let err = OperationError::Shell {
431 exit_code: 1,
432 stderr: "x".to_string(),
433 };
434 assert!(err.source().is_none());
435 }
436
437 #[test]
438 fn deserialize_helper_formats_correctly() {
439 let err = OperationError::deserialize::<Vec<String>>(format_args!("missing field"));
440 match &err {
441 OperationError::Deserialize {
442 target_type,
443 reason,
444 } => {
445 assert!(target_type.contains("Vec"));
446 assert!(target_type.contains("String"));
447 assert_eq!(reason, "missing field");
448 }
449 _ => panic!("expected Deserialize variant"),
450 }
451 }
452
453 #[test]
454 fn deserialize_display_format() {
455 let err = OperationError::Deserialize {
456 target_type: "MyStruct".to_string(),
457 reason: "bad input".to_string(),
458 };
459 assert_eq!(
460 err.to_string(),
461 "failed to deserialize into MyStruct: bad input"
462 );
463 }
464
465 #[test]
466 fn agent_error_prompt_too_large_display() {
467 let err = AgentError::PromptTooLarge {
468 chars: 966_007,
469 estimated_tokens: 241_501,
470 model_limit: 200_000,
471 };
472 let msg = err.to_string();
473 assert!(msg.contains("966007 chars"));
474 assert!(msg.contains("241501 tokens"));
475 assert!(msg.contains("200000 tokens"));
476 }
477
478 #[test]
479 fn from_agent_error_prompt_too_large() {
480 let agent_err = AgentError::PromptTooLarge {
481 chars: 1_000_000,
482 estimated_tokens: 250_000,
483 model_limit: 200_000,
484 };
485 let op_err: OperationError = agent_err.into();
486 assert!(matches!(
487 op_err,
488 OperationError::Agent(AgentError::PromptTooLarge {
489 model_limit: 200_000,
490 ..
491 })
492 ));
493 }
494
495 #[test]
496 fn source_none_for_http_timeout_deserialize() {
497 use std::error::Error;
498 let http = OperationError::Http {
499 status: Some(500),
500 message: "x".to_string(),
501 };
502 assert!(http.source().is_none());
503
504 let timeout = OperationError::Timeout {
505 step: "x".to_string(),
506 limit: Duration::from_secs(1),
507 };
508 assert!(timeout.source().is_none());
509
510 let deser = OperationError::Deserialize {
511 target_type: "T".to_string(),
512 reason: "r".to_string(),
513 };
514 assert!(deser.source().is_none());
515 }
516
517 #[test]
518 fn schema_validation_raw_response_preserved() {
519 let err = AgentError::SchemaValidation {
520 expected: "structured_output field".to_string(),
521 got: "null".to_string(),
522 debug_messages: Vec::new(),
523 partial_usage: Box::default(),
524 raw_response: Some("The model said something useful".to_string()),
525 };
526 match err {
527 AgentError::SchemaValidation { raw_response, .. } => {
528 assert_eq!(
529 raw_response.as_deref(),
530 Some("The model said something useful")
531 );
532 }
533 _ => panic!("expected SchemaValidation"),
534 }
535 }
536
537 #[test]
538 fn schema_validation_raw_response_none_by_default() {
539 let err = AgentError::SchemaValidation {
540 expected: "a".to_string(),
541 got: "b".to_string(),
542 debug_messages: Vec::new(),
543 partial_usage: Box::default(),
544 raw_response: None,
545 };
546 match err {
547 AgentError::SchemaValidation { raw_response, .. } => {
548 assert!(raw_response.is_none());
549 }
550 _ => panic!("expected SchemaValidation"),
551 }
552 }
553}