Skip to main content

claude_codes/io/
content_blocks.rs

1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use serde_json::Value;
3use std::fmt;
4
5/// Deserialize content blocks that can be either a string or array
6pub(crate) fn deserialize_content_blocks<'de, D>(
7    deserializer: D,
8) -> Result<Vec<ContentBlock>, D::Error>
9where
10    D: Deserializer<'de>,
11{
12    let value: Value = Value::deserialize(deserializer)?;
13    match value {
14        Value::String(s) => Ok(vec![ContentBlock::Text(TextBlock {
15            text: s,
16            citations: Vec::new(),
17        })]),
18        Value::Array(_) => serde_json::from_value(value).map_err(serde::de::Error::custom),
19        _ => Err(serde::de::Error::custom(
20            "content must be a string or array",
21        )),
22    }
23}
24
25/// Content blocks for messages
26///
27/// Includes typed variants for known block types and an `Unknown` fallback
28/// for forward compatibility with new block types added by the CLI.
29#[derive(Debug, Clone)]
30pub enum ContentBlock {
31    Text(TextBlock),
32    Image(ImageBlock),
33    Thinking(ThinkingBlock),
34    ToolUse(ToolUseBlock),
35    ToolResult(ToolResultBlock),
36    /// Server-side tool use (e.g., web search, code execution).
37    ServerToolUse(ServerToolUseBlock),
38    /// Result from a web search server tool.
39    WebSearchToolResult(WebSearchToolResultBlock),
40    /// Result from server-side code execution.
41    CodeExecutionToolResult(CodeExecutionToolResultBlock),
42    /// MCP tool invocation as a content block.
43    McpToolUse(McpToolUseBlock),
44    /// MCP tool result as a content block.
45    McpToolResult(McpToolResultBlock),
46    /// Container file upload content block.
47    ContainerUpload(ContainerUploadBlock),
48    /// A content block type not yet known to this version of the crate.
49    /// Contains the raw JSON value for caller inspection.
50    Unknown(Value),
51}
52
53impl ContentBlock {
54    /// Returns the type tag string for this content block.
55    pub fn block_type(&self) -> &str {
56        match self {
57            Self::Text(_) => "text",
58            Self::Image(_) => "image",
59            Self::Thinking(_) => "thinking",
60            Self::ToolUse(_) => "tool_use",
61            Self::ToolResult(_) => "tool_result",
62            Self::ServerToolUse(_) => "server_tool_use",
63            Self::WebSearchToolResult(_) => "web_search_tool_result",
64            Self::CodeExecutionToolResult(_) => "code_execution_tool_result",
65            Self::McpToolUse(_) => "mcp_tool_use",
66            Self::McpToolResult(_) => "mcp_tool_result",
67            Self::ContainerUpload(_) => "container_upload",
68            Self::Unknown(v) => v.get("type").and_then(|t| t.as_str()).unwrap_or("unknown"),
69        }
70    }
71
72    /// Returns `true` if this is an unknown/unrecognized content block type.
73    pub fn is_unknown(&self) -> bool {
74        matches!(self, Self::Unknown(_))
75    }
76}
77
78impl Serialize for ContentBlock {
79    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
80        match self {
81            Self::Text(v) => serialize_tagged("text", v, serializer),
82            Self::Image(v) => serialize_tagged("image", v, serializer),
83            Self::Thinking(v) => serialize_tagged("thinking", v, serializer),
84            Self::ToolUse(v) => serialize_tagged("tool_use", v, serializer),
85            Self::ToolResult(v) => serialize_tagged("tool_result", v, serializer),
86            Self::ServerToolUse(v) => serialize_tagged("server_tool_use", v, serializer),
87            Self::WebSearchToolResult(v) => {
88                serialize_tagged("web_search_tool_result", v, serializer)
89            }
90            Self::CodeExecutionToolResult(v) => {
91                serialize_tagged("code_execution_tool_result", v, serializer)
92            }
93            Self::McpToolUse(v) => serialize_tagged("mcp_tool_use", v, serializer),
94            Self::McpToolResult(v) => serialize_tagged("mcp_tool_result", v, serializer),
95            Self::ContainerUpload(v) => serialize_tagged("container_upload", v, serializer),
96            Self::Unknown(v) => v.serialize(serializer),
97        }
98    }
99}
100
101impl<'de> Deserialize<'de> for ContentBlock {
102    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
103        let value = Value::deserialize(deserializer)?;
104        let type_str = value
105            .get("type")
106            .and_then(|v| v.as_str())
107            .ok_or_else(|| serde::de::Error::missing_field("type"))?;
108
109        match type_str {
110            "text" => serde_json::from_value(value)
111                .map(ContentBlock::Text)
112                .map_err(serde::de::Error::custom),
113            "image" => serde_json::from_value(value)
114                .map(ContentBlock::Image)
115                .map_err(serde::de::Error::custom),
116            "thinking" => serde_json::from_value(value)
117                .map(ContentBlock::Thinking)
118                .map_err(serde::de::Error::custom),
119            "tool_use" => serde_json::from_value(value)
120                .map(ContentBlock::ToolUse)
121                .map_err(serde::de::Error::custom),
122            "tool_result" => serde_json::from_value(value)
123                .map(ContentBlock::ToolResult)
124                .map_err(serde::de::Error::custom),
125            "server_tool_use" => serde_json::from_value(value)
126                .map(ContentBlock::ServerToolUse)
127                .map_err(serde::de::Error::custom),
128            "web_search_tool_result" => serde_json::from_value(value)
129                .map(ContentBlock::WebSearchToolResult)
130                .map_err(serde::de::Error::custom),
131            "code_execution_tool_result" => serde_json::from_value(value)
132                .map(ContentBlock::CodeExecutionToolResult)
133                .map_err(serde::de::Error::custom),
134            "mcp_tool_use" => serde_json::from_value(value)
135                .map(ContentBlock::McpToolUse)
136                .map_err(serde::de::Error::custom),
137            "mcp_tool_result" => serde_json::from_value(value)
138                .map(ContentBlock::McpToolResult)
139                .map_err(serde::de::Error::custom),
140            "container_upload" => serde_json::from_value(value)
141                .map(ContentBlock::ContainerUpload)
142                .map_err(serde::de::Error::custom),
143            _ => Ok(ContentBlock::Unknown(value)),
144        }
145    }
146}
147
148/// Serialize a value with an internally-tagged "type" field.
149fn serialize_tagged<S: Serializer, T: Serialize>(
150    tag: &str,
151    value: &T,
152    serializer: S,
153) -> Result<S::Ok, S::Error> {
154    let mut map = serde_json::to_value(value).map_err(serde::ser::Error::custom)?;
155    if let Some(obj) = map.as_object_mut() {
156        obj.insert("type".to_string(), Value::String(tag.to_string()));
157    }
158    map.serialize(serializer)
159}
160
161/// Text content block
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct TextBlock {
164    pub text: String,
165    /// Citations associated with this text block, if any.
166    /// Populated when the model references web search results or other sources.
167    #[serde(default, skip_serializing_if = "Vec::is_empty")]
168    pub citations: Vec<Citation>,
169}
170
171/// A citation attached to a [`TextBlock`], linking generated text back to a
172/// source.
173///
174/// Anthropic emits several citation shapes — `web_search_result_location`,
175/// `char_location`, `page_location`, `content_block_location`, … — that share a
176/// `type` tag and overlapping fields. This models the fields consumers commonly
177/// render as typed optionals and preserves any remaining variant-specific
178/// fields (start/end indices, `encrypted_index`, …) verbatim in
179/// [`Citation::extra`], so unmodeled or future shapes deserialize without loss.
180#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
181pub struct Citation {
182    /// Citation variant tag, e.g. `"web_search_result_location"`.
183    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
184    pub citation_type: Option<String>,
185    /// Source URL (web-search citations).
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub url: Option<String>,
188    /// Human-readable source title.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub title: Option<String>,
191    /// The span of source text being cited.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub cited_text: Option<String>,
194    /// Index of the source document (document-location citations).
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub document_index: Option<u32>,
197    /// Title of the source document.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub document_title: Option<String>,
200    /// Any additional location fields not modeled above, preserved verbatim.
201    #[serde(flatten)]
202    pub extra: serde_json::Map<String, Value>,
203}
204
205/// Image content block (follows Anthropic API structure)
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct ImageBlock {
208    pub source: ImageSource,
209}
210
211/// Encoding type for image source data.
212#[derive(Debug, Clone, PartialEq, Eq, Hash)]
213pub enum ImageSourceType {
214    /// Base64-encoded image data.
215    Base64,
216    /// A source type not yet known to this version of the crate.
217    Unknown(String),
218}
219
220impl ImageSourceType {
221    pub fn as_str(&self) -> &str {
222        match self {
223            Self::Base64 => "base64",
224            Self::Unknown(s) => s.as_str(),
225        }
226    }
227}
228
229impl fmt::Display for ImageSourceType {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        f.write_str(self.as_str())
232    }
233}
234
235impl From<&str> for ImageSourceType {
236    fn from(s: &str) -> Self {
237        match s {
238            "base64" => Self::Base64,
239            other => Self::Unknown(other.to_string()),
240        }
241    }
242}
243
244impl Serialize for ImageSourceType {
245    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
246        serializer.serialize_str(self.as_str())
247    }
248}
249
250impl<'de> Deserialize<'de> for ImageSourceType {
251    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
252        let s = String::deserialize(deserializer)?;
253        Ok(Self::from(s.as_str()))
254    }
255}
256
257/// MIME type for image content.
258#[derive(Debug, Clone, PartialEq, Eq, Hash)]
259pub enum MediaType {
260    /// JPEG image.
261    Jpeg,
262    /// PNG image.
263    Png,
264    /// GIF image.
265    Gif,
266    /// WebP image.
267    Webp,
268    /// A media type not yet known to this version of the crate.
269    Unknown(String),
270}
271
272impl MediaType {
273    pub fn as_str(&self) -> &str {
274        match self {
275            Self::Jpeg => "image/jpeg",
276            Self::Png => "image/png",
277            Self::Gif => "image/gif",
278            Self::Webp => "image/webp",
279            Self::Unknown(s) => s.as_str(),
280        }
281    }
282}
283
284impl fmt::Display for MediaType {
285    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
286        f.write_str(self.as_str())
287    }
288}
289
290impl From<&str> for MediaType {
291    fn from(s: &str) -> Self {
292        match s {
293            "image/jpeg" => Self::Jpeg,
294            "image/png" => Self::Png,
295            "image/gif" => Self::Gif,
296            "image/webp" => Self::Webp,
297            other => Self::Unknown(other.to_string()),
298        }
299    }
300}
301
302impl Serialize for MediaType {
303    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
304        serializer.serialize_str(self.as_str())
305    }
306}
307
308impl<'de> Deserialize<'de> for MediaType {
309    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
310        let s = String::deserialize(deserializer)?;
311        Ok(Self::from(s.as_str()))
312    }
313}
314
315/// Image source information
316#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct ImageSource {
318    #[serde(rename = "type")]
319    pub source_type: ImageSourceType,
320    pub media_type: MediaType,
321    pub data: String,
322}
323
324/// Thinking content block
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct ThinkingBlock {
327    pub thinking: String,
328    pub signature: String,
329}
330
331/// Tool use content block
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct ToolUseBlock {
334    pub id: String,
335    pub name: String,
336    pub input: Value,
337}
338
339impl ToolUseBlock {
340    /// Try to parse the input as a typed ToolInput.
341    ///
342    /// This attempts to deserialize the raw JSON input into a strongly-typed
343    /// `ToolInput` enum variant. Returns `None` if parsing fails.
344    ///
345    /// # Example
346    ///
347    /// ```
348    /// use claude_codes::{ToolUseBlock, ToolInput};
349    /// use serde_json::json;
350    ///
351    /// let block = ToolUseBlock {
352    ///     id: "toolu_123".to_string(),
353    ///     name: "Bash".to_string(),
354    ///     input: json!({"command": "ls -la"}),
355    /// };
356    ///
357    /// if let Some(ToolInput::Bash(bash)) = block.typed_input() {
358    ///     assert_eq!(bash.command, "ls -la");
359    /// }
360    /// ```
361    pub fn typed_input(&self) -> Option<crate::tool_inputs::ToolInput> {
362        serde_json::from_value(self.input.clone()).ok()
363    }
364
365    /// Parse the input as a typed ToolInput, returning an error on failure.
366    ///
367    /// Unlike `typed_input()`, this method returns the parsing error for debugging.
368    pub fn try_typed_input(&self) -> Result<crate::tool_inputs::ToolInput, serde_json::Error> {
369        serde_json::from_value(self.input.clone())
370    }
371}
372
373/// Tool result content block
374#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct ToolResultBlock {
376    pub tool_use_id: String,
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub content: Option<ToolResultContent>,
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub is_error: Option<bool>,
381}
382
383/// Tool result content type
384#[derive(Debug, Clone, Serialize, Deserialize)]
385#[serde(untagged)]
386pub enum ToolResultContent {
387    Text(String),
388    Structured(Vec<Value>),
389}
390
391/// Server-side tool use content block (e.g., web search, code execution).
392///
393/// Emitted when the model invokes an Anthropic-hosted server tool.
394#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct ServerToolUseBlock {
396    pub id: String,
397    pub name: String,
398    #[serde(default)]
399    pub input: Value,
400}
401
402/// Result from a web search server tool.
403#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct WebSearchToolResultBlock {
405    pub tool_use_id: String,
406    #[serde(default)]
407    pub content: Value,
408}
409
410/// Result from server-side code execution.
411#[derive(Debug, Clone, Serialize, Deserialize)]
412pub struct CodeExecutionToolResultBlock {
413    pub tool_use_id: String,
414    #[serde(default)]
415    pub content: Value,
416}
417
418/// MCP tool invocation content block.
419///
420/// Emitted when the model invokes a tool provided by an MCP server.
421#[derive(Debug, Clone, Serialize, Deserialize)]
422pub struct McpToolUseBlock {
423    pub id: String,
424    pub name: String,
425    #[serde(skip_serializing_if = "Option::is_none")]
426    pub server_name: Option<String>,
427    #[serde(default)]
428    pub input: Value,
429}
430
431/// MCP tool result content block.
432#[derive(Debug, Clone, Serialize, Deserialize)]
433pub struct McpToolResultBlock {
434    pub tool_use_id: String,
435    #[serde(default)]
436    pub content: Value,
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub is_error: Option<bool>,
439}
440
441/// Container file upload content block.
442#[derive(Debug, Clone, Serialize, Deserialize)]
443pub struct ContainerUploadBlock {
444    #[serde(flatten)]
445    pub data: Value,
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use serde_json::json;
452
453    #[test]
454    fn test_unknown_content_block_deserializes() {
455        let json = json!({
456            "type": "some_future_block_type",
457            "data": "arbitrary"
458        });
459
460        let block: ContentBlock = serde_json::from_value(json.clone()).unwrap();
461        assert!(block.is_unknown());
462        assert_eq!(block.block_type(), "some_future_block_type");
463        if let ContentBlock::Unknown(v) = &block {
464            assert_eq!(v["data"], "arbitrary");
465        } else {
466            panic!("Expected Unknown variant");
467        }
468    }
469
470    #[test]
471    fn test_unknown_block_roundtrips() {
472        let json = json!({
473            "type": "some_future_type",
474            "tool_use_id": "x",
475            "content": [{"nested": true}]
476        });
477
478        let block: ContentBlock = serde_json::from_value(json.clone()).unwrap();
479        let reserialized = serde_json::to_value(&block).unwrap();
480        assert_eq!(json, reserialized);
481    }
482
483    #[test]
484    fn test_server_tool_use_deserializes() {
485        let json = json!({
486            "type": "server_tool_use",
487            "id": "srvtu_1",
488            "name": "web_search",
489            "input": {"query": "rust serde"}
490        });
491
492        let block: ContentBlock = serde_json::from_value(json).unwrap();
493        assert!(!block.is_unknown());
494        assert_eq!(block.block_type(), "server_tool_use");
495        if let ContentBlock::ServerToolUse(b) = &block {
496            assert_eq!(b.id, "srvtu_1");
497            assert_eq!(b.name, "web_search");
498            assert_eq!(b.input["query"], "rust serde");
499        } else {
500            panic!("Expected ServerToolUse variant");
501        }
502    }
503
504    #[test]
505    fn test_web_search_tool_result_deserializes() {
506        let json = json!({
507            "type": "web_search_tool_result",
508            "tool_use_id": "srvtu_1",
509            "content": [{"type": "web_search_result", "url": "https://example.com"}]
510        });
511
512        let block: ContentBlock = serde_json::from_value(json.clone()).unwrap();
513        assert_eq!(block.block_type(), "web_search_tool_result");
514        if let ContentBlock::WebSearchToolResult(b) = &block {
515            assert_eq!(b.tool_use_id, "srvtu_1");
516        } else {
517            panic!("Expected WebSearchToolResult variant");
518        }
519        // roundtrip
520        let reserialized = serde_json::to_value(&block).unwrap();
521        assert_eq!(json, reserialized);
522    }
523
524    #[test]
525    fn test_code_execution_tool_result_deserializes() {
526        let json = json!({
527            "type": "code_execution_tool_result",
528            "tool_use_id": "exec_1",
529            "content": {"stdout": "hello", "exit_code": 0}
530        });
531
532        let block: ContentBlock = serde_json::from_value(json).unwrap();
533        assert_eq!(block.block_type(), "code_execution_tool_result");
534        assert!(matches!(block, ContentBlock::CodeExecutionToolResult(_)));
535    }
536
537    #[test]
538    fn test_mcp_tool_use_deserializes() {
539        let json = json!({
540            "type": "mcp_tool_use",
541            "id": "mcp_tu_1",
542            "name": "custom_tool",
543            "server_name": "my-mcp-server",
544            "input": {"arg": "value"}
545        });
546
547        let block: ContentBlock = serde_json::from_value(json).unwrap();
548        assert_eq!(block.block_type(), "mcp_tool_use");
549        if let ContentBlock::McpToolUse(b) = &block {
550            assert_eq!(b.id, "mcp_tu_1");
551            assert_eq!(b.name, "custom_tool");
552            assert_eq!(b.server_name.as_deref(), Some("my-mcp-server"));
553        } else {
554            panic!("Expected McpToolUse variant");
555        }
556    }
557
558    #[test]
559    fn test_mcp_tool_result_deserializes() {
560        let json = json!({
561            "type": "mcp_tool_result",
562            "tool_use_id": "mcp_tu_1",
563            "content": "tool output text",
564            "is_error": false
565        });
566
567        let block: ContentBlock = serde_json::from_value(json).unwrap();
568        assert_eq!(block.block_type(), "mcp_tool_result");
569        if let ContentBlock::McpToolResult(b) = &block {
570            assert_eq!(b.tool_use_id, "mcp_tu_1");
571            assert_eq!(b.is_error, Some(false));
572        } else {
573            panic!("Expected McpToolResult variant");
574        }
575    }
576
577    #[test]
578    fn test_container_upload_deserializes() {
579        let json = json!({
580            "type": "container_upload",
581            "file_name": "output.csv",
582            "url": "https://storage.example.com/file"
583        });
584
585        let block: ContentBlock = serde_json::from_value(json).unwrap();
586        assert_eq!(block.block_type(), "container_upload");
587        assert!(matches!(block, ContentBlock::ContainerUpload(_)));
588    }
589
590    #[test]
591    fn test_known_blocks_still_work() {
592        let text_json = json!({"type": "text", "text": "hello"});
593        let block: ContentBlock = serde_json::from_value(text_json).unwrap();
594        assert!(!block.is_unknown());
595        assert_eq!(block.block_type(), "text");
596        assert!(matches!(block, ContentBlock::Text(TextBlock { text, .. }) if text == "hello"));
597
598        let tool_json =
599            json!({"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls"}});
600        let block: ContentBlock = serde_json::from_value(tool_json).unwrap();
601        assert_eq!(block.block_type(), "tool_use");
602        assert!(matches!(block, ContentBlock::ToolUse(_)));
603    }
604
605    #[test]
606    fn test_known_blocks_roundtrip() {
607        let text_json = json!({"type": "text", "text": "hello world"});
608        let block: ContentBlock = serde_json::from_value(text_json.clone()).unwrap();
609        let reserialized = serde_json::to_value(&block).unwrap();
610        assert_eq!(text_json, reserialized);
611    }
612
613    #[test]
614    fn test_assistant_message_with_server_tool_use() {
615        let json = r#"{
616            "type": "assistant",
617            "message": {
618                "id": "msg_1",
619                "role": "assistant",
620                "model": "claude-3",
621                "content": [
622                    {"type": "text", "text": "Let me search for that."},
623                    {"type": "server_tool_use", "id": "srvtu_1", "name": "web_search", "input": {"query": "test"}},
624                    {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls"}}
625                ]
626            },
627            "session_id": "abc"
628        }"#;
629
630        let output: crate::io::ClaudeOutput = serde_json::from_str(json).unwrap();
631        assert!(output.is_assistant_message());
632        let assistant = output.as_assistant().unwrap();
633        assert_eq!(assistant.message.content.len(), 3);
634        assert!(matches!(
635            &assistant.message.content[0],
636            ContentBlock::Text(_)
637        ));
638        assert!(matches!(
639            &assistant.message.content[1],
640            ContentBlock::ServerToolUse(_)
641        ));
642        assert!(matches!(
643            &assistant.message.content[2],
644            ContentBlock::ToolUse(_)
645        ));
646
647        // text_content() still works, skipping non-text blocks
648        assert_eq!(
649            output.text_content(),
650            Some("Let me search for that.".to_string())
651        );
652        // tool_uses() only returns regular tool_use
653        assert_eq!(output.tool_uses().count(), 1);
654    }
655
656    #[test]
657    fn test_text_block_with_citations() {
658        let json = json!({
659            "type": "text",
660            "text": "According to the documentation...",
661            "citations": [
662                {"type": "web_search_result_location", "url": "https://example.com", "title": "Example"}
663            ]
664        });
665
666        let block: ContentBlock = serde_json::from_value(json.clone()).unwrap();
667        if let ContentBlock::Text(t) = &block {
668            assert_eq!(t.text, "According to the documentation...");
669            assert_eq!(t.citations.len(), 1);
670            let cite = &t.citations[0];
671            assert_eq!(
672                cite.citation_type.as_deref(),
673                Some("web_search_result_location")
674            );
675            assert_eq!(cite.url.as_deref(), Some("https://example.com"));
676            assert_eq!(cite.title.as_deref(), Some("Example"));
677        } else {
678            panic!("Expected Text variant");
679        }
680        // roundtrip preserves citations
681        let reserialized = serde_json::to_value(&block).unwrap();
682        assert_eq!(json, reserialized);
683    }
684
685    #[test]
686    fn test_citation_preserves_unmodeled_location_fields() {
687        // A char_location citation carries indices we don't model as named
688        // fields; they must survive in `extra` and round-trip intact.
689        let json = json!({
690            "type": "char_location",
691            "cited_text": "the quick brown fox",
692            "document_index": 0,
693            "document_title": "Doc A",
694            "start_char_index": 12,
695            "end_char_index": 31
696        });
697        let cite: Citation = serde_json::from_value(json.clone()).unwrap();
698        assert_eq!(cite.citation_type.as_deref(), Some("char_location"));
699        assert_eq!(cite.cited_text.as_deref(), Some("the quick brown fox"));
700        assert_eq!(cite.document_index, Some(0));
701        assert_eq!(
702            cite.extra.get("start_char_index").and_then(|v| v.as_u64()),
703            Some(12)
704        );
705        assert_eq!(
706            cite.extra.get("end_char_index").and_then(|v| v.as_u64()),
707            Some(31)
708        );
709        // Round-trips without losing the unmodeled fields.
710        assert_eq!(serde_json::to_value(&cite).unwrap(), json);
711    }
712
713    #[test]
714    fn test_text_block_without_citations_defaults_empty() {
715        let json = json!({"type": "text", "text": "no citations"});
716        let block: ContentBlock = serde_json::from_value(json).unwrap();
717        if let ContentBlock::Text(t) = &block {
718            assert!(t.citations.is_empty());
719        } else {
720            panic!("Expected Text variant");
721        }
722        // serialization omits empty citations
723        let reserialized = serde_json::to_value(&block).unwrap();
724        assert!(reserialized.get("citations").is_none());
725    }
726
727    #[test]
728    fn test_missing_type_field_errors() {
729        let json = json!({"text": "no type field"});
730        let result = serde_json::from_value::<ContentBlock>(json);
731        assert!(result.is_err());
732    }
733}