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