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    /// Who issued the tool call. `{ "type": "direct" }` for the top-level
346    /// agent; subagent calls carry their own caller provenance. Absent on
347    /// older CLI versions.
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub caller: Option<ToolCaller>,
350}
351
352/// Provenance of a [`ToolUseBlock`] — identifies who issued the tool call.
353#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct ToolCaller {
355    /// Caller kind (e.g. `direct` for the top-level agent).
356    #[serde(rename = "type")]
357    pub caller_type: String,
358    /// Any additional caller provenance the CLI attaches, preserved verbatim.
359    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
360    pub extra: serde_json::Map<String, Value>,
361}
362
363impl ToolUseBlock {
364    /// Try to parse the input as a typed ToolInput.
365    ///
366    /// This attempts to deserialize the raw JSON input into a strongly-typed
367    /// `ToolInput` enum variant. Returns `None` if parsing fails.
368    ///
369    /// # Example
370    ///
371    /// ```
372    /// use claude_codes::{ToolUseBlock, ToolInput};
373    /// use serde_json::json;
374    ///
375    /// let block = ToolUseBlock {
376    ///     id: "toolu_123".to_string(),
377    ///     name: "Bash".to_string(),
378    ///     input: json!({"command": "ls -la"}),
379    ///     caller: None,
380    /// };
381    ///
382    /// if let Some(ToolInput::Bash(bash)) = block.typed_input() {
383    ///     assert_eq!(bash.command, "ls -la");
384    /// }
385    /// ```
386    pub fn typed_input(&self) -> Option<crate::tool_inputs::ToolInput> {
387        serde_json::from_value(self.input.clone()).ok()
388    }
389
390    /// Parse the input as a typed ToolInput, returning an error on failure.
391    ///
392    /// Unlike `typed_input()`, this method returns the parsing error for debugging.
393    pub fn try_typed_input(&self) -> Result<crate::tool_inputs::ToolInput, serde_json::Error> {
394        serde_json::from_value(self.input.clone())
395    }
396}
397
398/// Tool result content block
399#[derive(Debug, Clone, Serialize, Deserialize)]
400pub struct ToolResultBlock {
401    pub tool_use_id: String,
402    #[serde(skip_serializing_if = "Option::is_none")]
403    pub content: Option<ToolResultContent>,
404    #[serde(skip_serializing_if = "Option::is_none")]
405    pub is_error: Option<bool>,
406}
407
408/// Tool result content type
409#[derive(Debug, Clone, Serialize, Deserialize)]
410#[serde(untagged)]
411pub enum ToolResultContent {
412    Text(String),
413    Structured(Vec<Value>),
414}
415
416/// Server-side tool use content block (e.g., web search, code execution).
417///
418/// Emitted when the model invokes an Anthropic-hosted server tool.
419#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct ServerToolUseBlock {
421    pub id: String,
422    pub name: String,
423    #[serde(default)]
424    pub input: Value,
425}
426
427/// Result from a web search server tool.
428#[derive(Debug, Clone, Serialize, Deserialize)]
429pub struct WebSearchToolResultBlock {
430    pub tool_use_id: String,
431    #[serde(default)]
432    pub content: Value,
433}
434
435/// Result from server-side code execution.
436#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct CodeExecutionToolResultBlock {
438    pub tool_use_id: String,
439    #[serde(default)]
440    pub content: Value,
441}
442
443/// MCP tool invocation content block.
444///
445/// Emitted when the model invokes a tool provided by an MCP server.
446#[derive(Debug, Clone, Serialize, Deserialize)]
447pub struct McpToolUseBlock {
448    pub id: String,
449    pub name: String,
450    #[serde(skip_serializing_if = "Option::is_none")]
451    pub server_name: Option<String>,
452    #[serde(default)]
453    pub input: Value,
454}
455
456/// MCP tool result content block.
457#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct McpToolResultBlock {
459    pub tool_use_id: String,
460    #[serde(default)]
461    pub content: Value,
462    #[serde(skip_serializing_if = "Option::is_none")]
463    pub is_error: Option<bool>,
464}
465
466/// Container file upload content block.
467#[derive(Debug, Clone, Serialize, Deserialize)]
468pub struct ContainerUploadBlock {
469    #[serde(flatten)]
470    pub data: Value,
471}
472
473/// Model fallback content block.
474///
475/// Marks the point in `content` where the response switched from one model
476/// to another (e.g. when the primary model is overloaded). Blocks after this
477/// marker were produced by the `to` model.
478#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
479pub struct FallbackBlock {
480    /// The model the response is switching away from.
481    pub from: FallbackModel,
482    /// The model now serving the response.
483    pub to: FallbackModel,
484}
485
486/// A model reference inside a [`FallbackBlock`].
487#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
488pub struct FallbackModel {
489    pub model: String,
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use serde_json::json;
496
497    #[test]
498    fn test_unknown_content_block_deserializes() {
499        let json = json!({
500            "type": "some_future_block_type",
501            "data": "arbitrary"
502        });
503
504        let block: ContentBlock = serde_json::from_value(json.clone()).unwrap();
505        assert!(block.is_unknown());
506        assert_eq!(block.block_type(), "some_future_block_type");
507        if let ContentBlock::Unknown(v) = &block {
508            assert_eq!(v["data"], "arbitrary");
509        } else {
510            panic!("Expected Unknown variant");
511        }
512    }
513
514    #[test]
515    fn test_unknown_block_roundtrips() {
516        let json = json!({
517            "type": "some_future_type",
518            "tool_use_id": "x",
519            "content": [{"nested": true}]
520        });
521
522        let block: ContentBlock = serde_json::from_value(json.clone()).unwrap();
523        let reserialized = serde_json::to_value(&block).unwrap();
524        assert_eq!(json, reserialized);
525    }
526
527    #[test]
528    fn test_server_tool_use_deserializes() {
529        let json = json!({
530            "type": "server_tool_use",
531            "id": "srvtu_1",
532            "name": "web_search",
533            "input": {"query": "rust serde"}
534        });
535
536        let block: ContentBlock = serde_json::from_value(json).unwrap();
537        assert!(!block.is_unknown());
538        assert_eq!(block.block_type(), "server_tool_use");
539        if let ContentBlock::ServerToolUse(b) = &block {
540            assert_eq!(b.id, "srvtu_1");
541            assert_eq!(b.name, "web_search");
542            assert_eq!(b.input["query"], "rust serde");
543        } else {
544            panic!("Expected ServerToolUse variant");
545        }
546    }
547
548    #[test]
549    fn test_web_search_tool_result_deserializes() {
550        let json = json!({
551            "type": "web_search_tool_result",
552            "tool_use_id": "srvtu_1",
553            "content": [{"type": "web_search_result", "url": "https://example.com"}]
554        });
555
556        let block: ContentBlock = serde_json::from_value(json.clone()).unwrap();
557        assert_eq!(block.block_type(), "web_search_tool_result");
558        if let ContentBlock::WebSearchToolResult(b) = &block {
559            assert_eq!(b.tool_use_id, "srvtu_1");
560        } else {
561            panic!("Expected WebSearchToolResult variant");
562        }
563        // roundtrip
564        let reserialized = serde_json::to_value(&block).unwrap();
565        assert_eq!(json, reserialized);
566    }
567
568    #[test]
569    fn test_code_execution_tool_result_deserializes() {
570        let json = json!({
571            "type": "code_execution_tool_result",
572            "tool_use_id": "exec_1",
573            "content": {"stdout": "hello", "exit_code": 0}
574        });
575
576        let block: ContentBlock = serde_json::from_value(json).unwrap();
577        assert_eq!(block.block_type(), "code_execution_tool_result");
578        assert!(matches!(block, ContentBlock::CodeExecutionToolResult(_)));
579    }
580
581    #[test]
582    fn test_mcp_tool_use_deserializes() {
583        let json = json!({
584            "type": "mcp_tool_use",
585            "id": "mcp_tu_1",
586            "name": "custom_tool",
587            "server_name": "my-mcp-server",
588            "input": {"arg": "value"}
589        });
590
591        let block: ContentBlock = serde_json::from_value(json).unwrap();
592        assert_eq!(block.block_type(), "mcp_tool_use");
593        if let ContentBlock::McpToolUse(b) = &block {
594            assert_eq!(b.id, "mcp_tu_1");
595            assert_eq!(b.name, "custom_tool");
596            assert_eq!(b.server_name.as_deref(), Some("my-mcp-server"));
597        } else {
598            panic!("Expected McpToolUse variant");
599        }
600    }
601
602    #[test]
603    fn test_mcp_tool_result_deserializes() {
604        let json = json!({
605            "type": "mcp_tool_result",
606            "tool_use_id": "mcp_tu_1",
607            "content": "tool output text",
608            "is_error": false
609        });
610
611        let block: ContentBlock = serde_json::from_value(json).unwrap();
612        assert_eq!(block.block_type(), "mcp_tool_result");
613        if let ContentBlock::McpToolResult(b) = &block {
614            assert_eq!(b.tool_use_id, "mcp_tu_1");
615            assert_eq!(b.is_error, Some(false));
616        } else {
617            panic!("Expected McpToolResult variant");
618        }
619    }
620
621    #[test]
622    fn test_container_upload_deserializes() {
623        let json = json!({
624            "type": "container_upload",
625            "file_name": "output.csv",
626            "url": "https://storage.example.com/file"
627        });
628
629        let block: ContentBlock = serde_json::from_value(json).unwrap();
630        assert_eq!(block.block_type(), "container_upload");
631        assert!(matches!(block, ContentBlock::ContainerUpload(_)));
632    }
633
634    #[test]
635    fn test_fallback_block_deserializes() {
636        // Exact shape emitted by the CLI (verified against claude 2.1.172).
637        let json = json!({
638            "from": { "model": "claude-fable-5" },
639            "to": { "model": "claude-opus-4-8" },
640            "type": "fallback"
641        });
642
643        let block: ContentBlock = serde_json::from_value(json.clone()).unwrap();
644        assert!(!block.is_unknown());
645        assert_eq!(block.block_type(), "fallback");
646        if let ContentBlock::Fallback(b) = &block {
647            assert_eq!(b.from.model, "claude-fable-5");
648            assert_eq!(b.to.model, "claude-opus-4-8");
649        } else {
650            panic!("Expected Fallback variant");
651        }
652        // roundtrip
653        let reserialized = serde_json::to_value(&block).unwrap();
654        assert_eq!(json, reserialized);
655    }
656
657    #[test]
658    fn test_known_blocks_still_work() {
659        let text_json = json!({"type": "text", "text": "hello"});
660        let block: ContentBlock = serde_json::from_value(text_json).unwrap();
661        assert!(!block.is_unknown());
662        assert_eq!(block.block_type(), "text");
663        assert!(matches!(block, ContentBlock::Text(TextBlock { text, .. }) if text == "hello"));
664
665        let tool_json =
666            json!({"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls"}});
667        let block: ContentBlock = serde_json::from_value(tool_json).unwrap();
668        assert_eq!(block.block_type(), "tool_use");
669        assert!(matches!(block, ContentBlock::ToolUse(_)));
670    }
671
672    #[test]
673    fn test_known_blocks_roundtrip() {
674        let text_json = json!({"type": "text", "text": "hello world"});
675        let block: ContentBlock = serde_json::from_value(text_json.clone()).unwrap();
676        let reserialized = serde_json::to_value(&block).unwrap();
677        assert_eq!(text_json, reserialized);
678    }
679
680    #[test]
681    fn test_assistant_message_with_server_tool_use() {
682        let json = r#"{
683            "type": "assistant",
684            "message": {
685                "id": "msg_1",
686                "role": "assistant",
687                "model": "claude-3",
688                "content": [
689                    {"type": "text", "text": "Let me search for that."},
690                    {"type": "server_tool_use", "id": "srvtu_1", "name": "web_search", "input": {"query": "test"}},
691                    {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls"}}
692                ]
693            },
694            "session_id": "abc"
695        }"#;
696
697        let output: crate::io::ClaudeOutput = serde_json::from_str(json).unwrap();
698        assert!(output.is_assistant_message());
699        let assistant = output.as_assistant().unwrap();
700        assert_eq!(assistant.message.content.len(), 3);
701        assert!(matches!(
702            &assistant.message.content[0],
703            ContentBlock::Text(_)
704        ));
705        assert!(matches!(
706            &assistant.message.content[1],
707            ContentBlock::ServerToolUse(_)
708        ));
709        assert!(matches!(
710            &assistant.message.content[2],
711            ContentBlock::ToolUse(_)
712        ));
713
714        // text_content() still works, skipping non-text blocks
715        assert_eq!(
716            output.text_content(),
717            Some("Let me search for that.".to_string())
718        );
719        // tool_uses() only returns regular tool_use
720        assert_eq!(output.tool_uses().count(), 1);
721    }
722
723    #[test]
724    fn test_text_block_with_citations() {
725        let json = json!({
726            "type": "text",
727            "text": "According to the documentation...",
728            "citations": [
729                {"type": "web_search_result_location", "url": "https://example.com", "title": "Example"}
730            ]
731        });
732
733        let block: ContentBlock = serde_json::from_value(json.clone()).unwrap();
734        if let ContentBlock::Text(t) = &block {
735            assert_eq!(t.text, "According to the documentation...");
736            assert_eq!(t.citations.len(), 1);
737            let cite = &t.citations[0];
738            assert_eq!(
739                cite.citation_type.as_deref(),
740                Some("web_search_result_location")
741            );
742            assert_eq!(cite.url.as_deref(), Some("https://example.com"));
743            assert_eq!(cite.title.as_deref(), Some("Example"));
744        } else {
745            panic!("Expected Text variant");
746        }
747        // roundtrip preserves citations
748        let reserialized = serde_json::to_value(&block).unwrap();
749        assert_eq!(json, reserialized);
750    }
751
752    #[test]
753    fn test_citation_preserves_unmodeled_location_fields() {
754        // A char_location citation carries indices we don't model as named
755        // fields; they must survive in `extra` and round-trip intact.
756        let json = json!({
757            "type": "char_location",
758            "cited_text": "the quick brown fox",
759            "document_index": 0,
760            "document_title": "Doc A",
761            "start_char_index": 12,
762            "end_char_index": 31
763        });
764        let cite: Citation = serde_json::from_value(json.clone()).unwrap();
765        assert_eq!(cite.citation_type.as_deref(), Some("char_location"));
766        assert_eq!(cite.cited_text.as_deref(), Some("the quick brown fox"));
767        assert_eq!(cite.document_index, Some(0));
768        assert_eq!(
769            cite.extra.get("start_char_index").and_then(|v| v.as_u64()),
770            Some(12)
771        );
772        assert_eq!(
773            cite.extra.get("end_char_index").and_then(|v| v.as_u64()),
774            Some(31)
775        );
776        // Round-trips without losing the unmodeled fields.
777        assert_eq!(serde_json::to_value(&cite).unwrap(), json);
778    }
779
780    #[test]
781    fn test_text_block_without_citations_defaults_empty() {
782        let json = json!({"type": "text", "text": "no citations"});
783        let block: ContentBlock = serde_json::from_value(json).unwrap();
784        if let ContentBlock::Text(t) = &block {
785            assert!(t.citations.is_empty());
786        } else {
787            panic!("Expected Text variant");
788        }
789        // serialization omits empty citations
790        let reserialized = serde_json::to_value(&block).unwrap();
791        assert!(reserialized.get("citations").is_none());
792    }
793
794    #[test]
795    fn test_missing_type_field_errors() {
796        let json = json!({"text": "no type field"});
797        let result = serde_json::from_value::<ContentBlock>(json);
798        assert!(result.is_err());
799    }
800}