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