1use harn_lexer::Span;
2
3use super::{VmDictExt, VmValue};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ArityExpect {
10 Exact(usize),
12 Range { min: usize, max: usize },
14 AtLeast(usize),
17}
18
19impl std::fmt::Display for ArityExpect {
20 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 match self {
22 ArityExpect::Exact(n) => write!(f, "{n}"),
23 ArityExpect::Range { min, max } => write!(f, "{min}..={max}"),
24 ArityExpect::AtLeast(n) => write!(f, "at least {n}"),
25 }
26 }
27}
28
29#[derive(Debug, Clone)]
30pub struct ArityMismatchError {
31 pub callee: String,
32 pub expected: ArityExpect,
33 pub got: usize,
34 pub span: Option<Span>,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum DeadlockDiagnostic {
39 SelfDeadlock,
40 WaitForGraph,
41}
42
43impl DeadlockDiagnostic {
44 fn code(self) -> &'static str {
45 match self {
46 Self::SelfDeadlock => "HARN-ORC-011",
47 Self::WaitForGraph => "HARN-ORC-012",
48 }
49 }
50}
51
52#[derive(Debug, Clone)]
56pub struct DeadlockError {
57 pub diagnostic: DeadlockDiagnostic,
58 pub kind: String,
59 pub key: String,
60 pub detail: String,
61}
62
63impl DeadlockError {
64 pub(crate) fn self_deadlock(
65 kind: impl Into<String>,
66 key: impl Into<String>,
67 detail: impl Into<String>,
68 ) -> Self {
69 Self {
70 diagnostic: DeadlockDiagnostic::SelfDeadlock,
71 kind: kind.into(),
72 key: key.into(),
73 detail: detail.into(),
74 }
75 }
76
77 pub(crate) fn wait_for_graph(
78 kind: impl Into<String>,
79 key: impl Into<String>,
80 detail: impl Into<String>,
81 ) -> Self {
82 Self {
83 diagnostic: DeadlockDiagnostic::WaitForGraph,
84 kind: kind.into(),
85 key: key.into(),
86 detail: detail.into(),
87 }
88 }
89}
90
91#[derive(Debug, Clone)]
92pub struct ArgTypeMismatchError {
93 pub callee: String,
94 pub param: String,
95 pub expected: String,
96 pub got: &'static str,
97 pub span: Option<Span>,
98}
99
100#[derive(Debug, Clone)]
107pub struct BindingTypeMismatchError {
108 pub binding: String,
110 pub expected: String,
111 pub got: &'static str,
112 pub span: Option<Span>,
113}
114
115#[derive(Debug, Clone)]
116pub enum VmError {
117 StackUnderflow,
118 StackOverflow,
119 UndefinedVariable(String),
120 UndefinedBuiltin(String),
121 ImmutableAssignment(String),
122 TypeError(String),
123 Runtime(String),
124 DivisionByZero,
125 ExecutionDeadlineExceeded,
128 ProcessExit(i32),
133 AbandonedExecution,
138 McpInputRequired(Box<crate::mcp_input::McpInputRequired>),
142 Thrown(VmValue),
143 CategorizedError {
145 message: String,
146 category: ErrorCategory,
147 },
148 ProviderStreamFailure(Box<ProviderStreamFailure>),
153 DaemonQueueFull {
154 daemon_id: String,
155 capacity: usize,
156 },
157 Deadlock(Box<DeadlockError>),
164 Return(VmValue),
165 InvalidInstruction(u8),
166 ArityMismatch(Box<ArityMismatchError>),
170 ArgTypeMismatch(Box<ArgTypeMismatchError>),
176 BindingTypeMismatch(Box<BindingTypeMismatchError>),
180}
181
182impl VmError {
183 pub fn is_uncatchable_control_flow(&self) -> bool {
186 matches!(
187 self,
188 Self::ExecutionDeadlineExceeded | Self::ProcessExit(_) | Self::McpInputRequired(_)
189 )
190 }
191
192 pub fn process_exit_code(&self) -> Option<i32> {
195 match self {
196 Self::ProcessExit(code) => Some(*code),
197 _ => None,
198 }
199 }
200
201 pub fn thrown_value(&self) -> VmValue {
222 match self {
223 VmError::Thrown(v) => v.clone(),
224 VmError::CategorizedError { message, category } => {
225 let mut dict = std::collections::BTreeMap::new();
226 dict.put_str("category", category.as_str());
227 dict.put_str("message", message);
228 VmValue::dict(dict)
229 }
230 VmError::ProviderStreamFailure(failure) => failure.thrown_value(),
231 other => VmValue::String(arcstr::ArcStr::from(other.to_string())),
232 }
233 }
234
235 pub fn provider_stream_failure(&self) -> Option<&ProviderStreamFailure> {
236 match self {
237 Self::ProviderStreamFailure(failure) => Some(failure),
238 _ => None,
239 }
240 }
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub enum ProviderStreamPhase {
245 AwaitingFirstChunk,
246 Streaming,
247}
248
249impl ProviderStreamPhase {
250 pub fn as_str(self) -> &'static str {
251 match self {
252 Self::AwaitingFirstChunk => "awaiting_first_chunk",
253 Self::Streaming => "streaming",
254 }
255 }
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum ProviderStreamFailureReason {
260 Read,
261 PrematureEof,
262 Deadline,
263}
264
265impl ProviderStreamFailureReason {
266 pub fn as_str(self) -> &'static str {
267 match self {
268 Self::Read => "read",
269 Self::PrematureEof => "premature_eof",
270 Self::Deadline => "deadline",
271 }
272 }
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub enum ProviderStreamDeadline {
277 Total,
278 FirstChunk,
279 Idle,
280}
281
282impl ProviderStreamDeadline {
283 pub fn as_str(self) -> &'static str {
284 match self {
285 Self::Total => "total",
286 Self::FirstChunk => "first_chunk",
287 Self::Idle => "idle",
288 }
289 }
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct ProviderStreamFailure {
294 pub provider: String,
295 pub phase: ProviderStreamPhase,
296 pub reason: ProviderStreamFailureReason,
297 pub deadline: Option<ProviderStreamDeadline>,
298 pub partial: bool,
299 pub detail: String,
300}
301
302impl ProviderStreamFailure {
303 pub fn category(&self) -> ErrorCategory {
304 if self.deadline.is_some() {
305 ErrorCategory::Timeout
306 } else {
307 ErrorCategory::TransientNetwork
308 }
309 }
310
311 fn thrown_value(&self) -> VmValue {
312 let mut dict = std::collections::BTreeMap::new();
313 dict.put_str("category", self.category().as_str());
314 dict.put_str("message", self.to_string());
315 dict.put_str("source", "provider_stream");
316 dict.put_str("phase", self.phase.as_str());
317 dict.put_str("reason", self.reason.as_str());
318 dict.insert(
319 "deadline".to_string(),
320 self.deadline
321 .map(|deadline| VmValue::String(arcstr::ArcStr::from(deadline.as_str())))
322 .unwrap_or(VmValue::Nil),
323 );
324 dict.insert("partial".to_string(), VmValue::Bool(self.partial));
325 VmValue::dict(dict)
326 }
327}
328
329impl std::fmt::Display for ProviderStreamFailure {
330 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331 write!(
332 f,
333 "{} provider stream failure (phase={}, reason={}",
334 self.provider,
335 self.phase.as_str(),
336 self.reason.as_str()
337 )?;
338 if let Some(deadline) = self.deadline {
339 write!(f, ", deadline={}", deadline.as_str())?;
340 }
341 write!(f, ", partial={}): {}", self.partial, self.detail)
342 }
343}
344
345#[derive(Debug, Clone, PartialEq, Eq)]
347pub enum ErrorCategory {
348 Timeout,
350 Auth,
352 RateLimit,
354 Overloaded,
358 ServerError,
360 TransientNetwork,
363 ResourceBusy,
366 SchemaIncompatible,
369 SchemaValidation,
371 SchemaStreamAborted,
378 ToolError,
380 ToolRejected,
382 EgressBlocked,
384 Cancelled,
386 ChannelClosed,
388 NotFound,
390 CircuitOpen,
392 BudgetExceeded,
394 Internal,
402 Environment,
411 Generic,
413}
414
415impl ErrorCategory {
416 pub const ALL: [Self; 21] = [
425 Self::Timeout,
426 Self::Auth,
427 Self::RateLimit,
428 Self::Overloaded,
429 Self::ServerError,
430 Self::TransientNetwork,
431 Self::ResourceBusy,
432 Self::SchemaIncompatible,
433 Self::SchemaValidation,
434 Self::SchemaStreamAborted,
435 Self::ToolError,
436 Self::ToolRejected,
437 Self::EgressBlocked,
438 Self::Cancelled,
439 Self::ChannelClosed,
440 Self::NotFound,
441 Self::CircuitOpen,
442 Self::BudgetExceeded,
443 Self::Internal,
444 Self::Environment,
445 Self::Generic,
446 ];
447
448 pub fn as_str(&self) -> &'static str {
449 match self {
450 ErrorCategory::Timeout => "timeout",
451 ErrorCategory::Auth => "auth",
452 ErrorCategory::RateLimit => "rate_limit",
453 ErrorCategory::Overloaded => "overloaded",
454 ErrorCategory::ServerError => "server_error",
455 ErrorCategory::TransientNetwork => "transient_network",
456 ErrorCategory::ResourceBusy => "resource_busy",
457 ErrorCategory::SchemaIncompatible => "schema_incompatible",
458 ErrorCategory::SchemaValidation => "schema_validation",
459 ErrorCategory::SchemaStreamAborted => "schema_stream_aborted",
460 ErrorCategory::ToolError => "tool_error",
461 ErrorCategory::ToolRejected => "tool_rejected",
462 ErrorCategory::EgressBlocked => "egress_blocked",
463 ErrorCategory::Cancelled => "cancelled",
464 ErrorCategory::ChannelClosed => "channel_closed",
465 ErrorCategory::NotFound => "not_found",
466 ErrorCategory::CircuitOpen => "circuit_open",
467 ErrorCategory::BudgetExceeded => "budget_exceeded",
468 ErrorCategory::Internal => "internal",
469 ErrorCategory::Environment => "environment",
470 ErrorCategory::Generic => "generic",
471 }
472 }
473
474 pub fn parse(s: &str) -> Self {
475 match s {
476 "timeout" => ErrorCategory::Timeout,
477 "auth" => ErrorCategory::Auth,
478 "rate_limit" => ErrorCategory::RateLimit,
479 "overloaded" => ErrorCategory::Overloaded,
480 "server_error" => ErrorCategory::ServerError,
481 "transient_network" => ErrorCategory::TransientNetwork,
482 "resource_busy" => ErrorCategory::ResourceBusy,
483 "schema_incompatible" => ErrorCategory::SchemaIncompatible,
484 "schema_validation" => ErrorCategory::SchemaValidation,
485 "schema_stream_aborted" => ErrorCategory::SchemaStreamAborted,
486 "tool_error" => ErrorCategory::ToolError,
487 "tool_rejected" => ErrorCategory::ToolRejected,
488 "egress_blocked" => ErrorCategory::EgressBlocked,
489 "cancelled" => ErrorCategory::Cancelled,
490 "channel_closed" => ErrorCategory::ChannelClosed,
491 "not_found" => ErrorCategory::NotFound,
492 "circuit_open" => ErrorCategory::CircuitOpen,
493 "budget_exceeded" => ErrorCategory::BudgetExceeded,
494 "internal" => ErrorCategory::Internal,
495 "environment" => ErrorCategory::Environment,
496 _ => ErrorCategory::Generic,
497 }
498 }
499
500 pub fn is_internal(&self) -> bool {
503 matches!(self, ErrorCategory::Internal)
504 }
505
506 pub fn is_transient(&self) -> bool {
510 matches!(
511 self,
512 ErrorCategory::Timeout
513 | ErrorCategory::RateLimit
514 | ErrorCategory::Overloaded
515 | ErrorCategory::ServerError
516 | ErrorCategory::TransientNetwork
517 | ErrorCategory::ResourceBusy
518 )
519 }
520}
521
522pub fn categorized_error(message: impl Into<String>, category: ErrorCategory) -> VmError {
524 VmError::CategorizedError {
525 message: message.into(),
526 category,
527 }
528}
529
530pub fn error_to_category(err: &VmError) -> ErrorCategory {
539 match err {
540 VmError::ExecutionDeadlineExceeded => ErrorCategory::Timeout,
541 VmError::ProcessExit(_) => ErrorCategory::Generic,
545 VmError::AbandonedExecution => ErrorCategory::Cancelled,
546 VmError::CategorizedError { category, .. } => category.clone(),
547 VmError::ProviderStreamFailure(failure) => failure.category(),
548 VmError::Thrown(VmValue::Dict(d)) => d
549 .get("category")
550 .map(|v| ErrorCategory::parse(&v.display()))
551 .unwrap_or(ErrorCategory::Generic),
552 VmError::Thrown(VmValue::String(s)) => classify_error_message(s),
553 VmError::Runtime(msg) => classify_error_message(msg),
554 VmError::UndefinedBuiltin(_) | VmError::InvalidInstruction(_) => ErrorCategory::Internal,
559 VmError::Deadlock(_) => ErrorCategory::Generic,
562 _ => ErrorCategory::Generic,
563 }
564}
565
566pub fn classify_error_message(msg: &str) -> ErrorCategory {
569 if let Some(cat) = classify_by_http_status(msg) {
571 return cat;
572 }
573 if msg.contains("Undefined builtin") {
578 return ErrorCategory::Internal;
579 }
580 let lower = msg.to_lowercase();
583 if lower.contains("cancelled") || lower.contains("canceled") {
584 return ErrorCategory::Cancelled;
585 }
586 if msg.contains("ChannelClosed") || lower.contains("channel closed") {
587 return ErrorCategory::ChannelClosed;
588 }
589 if msg.contains("Deadline exceeded") || msg.contains("context deadline exceeded") {
590 return ErrorCategory::Timeout;
591 }
592 if msg.contains("overloaded_error") {
593 return ErrorCategory::Overloaded;
595 }
596 if msg.contains("api_error") {
597 return ErrorCategory::ServerError;
599 }
600 if msg.contains("insufficient_quota") || msg.contains("billing_hard_limit_reached") {
601 return ErrorCategory::RateLimit;
603 }
604 if msg.contains("invalid_api_key") || msg.contains("authentication_error") {
605 return ErrorCategory::Auth;
606 }
607 if msg.contains("not_found_error") || msg.contains("model_not_found") {
608 return ErrorCategory::NotFound;
609 }
610 if lower.contains("is not a valid model id") || lower.contains("invalid model id") {
616 return ErrorCategory::NotFound;
617 }
618 if msg.contains("circuit_open") {
619 return ErrorCategory::CircuitOpen;
620 }
621 if lower.contains("connection reset")
623 || lower.contains("connection refused")
624 || lower.contains("connection closed")
625 || lower.contains("broken pipe")
626 || lower.contains("dns error")
627 || lower.contains("stream error")
628 || lower.contains("unexpected eof")
629 {
630 return ErrorCategory::TransientNetwork;
631 }
632 ErrorCategory::Generic
633}
634
635fn classify_by_http_status(msg: &str) -> Option<ErrorCategory> {
639 for code in extract_http_status_codes(msg) {
642 return Some(match code {
643 401 | 403 => ErrorCategory::Auth,
644 404 | 410 => ErrorCategory::NotFound,
645 408 | 504 | 522 | 524 => ErrorCategory::Timeout,
646 429 => ErrorCategory::RateLimit,
647 503 | 529 => ErrorCategory::Overloaded,
648 500 | 502 => ErrorCategory::ServerError,
649 _ => continue,
650 });
651 }
652 None
653}
654
655#[expect(
657 clippy::string_slice,
658 reason = "i..i + 3 spans bytes verified to be ASCII digits"
659)]
660fn extract_http_status_codes(msg: &str) -> Vec<u16> {
661 let mut codes = Vec::new();
662 let bytes = msg.as_bytes();
663 for i in 0..bytes.len().saturating_sub(2) {
664 if bytes[i].is_ascii_digit()
666 && bytes[i + 1].is_ascii_digit()
667 && bytes[i + 2].is_ascii_digit()
668 {
669 let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
671 let after_ok = i + 3 >= bytes.len() || !bytes[i + 3].is_ascii_digit();
672 if before_ok && after_ok {
673 if let Ok(code) = msg[i..i + 3].parse::<u16>() {
674 if (400..=599).contains(&code) {
675 codes.push(code);
676 }
677 }
678 }
679 }
680 }
681 codes
682}
683
684impl std::fmt::Display for VmError {
685 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
686 match self {
687 VmError::StackUnderflow => write!(f, "Stack underflow"),
688 VmError::StackOverflow => write!(f, "Stack overflow: too many nested calls"),
689 VmError::UndefinedVariable(n) => write!(f, "Undefined variable: {n}"),
690 VmError::UndefinedBuiltin(n) => write!(f, "Undefined builtin: {n}"),
691 VmError::ImmutableAssignment(n) => {
692 write!(f, "Cannot assign to immutable binding: {n}")
693 }
694 VmError::TypeError(msg) => write!(f, "Type error: {msg}"),
695 VmError::Runtime(msg) => write!(f, "Runtime error: {msg}"),
696 VmError::DivisionByZero => write!(f, "Division by zero"),
697 VmError::ExecutionDeadlineExceeded => write!(f, "Execution deadline exceeded"),
698 VmError::ProcessExit(code) => write!(f, "Process exit requested: {code}"),
699 VmError::AbandonedExecution => write!(
700 f,
701 "Execution future was abandoned; discard this VM and reset its exclusively owned execution context"
702 ),
703 VmError::McpInputRequired(_) => write!(f, "MCP client input required"),
704 VmError::Thrown(v) => write!(f, "Thrown: {}", v.display()),
705 VmError::CategorizedError { message, category } => {
706 write!(f, "Error [{}]: {}", category.as_str(), message)
707 }
708 VmError::ProviderStreamFailure(failure) => failure.fmt(f),
709 VmError::DaemonQueueFull {
710 daemon_id,
711 capacity,
712 } => write!(
713 f,
714 "Daemon queue full: daemon '{daemon_id}' reached its event_queue_capacity of {capacity}"
715 ),
716 VmError::Deadlock(err) => match err.diagnostic {
717 DeadlockDiagnostic::SelfDeadlock => write!(
718 f,
719 "{}: deadlock detected: {} ({} '{}') — this wait can never complete and would block forever",
720 err.diagnostic.code(),
721 err.detail,
722 err.kind,
723 err.key
724 ),
725 DeadlockDiagnostic::WaitForGraph => write!(
726 f,
727 "{}: wait-for deadlock detected: {} ({} '{}') — no active task can make progress",
728 err.diagnostic.code(),
729 err.detail,
730 err.kind,
731 err.key
732 ),
733 },
734 VmError::Return(_) => write!(f, "Return from function"),
735 VmError::InvalidInstruction(op) => write!(f, "Invalid instruction: 0x{op:02x}"),
736 VmError::ArityMismatch(err) => {
737 let arg_word = match err.expected {
738 ArityExpect::Exact(1) | ArityExpect::AtLeast(1) => "argument",
739 _ => "arguments",
740 };
741 write!(
742 f,
743 "Arity mismatch: '{}' expects {} {}, got {}{}",
744 err.callee,
745 err.expected,
746 arg_word,
747 err.got,
748 fmt_span_suffix(&err.span)
749 )
750 }
751 VmError::ArgTypeMismatch(err) => {
752 write!(
753 f,
754 "Type error: '{}' parameter `{}` expects {}, got {}{}",
755 err.callee,
756 err.param,
757 err.expected,
758 err.got,
759 fmt_span_suffix(&err.span)
760 )
761 }
762 VmError::BindingTypeMismatch(err) => {
763 write!(
764 f,
765 "Type error: binding `{}` expects {}, got {}{}",
766 err.binding,
767 err.expected,
768 err.got,
769 fmt_span_suffix(&err.span)
770 )
771 }
772 }
773 }
774}
775
776fn fmt_span_suffix(span: &Option<Span>) -> String {
777 match span {
778 Some(s) => format!(" (at byte {}..{})", s.start, s.end),
779 None => String::new(),
780 }
781}
782
783impl std::error::Error for VmError {}
784
785#[cfg(test)]
786mod tests {
787 use super::*;
788
789 #[test]
793 fn all_categories_is_exhaustive() {
794 for category in &ErrorCategory::ALL {
795 match category {
796 ErrorCategory::Timeout
797 | ErrorCategory::Auth
798 | ErrorCategory::RateLimit
799 | ErrorCategory::Overloaded
800 | ErrorCategory::ServerError
801 | ErrorCategory::TransientNetwork
802 | ErrorCategory::ResourceBusy
803 | ErrorCategory::SchemaIncompatible
804 | ErrorCategory::SchemaValidation
805 | ErrorCategory::SchemaStreamAborted
806 | ErrorCategory::ToolError
807 | ErrorCategory::ToolRejected
808 | ErrorCategory::EgressBlocked
809 | ErrorCategory::Cancelled
810 | ErrorCategory::ChannelClosed
811 | ErrorCategory::NotFound
812 | ErrorCategory::CircuitOpen
813 | ErrorCategory::BudgetExceeded
814 | ErrorCategory::Internal
815 | ErrorCategory::Environment
816 | ErrorCategory::Generic => {}
817 }
818 }
819 assert_eq!(
820 ErrorCategory::ALL.len(),
821 21,
822 "a category was added or removed — update `ErrorCategory::ALL` and the \
823 `Error categories` table in docs/src/builtins.md"
824 );
825 }
826
827 #[test]
828 fn every_category_round_trips_through_parse() {
829 for category in &ErrorCategory::ALL {
830 assert_eq!(
831 &ErrorCategory::parse(category.as_str()),
832 category,
833 "`{}` does not round-trip — `parse` is missing an arm, so a \
834 host handing this category back to Harn silently gets \
835 `generic`",
836 category.as_str()
837 );
838 }
839 }
840
841 #[test]
846 fn every_category_is_documented_in_builtins_md() {
847 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/src/builtins.md");
848 let doc =
849 std::fs::read_to_string(path).unwrap_or_else(|err| panic!("cannot read {path}: {err}"));
850 let table = doc
851 .split_once("### Error categories")
852 .unwrap_or_else(|| {
853 panic!("docs/src/builtins.md lost its `### Error categories` section")
854 })
855 .1;
856 let table = table.split_once("\n## ").map_or(table, |(head, _)| head);
857 for category in &ErrorCategory::ALL {
858 let row = format!("| `{}` |", category.as_str());
859 assert!(
860 table.contains(&row),
861 "`{}` is missing from the `Error categories` table in \
862 docs/src/builtins.md",
863 category.as_str()
864 );
865 }
866 }
867
868 #[test]
869 fn classifies_cancelled_messages() {
870 assert_eq!(
871 classify_error_message("Bridge: operation cancelled"),
872 ErrorCategory::Cancelled
873 );
874 assert_eq!(
875 classify_error_message("operation canceled by host"),
876 ErrorCategory::Cancelled
877 );
878 }
879
880 #[test]
881 fn classifies_undefined_builtin_as_internal() {
882 assert_eq!(
884 error_to_category(&VmError::UndefinedBuiltin("__host_agent_foo".into())),
885 ErrorCategory::Internal
886 );
887 assert_eq!(
889 error_to_category(&VmError::InvalidInstruction(200)),
890 ErrorCategory::Internal
891 );
892 assert_eq!(
895 error_to_category(&VmError::Runtime(
896 "Undefined builtin: __host_agent_foo (did you mean `bar`?)".into()
897 )),
898 ErrorCategory::Internal
899 );
900 assert_eq!(
901 classify_error_message("Undefined builtin: __host_agent_foo"),
902 ErrorCategory::Internal
903 );
904 assert!(!ErrorCategory::Internal.is_transient());
906 assert!(ErrorCategory::Internal.is_internal());
907 assert_eq!(ErrorCategory::Internal.as_str(), "internal");
909 assert_eq!(ErrorCategory::parse("internal"), ErrorCategory::Internal);
910 }
911
912 #[test]
913 fn classifies_openrouter_invalid_model_id_as_not_found() {
914 assert_eq!(
918 classify_error_message(
919 "openrouter API error: qwen/qwen3-coder-bogus is not a valid model ID"
920 ),
921 ErrorCategory::NotFound
922 );
923 assert_eq!(
924 classify_error_message("invalid model id supplied"),
925 ErrorCategory::NotFound
926 );
927 }
928
929 #[test]
930 fn categorized_error_lowers_to_structured_dict() {
931 let err = categorized_error(
935 "sandbox violation: /etc/passwd",
936 ErrorCategory::ToolRejected,
937 );
938 let VmValue::Dict(dict) = err.thrown_value() else {
939 panic!(
940 "categorized error must lower to a dict, got {:?}",
941 err.thrown_value()
942 );
943 };
944 assert_eq!(
945 dict.get("category").map(|v| v.display()).as_deref(),
946 Some("tool_rejected"),
947 );
948 assert_eq!(
949 dict.get("message").map(|v| v.display()).as_deref(),
950 Some("sandbox violation: /etc/passwd"),
951 );
952 let rendered = categorized_error("boom", ErrorCategory::Cancelled)
956 .thrown_value()
957 .display();
958 assert!(rendered.contains("cancelled"), "rendered dict: {rendered}");
959 assert!(rendered.contains("boom"), "rendered dict: {rendered}");
960 }
961
962 #[test]
963 fn thrown_value_passes_structured_thrown_through_unchanged() {
964 let original = VmValue::dict(std::collections::BTreeMap::from([(
967 "code".to_string(),
968 VmValue::Int(7),
969 )]));
970 let VmValue::Dict(dict) = VmError::Thrown(original).thrown_value() else {
971 panic!("thrown dict must pass through as a dict");
972 };
973 assert!(matches!(dict.get("code"), Some(VmValue::Int(7))));
974 }
975
976 #[test]
977 fn deadlock_renders_with_stable_code() {
978 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
979 "mutex",
980 "__default__",
981 "re-entrant acquire",
982 )));
983 assert!(
984 err.to_string().starts_with("HARN-ORC-011"),
985 "deadlock Display must carry the stable code: {err}"
986 );
987 }
988
989 #[test]
990 fn deadlock_maps_to_generic_category() {
991 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
992 "task",
993 "task_1",
994 "self-join",
995 )));
996 let category = error_to_category(&err);
997 assert_eq!(category, ErrorCategory::Generic);
998 assert!(
999 !category.is_transient(),
1000 "a deadlock must not be treated as a retryable transient error"
1001 );
1002 }
1003}