Skip to main content

zai_rs/agent/
response.rs

1//! Exact Agent v1 response contracts.
2
3use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
4
5use crate::{
6    ZaiError, ZaiResult,
7    client::error::{codes, mask_sensitive_info},
8};
9
10fn invalid_response(message: impl Into<String>) -> ZaiError {
11    ZaiError::ApiError {
12        code: codes::SDK_VALIDATION,
13        message: format!(
14            "agent response invariant violated: {}",
15            mask_sensitive_info(&message.into())
16        ),
17    }
18}
19
20fn required_text(value: Option<String>, field: &str) -> ZaiResult<String> {
21    match value {
22        Some(value) if !value.trim().is_empty() => Ok(value),
23        _ => Err(invalid_response(format!("{field} must be non-blank"))),
24    }
25}
26
27/// Content-part kinds in a non-streaming Agent invocation response.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum AgentResponseContentType {
31    /// Text content.
32    Text,
33    /// Downloadable file URL.
34    FileUrl,
35    /// Downloadable image URL.
36    ImageUrl,
37    /// Downloadable audio URL.
38    AudioUrl,
39    /// Downloadable video URL.
40    VideoUrl,
41}
42
43/// One multimodal part in an Agent invocation response.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct AgentResponseContentPart {
47    /// Frozen content discriminator.
48    #[serde(rename = "type")]
49    pub type_: AgentResponseContentType,
50    /// Text value for `type=text`.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub text: Option<String>,
53    /// File URL for `type=file_url`.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub file_url: Option<String>,
56    /// Image URL for `type=image_url`.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub image_url: Option<String>,
59    /// Audio URL for `type=audio_url`.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub audio_url: Option<String>,
62    /// Video URL for `type=video_url`.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub video_url: Option<String>,
65}
66
67/// Agent invocation response content in one of the frozen wire forms.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(untagged)]
70pub enum AgentResponseContent {
71    /// Plain text response.
72    Text(String),
73    /// One multimodal response part.
74    Part(AgentResponseContentPart),
75    /// Multiple multimodal response parts.
76    Parts(Vec<AgentResponseContentPart>),
77}
78
79/// One generated message in an invocation choice.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct AgentResponseMessage {
83    /// Response role, usually `assistant`.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub role: Option<String>,
86    /// Text or multimodal response content.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub content: Option<AgentResponseContent>,
89}
90
91/// One invocation result choice.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(deny_unknown_fields)]
94pub struct AgentChoice {
95    /// Result index.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub index: Option<i64>,
98    /// Generated response messages.
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub messages: Option<Vec<AgentResponseMessage>>,
101    /// Service-provided finish reason.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub finish_reason: Option<String>,
104}
105
106/// Token usage returned by a completed Agent invocation.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct AgentUsage {
110    /// Number of input tokens.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub prompt_tokens: Option<i64>,
113    /// Number of output tokens.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub completion_tokens: Option<i64>,
116    /// Total token count.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub total_tokens: Option<i64>,
119}
120
121/// Completed non-streaming Agent invocation.
122#[derive(Debug, Clone, Serialize)]
123pub struct AgentCompletedResponse {
124    /// Request identifier.
125    pub id: String,
126    /// Agent identifier.
127    pub agent_id: String,
128    /// Conversation identifier, when assigned.
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub conversation_id: Option<String>,
131    /// Non-empty generated choices.
132    pub choices: Vec<AgentChoice>,
133    /// Optional token usage.
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub usage: Option<AgentUsage>,
136}
137
138/// Pending asynchronous Agent invocation.
139#[derive(Debug, Clone, Serialize)]
140pub struct AgentPendingResponse {
141    /// Agent identifier.
142    pub agent_id: String,
143    /// Identifier used to poll the async-result operation.
144    pub async_id: String,
145}
146
147/// A non-streaming Agent invocation response.
148///
149/// The frozen wire schema has no `status` property. Deserialization therefore
150/// distinguishes a completed payload from a pending payload using their
151/// mutually exclusive documented fields.
152#[derive(Debug, Clone, Serialize)]
153#[serde(untagged)]
154pub enum AgentInvokeResponse {
155    /// Completed result with generated choices.
156    Completed(AgentCompletedResponse),
157    /// Accepted asynchronous invocation.
158    Pending(AgentPendingResponse),
159}
160
161#[derive(Deserialize)]
162#[serde(deny_unknown_fields)]
163struct AgentInvokeWire {
164    id: Option<String>,
165    agent_id: Option<String>,
166    conversation_id: Option<String>,
167    async_id: Option<String>,
168    choices: Option<Vec<AgentChoice>>,
169    usage: Option<AgentUsage>,
170}
171
172impl AgentInvokeResponse {
173    /// Validate invariants even for a value constructed in local code.
174    pub fn validate(&self) -> ZaiResult<()> {
175        match self {
176            Self::Completed(response) => {
177                if response.id.trim().is_empty()
178                    || response.agent_id.trim().is_empty()
179                    || response.choices.is_empty()
180                {
181                    return Err(invalid_response(
182                        "completed invoke requires id, agent_id, and non-empty choices",
183                    ));
184                }
185            },
186            Self::Pending(response) => {
187                if response.agent_id.trim().is_empty() || response.async_id.trim().is_empty() {
188                    return Err(invalid_response(
189                        "pending invoke requires agent_id and async_id",
190                    ));
191                }
192            },
193        }
194        Ok(())
195    }
196}
197
198impl<'de> Deserialize<'de> for AgentInvokeResponse {
199    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
200    where
201        D: Deserializer<'de>,
202    {
203        let value = serde_json::Value::deserialize(deserializer)?;
204        let object = value
205            .as_object()
206            .ok_or_else(|| D::Error::custom("agent invoke response must be a JSON object"))?;
207        let has_completed_field = ["id", "conversation_id", "choices", "usage"]
208            .iter()
209            .any(|field| object.contains_key(*field));
210        let has_pending_field = object.contains_key("async_id");
211
212        if has_completed_field && has_pending_field {
213            return Err(D::Error::custom(
214                "agent invoke response mixed completed and pending fields",
215            ));
216        }
217
218        let wire: AgentInvokeWire =
219            serde_json::from_value(value).map_err(|error| D::Error::custom(error.to_string()))?;
220        let response = if has_completed_field {
221            let choices = wire.choices.ok_or_else(|| {
222                D::Error::custom("completed agent invoke response omitted choices")
223            })?;
224            if choices.is_empty() {
225                return Err(D::Error::custom(
226                    "completed agent invoke response contained empty choices",
227                ));
228            }
229            Self::Completed(AgentCompletedResponse {
230                id: required_text(wire.id, "id")
231                    .map_err(|error| D::Error::custom(error.to_string()))?,
232                agent_id: required_text(wire.agent_id, "agent_id")
233                    .map_err(|error| D::Error::custom(error.to_string()))?,
234                conversation_id: wire.conversation_id,
235                choices,
236                usage: wire.usage,
237            })
238        } else if has_pending_field {
239            Self::Pending(AgentPendingResponse {
240                agent_id: required_text(wire.agent_id, "agent_id")
241                    .map_err(|error| D::Error::custom(error.to_string()))?,
242                async_id: required_text(wire.async_id, "async_id")
243                    .map_err(|error| D::Error::custom(error.to_string()))?,
244            })
245        } else {
246            return Err(D::Error::custom(
247                "agent invoke response matched neither completed nor pending shape",
248            ));
249        };
250
251        Ok(response)
252    }
253}
254
255/// One content part returned by the async-result endpoint.
256#[derive(Debug, Clone, Serialize, Deserialize)]
257#[serde(deny_unknown_fields)]
258pub struct AgentAsyncContentPart {
259    /// Provider content type; currently documented as `file_url`.
260    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
261    pub type_: Option<String>,
262    /// Download URL.
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub file_url: Option<String>,
265    /// Chinese description.
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub tag_cn: Option<String>,
268    /// English description.
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub tag_en: Option<String>,
271}
272
273/// One generated message returned by the async-result endpoint.
274#[derive(Debug, Clone, Serialize, Deserialize)]
275#[serde(deny_unknown_fields)]
276pub struct AgentAsyncMessage {
277    /// Response role.
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub role: Option<String>,
280    /// Generated file content parts.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub content: Option<Vec<AgentAsyncContentPart>>,
283}
284
285/// One async-result choice.
286#[derive(Debug, Clone, Serialize, Deserialize)]
287#[serde(deny_unknown_fields)]
288pub struct AgentAsyncChoice {
289    /// Generated response messages.
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub messages: Option<Vec<AgentAsyncMessage>>,
292}
293
294/// Token usage returned by the async-result endpoint.
295#[derive(Debug, Clone, Serialize, Deserialize)]
296#[serde(deny_unknown_fields)]
297pub struct AgentAsyncUsage {
298    /// Total token count.
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub total_tokens: Option<i64>,
301}
302
303/// Closed async-result task status.
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(rename_all = "lowercase")]
306pub enum AgentAsyncStatus {
307    /// Task completed successfully.
308    Success,
309    /// Task reached a failed terminal state.
310    Failed,
311    /// Task is still running.
312    Pending,
313}
314
315/// Response from `POST /v1/agents/async-result`.
316#[derive(Debug, Clone, Serialize)]
317#[serde(tag = "status", rename_all = "lowercase")]
318pub enum AgentAsyncResult {
319    /// Task is still processing.
320    Pending {
321        /// Agent identifier.
322        agent_id: String,
323        /// Asynchronous task identifier.
324        async_id: String,
325    },
326    /// Task completed with non-empty choices.
327    Success {
328        /// Agent identifier.
329        agent_id: String,
330        /// Asynchronous task identifier.
331        async_id: String,
332        /// Non-empty task choices.
333        choices: Vec<AgentAsyncChoice>,
334        /// Optional token usage.
335        #[serde(skip_serializing_if = "Option::is_none")]
336        usage: Option<AgentAsyncUsage>,
337    },
338    /// Task failed without a success payload.
339    Failed {
340        /// Agent identifier.
341        agent_id: String,
342        /// Asynchronous task identifier.
343        async_id: String,
344    },
345}
346
347#[derive(Deserialize)]
348#[serde(deny_unknown_fields)]
349struct AgentAsyncWire {
350    agent_id: Option<String>,
351    async_id: Option<String>,
352    status: Option<AgentAsyncStatus>,
353    choices: Option<Vec<AgentAsyncChoice>>,
354    usage: Option<AgentAsyncUsage>,
355}
356
357impl AgentAsyncResult {
358    /// Return the task status.
359    pub const fn status(&self) -> AgentAsyncStatus {
360        match self {
361            Self::Pending { .. } => AgentAsyncStatus::Pending,
362            Self::Success { .. } => AgentAsyncStatus::Success,
363            Self::Failed { .. } => AgentAsyncStatus::Failed,
364        }
365    }
366
367    /// Validate invariants even for a value constructed in local code.
368    pub fn validate(&self) -> ZaiResult<()> {
369        match self {
370            Self::Pending { agent_id, async_id } | Self::Failed { agent_id, async_id } => {
371                if agent_id.trim().is_empty() || async_id.trim().is_empty() {
372                    return Err(invalid_response(
373                        "async result requires non-blank agent_id and async_id",
374                    ));
375                }
376            },
377            Self::Success {
378                agent_id,
379                async_id,
380                choices,
381                ..
382            } => {
383                if agent_id.trim().is_empty() || async_id.trim().is_empty() || choices.is_empty() {
384                    return Err(invalid_response(
385                        "successful async result requires ids and non-empty choices",
386                    ));
387                }
388            },
389        }
390        Ok(())
391    }
392}
393
394impl<'de> Deserialize<'de> for AgentAsyncResult {
395    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
396    where
397        D: Deserializer<'de>,
398    {
399        let value = serde_json::Value::deserialize(deserializer)?;
400        let object = value
401            .as_object()
402            .ok_or_else(|| D::Error::custom("agent async result must be a JSON object"))?;
403        let has_result_fields = object.contains_key("choices") || object.contains_key("usage");
404        let wire: AgentAsyncWire =
405            serde_json::from_value(value).map_err(|error| D::Error::custom(error.to_string()))?;
406        let status = wire
407            .status
408            .ok_or_else(|| D::Error::custom("agent async result omitted status"))?;
409        let agent_id = required_text(wire.agent_id, "agent_id")
410            .map_err(|error| D::Error::custom(error.to_string()))?;
411        let async_id = required_text(wire.async_id, "async_id")
412            .map_err(|error| D::Error::custom(error.to_string()))?;
413
414        match status {
415            AgentAsyncStatus::Pending | AgentAsyncStatus::Failed if has_result_fields => Err(
416                D::Error::custom("non-success agent async result contained success-only fields"),
417            ),
418            AgentAsyncStatus::Pending => Ok(Self::Pending { agent_id, async_id }),
419            AgentAsyncStatus::Failed => Ok(Self::Failed { agent_id, async_id }),
420            AgentAsyncStatus::Success => {
421                let choices = wire.choices.ok_or_else(|| {
422                    D::Error::custom("successful agent async result omitted choices")
423                })?;
424                if choices.is_empty() {
425                    return Err(D::Error::custom(
426                        "successful agent async result contained empty choices",
427                    ));
428                }
429                Ok(Self::Success {
430                    agent_id,
431                    async_id,
432                    choices,
433                    usage: wire.usage,
434                })
435            },
436        }
437    }
438}
439
440/// One slide-conversation response content part.
441#[derive(Debug, Clone, Serialize, Deserialize)]
442#[serde(deny_unknown_fields)]
443pub struct AgentConversationContentPart {
444    /// Provider content type (`file_url` or `image_url`).
445    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
446    pub type_: Option<String>,
447    /// Chinese description.
448    #[serde(skip_serializing_if = "Option::is_none")]
449    pub tag_cn: Option<String>,
450    /// English description.
451    #[serde(skip_serializing_if = "Option::is_none")]
452    pub tag_en: Option<String>,
453    /// Downloadable file URL.
454    #[serde(skip_serializing_if = "Option::is_none")]
455    pub file_url: Option<String>,
456    /// Downloadable image URL.
457    #[serde(skip_serializing_if = "Option::is_none")]
458    pub image_url: Option<String>,
459}
460
461/// One message in a slide-conversation choice.
462#[derive(Debug, Clone, Serialize, Deserialize)]
463#[serde(deny_unknown_fields)]
464pub struct AgentConversationMessage {
465    /// Response role.
466    #[serde(skip_serializing_if = "Option::is_none")]
467    pub role: Option<String>,
468    /// Generated content parts.
469    #[serde(skip_serializing_if = "Option::is_none")]
470    pub content: Option<Vec<AgentConversationContentPart>>,
471}
472
473/// One slide-conversation response choice.
474#[derive(Debug, Clone, Serialize, Deserialize)]
475#[serde(deny_unknown_fields)]
476pub struct AgentConversationChoice {
477    /// Frozen schema uses singular `message` for this message array.
478    #[serde(skip_serializing_if = "Option::is_none")]
479    pub message: Option<Vec<AgentConversationMessage>>,
480}
481
482/// Embedded failure detail returned by the conversation schema.
483#[derive(Debug, Clone, Serialize, Deserialize)]
484#[serde(deny_unknown_fields)]
485pub struct AgentErrorDetail {
486    /// Service error code.
487    #[serde(skip_serializing_if = "Option::is_none")]
488    pub code: Option<String>,
489    /// Human-readable failure message.
490    #[serde(skip_serializing_if = "Option::is_none")]
491    pub message: Option<String>,
492}
493
494impl AgentErrorDetail {
495    fn has_documented_value(&self) -> bool {
496        self.code.is_some() || self.message.is_some()
497    }
498}
499
500/// Successful slide-conversation response.
501#[derive(Debug, Clone, Serialize)]
502pub struct AgentConversationSuccess {
503    /// Conversation identifier.
504    pub conversation_id: String,
505    /// Agent identifier.
506    pub agent_id: String,
507    /// Non-empty response choices.
508    pub choices: Vec<AgentConversationChoice>,
509}
510
511/// Failed slide-conversation response.
512#[derive(Debug, Clone, Serialize)]
513pub struct AgentConversationFailure {
514    /// Conversation identifier, when returned.
515    #[serde(skip_serializing_if = "Option::is_none")]
516    pub conversation_id: Option<String>,
517    /// Agent identifier, when returned.
518    #[serde(skip_serializing_if = "Option::is_none")]
519    pub agent_id: Option<String>,
520    /// Typed error detail.
521    pub error: AgentErrorDetail,
522}
523
524/// Closed success/failure result from the slide-conversation endpoint.
525#[derive(Debug, Clone, Serialize)]
526#[serde(untagged)]
527pub enum AgentConversationResponse {
528    /// Successful conversation continuation.
529    Success(AgentConversationSuccess),
530    /// Schema-defined embedded failure.
531    Failed(AgentConversationFailure),
532}
533
534#[derive(Deserialize)]
535#[serde(deny_unknown_fields)]
536struct AgentConversationWire {
537    conversation_id: Option<String>,
538    agent_id: Option<String>,
539    choices: Option<Vec<AgentConversationChoice>>,
540    error: Option<AgentErrorDetail>,
541}
542
543impl AgentConversationResponse {
544    /// Validate invariants even for a value constructed in local code.
545    pub fn validate(&self) -> ZaiResult<()> {
546        match self {
547            Self::Success(response) => {
548                if response.conversation_id.trim().is_empty()
549                    || response.agent_id.trim().is_empty()
550                    || response.choices.is_empty()
551                {
552                    return Err(invalid_response(
553                        "conversation success requires ids and non-empty choices",
554                    ));
555                }
556            },
557            Self::Failed(response) if !response.error.has_documented_value() => {
558                return Err(invalid_response(
559                    "conversation failure requires a documented error field",
560                ));
561            },
562            Self::Failed(_) => {},
563        }
564        Ok(())
565    }
566}
567
568impl<'de> Deserialize<'de> for AgentConversationResponse {
569    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
570    where
571        D: Deserializer<'de>,
572    {
573        let value = serde_json::Value::deserialize(deserializer)?;
574        let object = value
575            .as_object()
576            .ok_or_else(|| D::Error::custom("agent conversation response must be an object"))?;
577        let has_error = object.contains_key("error");
578        let has_choices = object.contains_key("choices");
579        if has_error && has_choices {
580            return Err(D::Error::custom(
581                "agent conversation response mixed success and failure fields",
582            ));
583        }
584
585        let wire: AgentConversationWire =
586            serde_json::from_value(value).map_err(|error| D::Error::custom(error.to_string()))?;
587        if has_error {
588            let error = wire.error.ok_or_else(|| {
589                D::Error::custom("agent conversation failure contained a null error")
590            })?;
591            if !error.has_documented_value() {
592                return Err(D::Error::custom(
593                    "agent conversation failure contained an empty error",
594                ));
595            }
596            return Ok(Self::Failed(AgentConversationFailure {
597                conversation_id: wire.conversation_id,
598                agent_id: wire.agent_id,
599                error,
600            }));
601        }
602
603        let choices = wire
604            .choices
605            .ok_or_else(|| D::Error::custom("agent conversation success omitted choices"))?;
606        if choices.is_empty() {
607            return Err(D::Error::custom(
608                "agent conversation success contained empty choices",
609            ));
610        }
611        Ok(Self::Success(AgentConversationSuccess {
612            conversation_id: required_text(wire.conversation_id, "conversation_id")
613                .map_err(|error| D::Error::custom(error.to_string()))?,
614            agent_id: required_text(wire.agent_id, "agent_id")
615                .map_err(|error| D::Error::custom(error.to_string()))?,
616            choices,
617        }))
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    #[test]
626    fn invoke_completed_uses_messages_array_without_a_status_tag() {
627        let response: AgentInvokeResponse = serde_json::from_value(serde_json::json!({
628            "id": "invoke-1",
629            "agent_id": "general_translation",
630            "conversation_id": "conversation-1",
631            "choices": [{
632                "index": 0,
633                "messages": [{"role": "assistant", "content": "done"}],
634                "finish_reason": "stop"
635            }],
636            "usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
637        }))
638        .unwrap();
639
640        let AgentInvokeResponse::Completed(completed) = response else {
641            panic!("expected completed response");
642        };
643        assert_eq!(completed.id, "invoke-1");
644        assert_eq!(completed.choices.len(), 1);
645        assert_eq!(completed.choices[0].messages.as_ref().unwrap().len(), 1);
646    }
647
648    #[test]
649    fn invoke_pending_has_no_synthetic_status_field() {
650        let response: AgentInvokeResponse = serde_json::from_value(serde_json::json!({
651            "agent_id": "general_translation",
652            "async_id": "task-1"
653        }))
654        .unwrap();
655        assert!(matches!(response, AgentInvokeResponse::Pending(_)));
656    }
657
658    #[test]
659    fn invoke_rejects_empty_malformed_and_contradictory_shapes() {
660        for value in [
661            serde_json::json!({}),
662            serde_json::json!({"status": "completed", "id": "i"}),
663            serde_json::json!({"id": "i", "agent_id": "a", "choices": []}),
664            serde_json::json!({
665                "id": "i", "agent_id": "a", "async_id": "t", "choices": []
666            }),
667            serde_json::json!({
668                "agent_id": "a", "async_id": "t", "choices": null
669            }),
670        ] {
671            assert!(serde_json::from_value::<AgentInvokeResponse>(value).is_err());
672        }
673    }
674
675    #[test]
676    fn invoke_response_decodes_each_closed_content_form() {
677        let response: AgentInvokeResponse = serde_json::from_value(serde_json::json!({
678            "id": "invoke-1",
679            "agent_id": "ai_drawing_agent",
680            "choices": [{
681                "messages": [
682                    {"content": {"type": "image_url", "image_url": "https://example.test/one.png"}},
683                    {"content": [
684                        {"type": "text", "text": "caption"},
685                        {"type": "image_url", "image_url": "https://example.test/two.png"}
686                    ]}
687                ]
688            }]
689        }))
690        .unwrap();
691        assert!(matches!(response, AgentInvokeResponse::Completed(_)));
692    }
693
694    #[test]
695    fn async_result_decodes_all_statuses_and_exact_nested_messages() {
696        let success: AgentAsyncResult = serde_json::from_value(serde_json::json!({
697            "agent_id": "agent-1",
698            "async_id": "task-1",
699            "status": "success",
700            "choices": [{
701                "messages": [{
702                    "role": "assistant",
703                    "content": [{
704                        "type": "file_url",
705                        "file_url": "https://example.test/result.pdf",
706                        "tag_cn": "结果",
707                        "tag_en": "result"
708                    }]
709                }]
710            }],
711            "usage": {"total_tokens": 42}
712        }))
713        .unwrap();
714        assert_eq!(success.status(), AgentAsyncStatus::Success);
715
716        let pending: AgentAsyncResult = serde_json::from_value(serde_json::json!({
717            "agent_id": "agent-1", "async_id": "task-1", "status": "pending"
718        }))
719        .unwrap();
720        assert_eq!(pending.status(), AgentAsyncStatus::Pending);
721
722        let failed: AgentAsyncResult = serde_json::from_value(serde_json::json!({
723            "agent_id": "agent-1", "async_id": "task-1", "status": "failed"
724        }))
725        .unwrap();
726        assert_eq!(failed.status(), AgentAsyncStatus::Failed);
727    }
728
729    #[test]
730    fn async_result_rejects_empty_or_status_contradictions() {
731        for value in [
732            serde_json::json!({}),
733            serde_json::json!({"agent_id": "a", "async_id": "t"}),
734            serde_json::json!({
735                "agent_id": "a", "async_id": "t", "status": "success", "choices": []
736            }),
737            serde_json::json!({
738                "agent_id": "a", "async_id": "t", "status": "pending", "choices": null
739            }),
740        ] {
741            assert!(serde_json::from_value::<AgentAsyncResult>(value).is_err());
742        }
743    }
744
745    #[test]
746    fn conversation_uses_singular_message_array_and_typed_error() {
747        let success: AgentConversationResponse = serde_json::from_value(serde_json::json!({
748            "conversation_id": "conversation-1",
749            "agent_id": "slides_glm_agent",
750            "choices": [{
751                "message": [{
752                    "role": "assistant",
753                    "content": [{
754                        "type": "file_url",
755                        "file_url": "https://example.test/slides.pptx",
756                        "tag_cn": "演示文稿",
757                        "tag_en": "slides"
758                    }]
759                }]
760            }]
761        }))
762        .unwrap();
763        assert!(matches!(success, AgentConversationResponse::Success(_)));
764
765        let failed: AgentConversationResponse = serde_json::from_value(serde_json::json!({
766            "conversation_id": "conversation-1",
767            "agent_id": "slides_glm_agent",
768            "error": {"code": "invalid_slide", "message": "could not render"}
769        }))
770        .unwrap();
771        assert!(matches!(failed, AgentConversationResponse::Failed(_)));
772    }
773
774    #[test]
775    fn conversation_rejects_empty_wrong_and_contradictory_shapes() {
776        for value in [
777            serde_json::json!({}),
778            serde_json::json!({
779                "conversation_id": "c", "agent_id": "a", "choices": []
780            }),
781            serde_json::json!({
782                "conversation_id": "c", "agent_id": "a",
783                "choices": [{"messages": []}]
784            }),
785            serde_json::json!({"error": {}}),
786            serde_json::json!({
787                "conversation_id": "c", "agent_id": "a", "choices": [{}],
788                "error": {"message": "failed"}
789            }),
790        ] {
791            assert!(serde_json::from_value::<AgentConversationResponse>(value).is_err());
792        }
793    }
794}