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
655fn extract_http_status_codes(msg: &str) -> Vec<u16> {
657 let mut codes = Vec::new();
658 let bytes = msg.as_bytes();
659 for i in 0..bytes.len().saturating_sub(2) {
660 if bytes[i].is_ascii_digit()
662 && bytes[i + 1].is_ascii_digit()
663 && bytes[i + 2].is_ascii_digit()
664 {
665 let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
667 let after_ok = i + 3 >= bytes.len() || !bytes[i + 3].is_ascii_digit();
668 if before_ok && after_ok {
669 if let Ok(code) = msg[i..i + 3].parse::<u16>() {
670 if (400..=599).contains(&code) {
671 codes.push(code);
672 }
673 }
674 }
675 }
676 }
677 codes
678}
679
680impl std::fmt::Display for VmError {
681 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
682 match self {
683 VmError::StackUnderflow => write!(f, "Stack underflow"),
684 VmError::StackOverflow => write!(f, "Stack overflow: too many nested calls"),
685 VmError::UndefinedVariable(n) => write!(f, "Undefined variable: {n}"),
686 VmError::UndefinedBuiltin(n) => write!(f, "Undefined builtin: {n}"),
687 VmError::ImmutableAssignment(n) => {
688 write!(f, "Cannot assign to immutable binding: {n}")
689 }
690 VmError::TypeError(msg) => write!(f, "Type error: {msg}"),
691 VmError::Runtime(msg) => write!(f, "Runtime error: {msg}"),
692 VmError::DivisionByZero => write!(f, "Division by zero"),
693 VmError::ExecutionDeadlineExceeded => write!(f, "Execution deadline exceeded"),
694 VmError::ProcessExit(code) => write!(f, "Process exit requested: {code}"),
695 VmError::AbandonedExecution => write!(
696 f,
697 "Execution future was abandoned; discard this VM and reset its exclusively owned execution context"
698 ),
699 VmError::McpInputRequired(_) => write!(f, "MCP client input required"),
700 VmError::Thrown(v) => write!(f, "Thrown: {}", v.display()),
701 VmError::CategorizedError { message, category } => {
702 write!(f, "Error [{}]: {}", category.as_str(), message)
703 }
704 VmError::ProviderStreamFailure(failure) => failure.fmt(f),
705 VmError::DaemonQueueFull {
706 daemon_id,
707 capacity,
708 } => write!(
709 f,
710 "Daemon queue full: daemon '{daemon_id}' reached its event_queue_capacity of {capacity}"
711 ),
712 VmError::Deadlock(err) => match err.diagnostic {
713 DeadlockDiagnostic::SelfDeadlock => write!(
714 f,
715 "{}: deadlock detected: {} ({} '{}') — this wait can never complete and would block forever",
716 err.diagnostic.code(),
717 err.detail,
718 err.kind,
719 err.key
720 ),
721 DeadlockDiagnostic::WaitForGraph => write!(
722 f,
723 "{}: wait-for deadlock detected: {} ({} '{}') — no active task can make progress",
724 err.diagnostic.code(),
725 err.detail,
726 err.kind,
727 err.key
728 ),
729 },
730 VmError::Return(_) => write!(f, "Return from function"),
731 VmError::InvalidInstruction(op) => write!(f, "Invalid instruction: 0x{op:02x}"),
732 VmError::ArityMismatch(err) => {
733 let arg_word = match err.expected {
734 ArityExpect::Exact(1) | ArityExpect::AtLeast(1) => "argument",
735 _ => "arguments",
736 };
737 write!(
738 f,
739 "Arity mismatch: '{}' expects {} {}, got {}{}",
740 err.callee,
741 err.expected,
742 arg_word,
743 err.got,
744 fmt_span_suffix(&err.span)
745 )
746 }
747 VmError::ArgTypeMismatch(err) => {
748 write!(
749 f,
750 "Type error: '{}' parameter `{}` expects {}, got {}{}",
751 err.callee,
752 err.param,
753 err.expected,
754 err.got,
755 fmt_span_suffix(&err.span)
756 )
757 }
758 VmError::BindingTypeMismatch(err) => {
759 write!(
760 f,
761 "Type error: binding `{}` expects {}, got {}{}",
762 err.binding,
763 err.expected,
764 err.got,
765 fmt_span_suffix(&err.span)
766 )
767 }
768 }
769 }
770}
771
772fn fmt_span_suffix(span: &Option<Span>) -> String {
773 match span {
774 Some(s) => format!(" (at byte {}..{})", s.start, s.end),
775 None => String::new(),
776 }
777}
778
779impl std::error::Error for VmError {}
780
781#[cfg(test)]
782mod tests {
783 use super::*;
784
785 #[test]
789 fn all_categories_is_exhaustive() {
790 for category in &ErrorCategory::ALL {
791 match category {
792 ErrorCategory::Timeout
793 | ErrorCategory::Auth
794 | ErrorCategory::RateLimit
795 | ErrorCategory::Overloaded
796 | ErrorCategory::ServerError
797 | ErrorCategory::TransientNetwork
798 | ErrorCategory::ResourceBusy
799 | ErrorCategory::SchemaIncompatible
800 | ErrorCategory::SchemaValidation
801 | ErrorCategory::SchemaStreamAborted
802 | ErrorCategory::ToolError
803 | ErrorCategory::ToolRejected
804 | ErrorCategory::EgressBlocked
805 | ErrorCategory::Cancelled
806 | ErrorCategory::ChannelClosed
807 | ErrorCategory::NotFound
808 | ErrorCategory::CircuitOpen
809 | ErrorCategory::BudgetExceeded
810 | ErrorCategory::Internal
811 | ErrorCategory::Environment
812 | ErrorCategory::Generic => {}
813 }
814 }
815 assert_eq!(
816 ErrorCategory::ALL.len(),
817 21,
818 "a category was added or removed — update `ErrorCategory::ALL` and the \
819 `Error categories` table in docs/src/builtins.md"
820 );
821 }
822
823 #[test]
824 fn every_category_round_trips_through_parse() {
825 for category in &ErrorCategory::ALL {
826 assert_eq!(
827 &ErrorCategory::parse(category.as_str()),
828 category,
829 "`{}` does not round-trip — `parse` is missing an arm, so a \
830 host handing this category back to Harn silently gets \
831 `generic`",
832 category.as_str()
833 );
834 }
835 }
836
837 #[test]
842 fn every_category_is_documented_in_builtins_md() {
843 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/src/builtins.md");
844 let doc =
845 std::fs::read_to_string(path).unwrap_or_else(|err| panic!("cannot read {path}: {err}"));
846 let table = doc
847 .split_once("### Error categories")
848 .unwrap_or_else(|| {
849 panic!("docs/src/builtins.md lost its `### Error categories` section")
850 })
851 .1;
852 let table = table.split_once("\n## ").map_or(table, |(head, _)| head);
853 for category in &ErrorCategory::ALL {
854 let row = format!("| `{}` |", category.as_str());
855 assert!(
856 table.contains(&row),
857 "`{}` is missing from the `Error categories` table in \
858 docs/src/builtins.md",
859 category.as_str()
860 );
861 }
862 }
863
864 #[test]
865 fn classifies_cancelled_messages() {
866 assert_eq!(
867 classify_error_message("Bridge: operation cancelled"),
868 ErrorCategory::Cancelled
869 );
870 assert_eq!(
871 classify_error_message("operation canceled by host"),
872 ErrorCategory::Cancelled
873 );
874 }
875
876 #[test]
877 fn classifies_undefined_builtin_as_internal() {
878 assert_eq!(
880 error_to_category(&VmError::UndefinedBuiltin("__host_agent_foo".into())),
881 ErrorCategory::Internal
882 );
883 assert_eq!(
885 error_to_category(&VmError::InvalidInstruction(200)),
886 ErrorCategory::Internal
887 );
888 assert_eq!(
891 error_to_category(&VmError::Runtime(
892 "Undefined builtin: __host_agent_foo (did you mean `bar`?)".into()
893 )),
894 ErrorCategory::Internal
895 );
896 assert_eq!(
897 classify_error_message("Undefined builtin: __host_agent_foo"),
898 ErrorCategory::Internal
899 );
900 assert!(!ErrorCategory::Internal.is_transient());
902 assert!(ErrorCategory::Internal.is_internal());
903 assert_eq!(ErrorCategory::Internal.as_str(), "internal");
905 assert_eq!(ErrorCategory::parse("internal"), ErrorCategory::Internal);
906 }
907
908 #[test]
909 fn classifies_openrouter_invalid_model_id_as_not_found() {
910 assert_eq!(
914 classify_error_message(
915 "openrouter API error: qwen/qwen3-coder-bogus is not a valid model ID"
916 ),
917 ErrorCategory::NotFound
918 );
919 assert_eq!(
920 classify_error_message("invalid model id supplied"),
921 ErrorCategory::NotFound
922 );
923 }
924
925 #[test]
926 fn categorized_error_lowers_to_structured_dict() {
927 let err = categorized_error(
931 "sandbox violation: /etc/passwd",
932 ErrorCategory::ToolRejected,
933 );
934 let VmValue::Dict(dict) = err.thrown_value() else {
935 panic!(
936 "categorized error must lower to a dict, got {:?}",
937 err.thrown_value()
938 );
939 };
940 assert_eq!(
941 dict.get("category").map(|v| v.display()).as_deref(),
942 Some("tool_rejected"),
943 );
944 assert_eq!(
945 dict.get("message").map(|v| v.display()).as_deref(),
946 Some("sandbox violation: /etc/passwd"),
947 );
948 let rendered = categorized_error("boom", ErrorCategory::Cancelled)
952 .thrown_value()
953 .display();
954 assert!(rendered.contains("cancelled"), "rendered dict: {rendered}");
955 assert!(rendered.contains("boom"), "rendered dict: {rendered}");
956 }
957
958 #[test]
959 fn thrown_value_passes_structured_thrown_through_unchanged() {
960 let original = VmValue::dict(std::collections::BTreeMap::from([(
963 "code".to_string(),
964 VmValue::Int(7),
965 )]));
966 let VmValue::Dict(dict) = VmError::Thrown(original).thrown_value() else {
967 panic!("thrown dict must pass through as a dict");
968 };
969 assert!(matches!(dict.get("code"), Some(VmValue::Int(7))));
970 }
971
972 #[test]
973 fn deadlock_renders_with_stable_code() {
974 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
975 "mutex",
976 "__default__",
977 "re-entrant acquire",
978 )));
979 assert!(
980 err.to_string().starts_with("HARN-ORC-011"),
981 "deadlock Display must carry the stable code: {err}"
982 );
983 }
984
985 #[test]
986 fn deadlock_maps_to_generic_category() {
987 let err = VmError::Deadlock(Box::new(DeadlockError::self_deadlock(
988 "task",
989 "task_1",
990 "self-join",
991 )));
992 let category = error_to_category(&err);
993 assert_eq!(category, ErrorCategory::Generic);
994 assert!(
995 !category.is_transient(),
996 "a deadlock must not be treated as a retryable transient error"
997 );
998 }
999}