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