Skip to main content

foundation_models/
error.rs

1//! Errors produced by the `FoundationModels` bridge.
2
3use core::ffi::c_char;
4use core::fmt;
5use std::collections::HashMap;
6use std::ffi::{CStr, CString};
7use std::sync::{Mutex, OnceLock};
8
9use serde::Deserialize;
10
11use crate::ffi;
12use crate::prompt::ToolDefinition;
13use crate::schema::GenerationSchema;
14use crate::session::{self, SessionResponse, StreamEvent};
15use crate::transcript::{Entry, Transcript};
16
17/// Structured generation-error context.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct GenerationErrorContext {
20    debug_description: String,
21}
22
23impl GenerationErrorContext {
24    /// Create a context object from a debug description string.
25    #[must_use]
26    pub fn new(debug_description: impl Into<String>) -> Self {
27        Self {
28            debug_description: debug_description.into(),
29        }
30    }
31
32    /// Borrow the context's debug description.
33    #[must_use]
34    pub fn debug_description(&self) -> &str {
35        &self.debug_description
36    }
37}
38
39/// Structured schema-validation error context.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct SchemaErrorContext {
42    debug_description: String,
43}
44
45impl SchemaErrorContext {
46    /// Create a context object from a debug description string.
47    #[must_use]
48    pub fn new(debug_description: impl Into<String>) -> Self {
49        Self {
50            debug_description: debug_description.into(),
51        }
52    }
53
54    /// Borrow the context's debug description.
55    #[must_use]
56    pub fn debug_description(&self) -> &str {
57        &self.debug_description
58    }
59}
60
61/// Structured adapter-asset error context.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct AdapterAssetErrorContext {
64    debug_description: String,
65}
66
67impl AdapterAssetErrorContext {
68    /// Create an adapter-asset error context from a debug description string.
69    #[must_use]
70    pub fn new(debug_description: impl Into<String>) -> Self {
71        Self {
72            debug_description: debug_description.into(),
73        }
74    }
75
76    /// Borrow the context's debug description.
77    #[must_use]
78    pub fn debug_description(&self) -> &str {
79        &self.debug_description
80    }
81}
82
83/// Typed tool-call failure metadata.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct ToolCallError {
86    tool: ToolDefinition,
87    underlying_error: String,
88}
89
90impl ToolCallError {
91    /// Create a tool-call error from the tool definition and underlying error text.
92    #[must_use]
93    pub fn new(tool: ToolDefinition, underlying_error: impl Into<String>) -> Self {
94        Self {
95            tool,
96            underlying_error: underlying_error.into(),
97        }
98    }
99
100    /// Borrow the tool definition that failed.
101    #[must_use]
102    pub const fn tool(&self) -> &ToolDefinition {
103        &self.tool
104    }
105
106    /// Borrow the underlying error text.
107    #[must_use]
108    pub fn underlying_error(&self) -> &str {
109        &self.underlying_error
110    }
111}
112
113/// Typed refusal helper returned by generation-refusal errors.
114#[derive(Debug, Clone, PartialEq)]
115pub struct Refusal {
116    token: Option<String>,
117    transcript: Option<Transcript>,
118}
119
120impl Refusal {
121    /// Create a refusal helper from transcript entries.
122    #[must_use]
123    pub fn new(entries: impl IntoIterator<Item = Entry>) -> Self {
124        Self {
125            token: None,
126            transcript: Some(Transcript::from_entries(entries.into_iter().collect())),
127        }
128    }
129
130    pub(crate) fn from_token(token: impl Into<String>) -> Self {
131        Self {
132            token: Some(token.into()),
133            transcript: None,
134        }
135    }
136
137    /// Borrow the local transcript, if this refusal was constructed from entries.
138    #[must_use]
139    pub fn transcript(&self) -> Option<&Transcript> {
140        self.transcript.as_ref()
141    }
142
143    /// Resolve the refusal's explanation response.
144    ///
145    /// # Errors
146    ///
147    /// Returns an [`FMError`] if the Swift bridge rejects the refusal helper.
148    pub fn explanation(&self) -> Result<SessionResponse<String>, FMError> {
149        if let Some(token) = &self.token {
150            let token = CString::new(token.as_str()).map_err(|error| {
151                FMError::InvalidArgument(format!(
152                    "refusal token contains an interior NUL byte: {error}"
153                ))
154            })?;
155            return session::request_text_response_with(|context, callback| unsafe {
156                ffi::fm_refusal_explanation_json(token.as_ptr(), context, callback)
157            });
158        }
159
160        let transcript = self.transcript.as_ref().ok_or_else(|| {
161            FMError::InvalidArgument("refusal does not contain any transcript state".into())
162        })?;
163        let transcript_json = CString::new(transcript.to_json_string()?).map_err(|error| {
164            FMError::InvalidArgument(format!(
165                "refusal transcript JSON contains an interior NUL byte: {error}"
166            ))
167        })?;
168        session::request_text_response_with(|context, callback| unsafe {
169            ffi::fm_refusal_explanation_from_transcript_json(
170                transcript_json.as_ptr(),
171                context,
172                callback,
173            )
174        })
175    }
176
177    /// Stream the refusal's explanation text.
178    ///
179    /// # Errors
180    ///
181    /// Returns an [`FMError`] if the Swift bridge rejects the refusal helper.
182    pub fn explanation_stream<F>(&self, on_chunk: F) -> Result<(), FMError>
183    where
184        F: FnMut(StreamEvent<'_>) + Send + 'static,
185    {
186        if let Some(token) = &self.token {
187            let token = CString::new(token.as_str()).map_err(|error| {
188                FMError::InvalidArgument(format!(
189                    "refusal token contains an interior NUL byte: {error}"
190                ))
191            })?;
192            return session::run_text_stream_with(
193                |context, callback| unsafe {
194                    ffi::fm_refusal_explanation_stream(token.as_ptr(), context, callback)
195                },
196                on_chunk,
197            );
198        }
199
200        let transcript = self.transcript.as_ref().ok_or_else(|| {
201            FMError::InvalidArgument("refusal does not contain any transcript state".into())
202        })?;
203        let transcript_json = CString::new(transcript.to_json_string()?).map_err(|error| {
204            FMError::InvalidArgument(format!(
205                "refusal transcript JSON contains an interior NUL byte: {error}"
206            ))
207        })?;
208        session::run_text_stream_with(
209            |context, callback| unsafe {
210                ffi::fm_refusal_explanation_stream_from_transcript_json(
211                    transcript_json.as_ptr(),
212                    context,
213                    callback,
214                )
215            },
216            on_chunk,
217        )
218    }
219}
220
221#[derive(Debug, Clone, Default, PartialEq)]
222struct ErrorMetadata {
223    recovery_suggestion: Option<String>,
224    failure_reason: Option<String>,
225    generation_error_context: Option<GenerationErrorContext>,
226    adapter_asset_error_context: Option<AdapterAssetErrorContext>,
227    schema_error_context: Option<SchemaErrorContext>,
228    refusal: Option<Refusal>,
229    tool_call_error: Option<ToolCallError>,
230}
231
232#[derive(Debug, Deserialize)]
233struct BridgeErrorContext {
234    #[serde(rename = "debugDescription")]
235    debug_description: String,
236}
237
238#[derive(Debug, Deserialize)]
239struct BridgeRefusal {
240    token: String,
241}
242
243#[derive(Debug, Deserialize)]
244struct BridgeToolDefinition {
245    name: String,
246    description: String,
247    #[serde(rename = "parametersJSON")]
248    parameters_json: String,
249}
250
251#[derive(Debug, Deserialize)]
252struct BridgeToolCallError {
253    tool: BridgeToolDefinition,
254    #[serde(rename = "underlyingError")]
255    underlying_error: String,
256}
257
258#[derive(Debug, Deserialize)]
259struct BridgeErrorPayload {
260    message: String,
261    #[serde(rename = "recoverySuggestion")]
262    recovery_suggestion: Option<String>,
263    #[serde(rename = "failureReason")]
264    failure_reason: Option<String>,
265    #[serde(rename = "generationErrorContext")]
266    generation_error_context: Option<BridgeErrorContext>,
267    refusal: Option<BridgeRefusal>,
268    #[serde(rename = "toolCallError")]
269    tool_call_error: Option<BridgeToolCallError>,
270    #[serde(rename = "adapterAssetErrorContext")]
271    adapter_asset_error_context: Option<BridgeErrorContext>,
272    #[serde(rename = "schemaErrorContext")]
273    schema_error_context: Option<BridgeErrorContext>,
274}
275
276impl BridgeErrorPayload {
277    fn into_metadata(self) -> ErrorMetadata {
278        ErrorMetadata {
279            recovery_suggestion: self.recovery_suggestion,
280            failure_reason: self.failure_reason,
281            generation_error_context: self
282                .generation_error_context
283                .map(|context| GenerationErrorContext::new(context.debug_description)),
284            adapter_asset_error_context: self
285                .adapter_asset_error_context
286                .map(|context| AdapterAssetErrorContext::new(context.debug_description)),
287            schema_error_context: self
288                .schema_error_context
289                .map(|context| SchemaErrorContext::new(context.debug_description)),
290            refusal: self
291                .refusal
292                .map(|refusal| Refusal::from_token(refusal.token)),
293            tool_call_error: self.tool_call_error.map(|error| {
294                ToolCallError::new(
295                    ToolDefinition::new(
296                        error.tool.name,
297                        error.tool.description,
298                        GenerationSchema::from_json_schema_unchecked(error.tool.parameters_json),
299                    ),
300                    error.underlying_error,
301                )
302            }),
303        }
304    }
305}
306
307fn metadata_registry() -> &'static Mutex<HashMap<usize, ErrorMetadata>> {
308    static REGISTRY: OnceLock<Mutex<HashMap<usize, ErrorMetadata>>> = OnceLock::new();
309    REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
310}
311
312fn register_metadata(message: &str, metadata: ErrorMetadata) {
313    if metadata == ErrorMetadata::default() {
314        return;
315    }
316    metadata_registry()
317        .lock()
318        .expect("error metadata registry mutex poisoned")
319        .insert(message.as_ptr() as usize, metadata);
320}
321
322fn clone_message_with_metadata(message: &str) -> String {
323    let cloned = message.to_owned();
324    let metadata = metadata_registry()
325        .lock()
326        .expect("error metadata registry mutex poisoned")
327        .get(&(message.as_ptr() as usize))
328        .cloned();
329    if let Some(metadata) = metadata {
330        register_metadata(&cloned, metadata);
331    }
332    cloned
333}
334
335/// Top-level error type returned by all fallible APIs in this crate.
336#[derive(Debug, PartialEq, Eq)]
337#[non_exhaustive]
338pub enum FMError {
339    /// `FoundationModels` is not available on this device.
340    ///
341    /// See [`Unavailability`] for the specific reason.
342    ModelUnavailable {
343        reason: Unavailability,
344        message: String,
345    },
346    /// The model refused to produce a response because the prompt or
347    /// generated content tripped a safety guardrail.
348    GuardrailViolation(String),
349    /// The combined prompt + history exceeds the model's context window.
350    ContextWindowExceeded(String),
351    /// The requested locale or language is not supported by the on-device model.
352    UnsupportedLanguage(String),
353    /// On-device model assets are still downloading or otherwise unavailable.
354    AssetsUnavailable(String),
355    /// The session was rate-limited (typically only relevant on Mac with
356    /// extended generation budgets).
357    RateLimited(String),
358    /// Structured generation failed to decode the model's output into the
359    /// requested `Generable` schema.
360    DecodingFailure(String),
361    /// The model refused the request (distinct from a guardrail violation —
362    /// the model itself declined to answer).
363    Refusal(String),
364    /// Too many concurrent generation requests against the same session.
365    ConcurrentRequests(String),
366    /// The supplied [`crate::schema::GenerationGuide`] is unsupported by the on-device model.
367    UnsupportedGuide(String),
368    /// A tool invocation failed while the model was using `Tool` calling.
369    ToolCallFailed(String),
370    /// An adapter asset pack was invalid.
371    AdapterInvalidAsset(String),
372    /// The requested adapter name was invalid.
373    AdapterInvalidName(String),
374    /// No compatible adapter could be found for the requested name.
375    AdapterCompatibleNotFound(String),
376    /// The generation Task was cancelled before completion.
377    Cancelled,
378    /// An invalid argument crossed the FFI boundary (e.g. a NUL byte in a prompt).
379    InvalidArgument(String),
380    /// Catch-all for unmapped Swift errors. Inspect [`code`](Self::code) and
381    /// [`message`](Self::message) for diagnostics.
382    Unknown { code: i32, message: String },
383}
384
385impl Clone for FMError {
386    fn clone(&self) -> Self {
387        match self {
388            Self::ModelUnavailable { reason, message } => Self::ModelUnavailable {
389                reason: *reason,
390                message: clone_message_with_metadata(message),
391            },
392            Self::GuardrailViolation(message) => {
393                Self::GuardrailViolation(clone_message_with_metadata(message))
394            }
395            Self::ContextWindowExceeded(message) => {
396                Self::ContextWindowExceeded(clone_message_with_metadata(message))
397            }
398            Self::UnsupportedLanguage(message) => {
399                Self::UnsupportedLanguage(clone_message_with_metadata(message))
400            }
401            Self::AssetsUnavailable(message) => {
402                Self::AssetsUnavailable(clone_message_with_metadata(message))
403            }
404            Self::RateLimited(message) => Self::RateLimited(clone_message_with_metadata(message)),
405            Self::DecodingFailure(message) => {
406                Self::DecodingFailure(clone_message_with_metadata(message))
407            }
408            Self::Refusal(message) => Self::Refusal(clone_message_with_metadata(message)),
409            Self::ConcurrentRequests(message) => {
410                Self::ConcurrentRequests(clone_message_with_metadata(message))
411            }
412            Self::UnsupportedGuide(message) => {
413                Self::UnsupportedGuide(clone_message_with_metadata(message))
414            }
415            Self::ToolCallFailed(message) => {
416                Self::ToolCallFailed(clone_message_with_metadata(message))
417            }
418            Self::AdapterInvalidAsset(message) => {
419                Self::AdapterInvalidAsset(clone_message_with_metadata(message))
420            }
421            Self::AdapterInvalidName(message) => {
422                Self::AdapterInvalidName(clone_message_with_metadata(message))
423            }
424            Self::AdapterCompatibleNotFound(message) => {
425                Self::AdapterCompatibleNotFound(clone_message_with_metadata(message))
426            }
427            Self::Cancelled => Self::Cancelled,
428            Self::InvalidArgument(message) => {
429                Self::InvalidArgument(clone_message_with_metadata(message))
430            }
431            Self::Unknown { code, message } => Self::Unknown {
432                code: *code,
433                message: clone_message_with_metadata(message),
434            },
435        }
436    }
437}
438
439/// Reason why [`SystemLanguageModel`](crate::SystemLanguageModel) is unavailable.
440#[derive(Debug, Clone, Copy, PartialEq, Eq)]
441#[non_exhaustive]
442pub enum Unavailability {
443    /// The hardware does not support Apple Intelligence (e.g. Intel Mac, M1).
444    DeviceNotEligible,
445    /// Apple Intelligence is supported but disabled in System Settings.
446    AppleIntelligenceNotEnabled,
447    /// Model assets are still downloading.
448    ModelNotReady,
449    /// The host OS is older than macOS 26.0.
450    OsTooOld,
451    /// `FoundationModels` reported an unavailability reason this crate doesn't
452    /// recognise — most likely added in a newer SDK.
453    Unknown,
454}
455
456impl FMError {
457    fn message_storage(&self) -> Option<&String> {
458        match self {
459            Self::ModelUnavailable { message, .. }
460            | Self::GuardrailViolation(message)
461            | Self::ContextWindowExceeded(message)
462            | Self::UnsupportedLanguage(message)
463            | Self::AssetsUnavailable(message)
464            | Self::RateLimited(message)
465            | Self::DecodingFailure(message)
466            | Self::Refusal(message)
467            | Self::ConcurrentRequests(message)
468            | Self::UnsupportedGuide(message)
469            | Self::ToolCallFailed(message)
470            | Self::AdapterInvalidAsset(message)
471            | Self::AdapterInvalidName(message)
472            | Self::AdapterCompatibleNotFound(message)
473            | Self::InvalidArgument(message)
474            | Self::Unknown { message, .. } => Some(message),
475            Self::Cancelled => None,
476        }
477    }
478
479    fn metadata(&self) -> Option<ErrorMetadata> {
480        let message = self.message_storage()?;
481        metadata_registry()
482            .lock()
483            .expect("error metadata registry mutex poisoned")
484            .get(&(message.as_ptr() as usize))
485            .cloned()
486    }
487
488    /// Numeric status code reported by the Swift bridge. Useful for matching
489    /// against [`crate::ffi::status`] constants.
490    #[must_use]
491    pub const fn code(&self) -> i32 {
492        match self {
493            Self::ModelUnavailable { .. } => ffi::status::MODEL_UNAVAILABLE,
494            Self::GuardrailViolation(_) => ffi::status::GUARDRAIL_VIOLATION,
495            Self::ContextWindowExceeded(_) => ffi::status::CONTEXT_WINDOW_EXCEEDED,
496            Self::UnsupportedLanguage(_) => ffi::status::UNSUPPORTED_LANGUAGE,
497            Self::AssetsUnavailable(_) => ffi::status::ASSETS_UNAVAILABLE,
498            Self::RateLimited(_) => ffi::status::RATE_LIMITED,
499            Self::DecodingFailure(_) => ffi::status::DECODING_FAILURE,
500            Self::Refusal(_) => ffi::status::REFUSAL,
501            Self::ConcurrentRequests(_) => ffi::status::CONCURRENT_REQUESTS,
502            Self::UnsupportedGuide(_) => ffi::status::UNSUPPORTED_GUIDE,
503            Self::ToolCallFailed(_) => ffi::status::TOOL_CALL_FAILED,
504            Self::AdapterInvalidAsset(_) => ffi::status::ADAPTER_INVALID_ASSET,
505            Self::AdapterInvalidName(_) => ffi::status::ADAPTER_INVALID_NAME,
506            Self::AdapterCompatibleNotFound(_) => ffi::status::ADAPTER_COMPATIBLE_NOT_FOUND,
507            Self::Cancelled => ffi::status::CANCELLED,
508            Self::InvalidArgument(_) => ffi::status::INVALID_ARGUMENT,
509            Self::Unknown { code, .. } => *code,
510        }
511    }
512
513    /// Human-readable description (forwarded from `Error.localizedDescription`).
514    #[must_use]
515    pub fn message(&self) -> &str {
516        match self {
517            Self::ModelUnavailable { message, .. }
518            | Self::GuardrailViolation(message)
519            | Self::ContextWindowExceeded(message)
520            | Self::UnsupportedLanguage(message)
521            | Self::AssetsUnavailable(message)
522            | Self::RateLimited(message)
523            | Self::DecodingFailure(message)
524            | Self::Refusal(message)
525            | Self::ConcurrentRequests(message)
526            | Self::UnsupportedGuide(message)
527            | Self::ToolCallFailed(message)
528            | Self::AdapterInvalidAsset(message)
529            | Self::AdapterInvalidName(message)
530            | Self::AdapterCompatibleNotFound(message)
531            | Self::InvalidArgument(message)
532            | Self::Unknown { message, .. } => message,
533            Self::Cancelled => "generation cancelled",
534        }
535    }
536
537    /// Structured generation-error context, when available.
538    #[must_use]
539    pub fn generation_error_context(&self) -> Option<GenerationErrorContext> {
540        self.metadata()?.generation_error_context
541    }
542
543    /// Structured adapter-asset error context, when available.
544    #[must_use]
545    pub fn adapter_asset_error_context(&self) -> Option<AdapterAssetErrorContext> {
546        self.metadata()?.adapter_asset_error_context
547    }
548
549    /// Structured schema-error context, when available.
550    #[must_use]
551    pub fn schema_error_context(&self) -> Option<SchemaErrorContext> {
552        self.metadata()?.schema_error_context
553    }
554
555    /// Localized recovery suggestion, when the SDK provided one.
556    #[must_use]
557    pub fn recovery_suggestion(&self) -> Option<String> {
558        self.metadata()?.recovery_suggestion
559    }
560
561    /// Localized failure reason, when the SDK provided one.
562    #[must_use]
563    pub fn failure_reason(&self) -> Option<String> {
564        self.metadata()?.failure_reason
565    }
566
567    /// Typed refusal helper, when this error came from a refusal.
568    #[must_use]
569    pub fn refusal(&self) -> Option<Refusal> {
570        self.metadata()?.refusal
571    }
572
573    /// Typed tool-call failure metadata, when available.
574    #[must_use]
575    pub fn tool_call_error(&self) -> Option<ToolCallError> {
576        self.metadata()?.tool_call_error
577    }
578}
579
580impl fmt::Display for FMError {
581    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
582        write!(f, "{} (code {})", self.message(), self.code())
583    }
584}
585
586impl std::error::Error for FMError {}
587
588/// Build an `FMError` from a status code + error message returned by Swift.
589///
590/// Takes ownership of `error_str` (a heap-allocated C string from the
591/// Swift bridge) and frees it via `fm_string_free` after copying.
592pub(crate) fn from_swift(status: i32, error_str: *mut c_char) -> FMError {
593    let raw_message = if error_str.is_null() {
594        String::new()
595    } else {
596        let value = unsafe { CStr::from_ptr(error_str) }
597            .to_string_lossy()
598            .into_owned();
599        unsafe { ffi::fm_string_free(error_str) };
600        value
601    };
602
603    let (message, metadata) = match serde_json::from_str::<BridgeErrorPayload>(&raw_message) {
604        Ok(payload) => {
605            let message = payload.message.clone();
606            let metadata = payload.into_metadata();
607            (message, Some(metadata))
608        }
609        Err(_) => (raw_message, None),
610    };
611
612    let error = match status {
613        ffi::status::MODEL_UNAVAILABLE => FMError::ModelUnavailable {
614            reason: Unavailability::Unknown,
615            message,
616        },
617        ffi::status::GUARDRAIL_VIOLATION => FMError::GuardrailViolation(message),
618        ffi::status::CONTEXT_WINDOW_EXCEEDED => FMError::ContextWindowExceeded(message),
619        ffi::status::UNSUPPORTED_LANGUAGE => FMError::UnsupportedLanguage(message),
620        ffi::status::ASSETS_UNAVAILABLE => FMError::AssetsUnavailable(message),
621        ffi::status::RATE_LIMITED => FMError::RateLimited(message),
622        ffi::status::DECODING_FAILURE => FMError::DecodingFailure(message),
623        ffi::status::REFUSAL => FMError::Refusal(message),
624        ffi::status::CONCURRENT_REQUESTS => FMError::ConcurrentRequests(message),
625        ffi::status::UNSUPPORTED_GUIDE => FMError::UnsupportedGuide(message),
626        ffi::status::TOOL_CALL_FAILED => FMError::ToolCallFailed(message),
627        ffi::status::ADAPTER_INVALID_ASSET => FMError::AdapterInvalidAsset(message),
628        ffi::status::ADAPTER_INVALID_NAME => FMError::AdapterInvalidName(message),
629        ffi::status::ADAPTER_COMPATIBLE_NOT_FOUND => FMError::AdapterCompatibleNotFound(message),
630        ffi::status::CANCELLED => FMError::Cancelled,
631        ffi::status::INVALID_ARGUMENT => FMError::InvalidArgument(message),
632        code => FMError::Unknown { code, message },
633    };
634
635    if let (Some(message), Some(metadata)) = (error.message_storage(), metadata) {
636        register_metadata(message, metadata);
637    }
638
639    error
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645    use serde_json::json;
646
647    fn payload_ptr(value: serde_json::Value) -> *mut c_char {
648        let payload = CString::new(value.to_string()).expect("JSON payloads must not contain NUL");
649        unsafe { ffi::fm_string_dup(payload.as_ptr()) }
650    }
651
652    #[test]
653    fn generation_error_metadata_round_trips() {
654        let error = from_swift(
655            ffi::status::REFUSAL,
656            payload_ptr(json!({
657                "message": "request refused",
658                "recoverySuggestion": "Try a safer prompt",
659                "failureReason": "Safety policy",
660                "generationErrorContext": { "debugDescription": "guardrail refusal" },
661                "refusal": { "token": "refusal-token" }
662            })),
663        );
664        let cloned = error.clone();
665
666        assert_eq!(error.recovery_suggestion(), cloned.recovery_suggestion());
667        assert_eq!(cloned.message(), "request refused");
668        assert_eq!(
669            cloned.recovery_suggestion().as_deref(),
670            Some("Try a safer prompt")
671        );
672        assert_eq!(cloned.failure_reason().as_deref(), Some("Safety policy"));
673        assert_eq!(
674            cloned
675                .generation_error_context()
676                .expect("generation context")
677                .debug_description(),
678            "guardrail refusal"
679        );
680        assert_eq!(cloned.refusal(), Some(Refusal::from_token("refusal-token")));
681    }
682
683    #[test]
684    fn tool_call_error_metadata_round_trips() {
685        let error = from_swift(
686            ffi::status::TOOL_CALL_FAILED,
687            payload_ptr(json!({
688                "message": "tool failed",
689                "toolCallError": {
690                    "tool": {
691                        "name": "echo",
692                        "description": "Echo input",
693                        "parametersJSON": "{\"type\":\"object\"}"
694                    },
695                    "underlyingError": "callback panicked"
696                }
697            })),
698        );
699
700        let tool_call_error = error.tool_call_error().expect("tool call metadata");
701        assert_eq!(tool_call_error.tool().name, "echo");
702        assert_eq!(tool_call_error.tool().description, "Echo input");
703        assert_eq!(
704            tool_call_error.tool().parameters.json_schema(),
705            "{\"type\":\"object\"}"
706        );
707        assert_eq!(tool_call_error.underlying_error(), "callback panicked");
708    }
709
710    #[test]
711    fn schema_error_metadata_round_trips() {
712        let error = from_swift(
713            ffi::status::UNKNOWN,
714            payload_ptr(json!({
715                "message": "schema rejected",
716                "recoverySuggestion": "Rename the duplicate type",
717                "schemaErrorContext": { "debugDescription": "duplicate type Person" }
718            })),
719        );
720
721        assert_eq!(
722            error.recovery_suggestion().as_deref(),
723            Some("Rename the duplicate type")
724        );
725        assert_eq!(
726            error
727                .schema_error_context()
728                .expect("schema context")
729                .debug_description(),
730            "duplicate type Person"
731        );
732    }
733
734    #[test]
735    fn adapter_asset_error_metadata_round_trips() {
736        let error = from_swift(
737            ffi::status::ADAPTER_INVALID_NAME,
738            payload_ptr(json!({
739                "message": "adapter not found",
740                "recoverySuggestion": "Install a compatible adapter first",
741                "adapterAssetErrorContext": { "debugDescription": "missing adapter metadata" }
742            })),
743        );
744
745        assert_eq!(
746            error.recovery_suggestion().as_deref(),
747            Some("Install a compatible adapter first")
748        );
749        assert_eq!(
750            error
751                .adapter_asset_error_context()
752                .expect("adapter asset context")
753                .debug_description(),
754            "missing adapter metadata"
755        );
756    }
757}