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