use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageContent {
pub role: String,
#[serde(deserialize_with = "deserialize_content_blocks")]
pub content: Vec<ContentBlock>,
}
fn deserialize_content_blocks<'de, D>(deserializer: D) -> Result<Vec<ContentBlock>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum ContentRepr {
Text(String),
Blocks(Vec<ContentBlock>),
}
match ContentRepr::deserialize(deserializer)? {
ContentRepr::Text(text) => Ok(vec![ContentBlock::Text(TextBlock { text })]),
ContentRepr::Blocks(blocks) => Ok(blocks),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlock {
Text(TextBlock),
ToolUse(ToolUseBlock),
ToolResult(ToolResultBlock),
Image(ImageBlock),
#[serde(rename = "attachment")]
Attachment(crate::events::attachment::AttachmentBlock),
Thinking(ThinkingBlock),
#[serde(other)]
Unknown,
}
impl ContentBlock {
#[must_use]
pub fn as_text(&self) -> Option<&str> {
match self {
Self::Text(text) => Some(&text.text),
_ => None,
}
}
#[must_use]
pub fn as_tool_use(&self) -> Option<(&str, &str, &JsonValue)> {
match self {
Self::ToolUse(tool) => Some((&tool.id, &tool.name, &tool.input)),
_ => None,
}
}
#[must_use]
pub fn as_thinking(&self) -> Option<&str> {
match self {
Self::Thinking(thinking) => Some(&thinking.thinking),
_ => None,
}
}
#[must_use]
pub fn is_tool_result(&self) -> bool {
matches!(self, Self::ToolResult(_))
}
#[must_use]
pub fn is_attachment(&self) -> bool {
matches!(self, Self::Attachment(_))
}
#[must_use]
pub fn is_thinking(&self) -> bool {
matches!(self, Self::Thinking(_))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextBlock {
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolUseBlock {
pub id: String,
pub name: String,
pub input: JsonValue,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResultBlock {
#[serde(rename = "tool_use_id")]
pub tool_use_id: String,
pub content: JsonValue,
#[serde(
rename = "toolUseResult",
deserialize_with = "crate::events::tool_result::deserialize_tool_use_result_lenient",
default
)]
pub tool_use_result: Option<crate::events::tool_result::ToolUseResult>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThinkingBlock {
pub thinking: String,
pub signature: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageBlock {
pub source: ImageSource,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageSource {
#[serde(rename = "type")]
pub source_type: String,
pub media_type: String,
pub data: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_text_block() {
let json = r#"{
"type": "text",
"text": "Hello world"
}"#;
let block: ContentBlock = serde_json::from_str(json).unwrap();
assert!(matches!(block, ContentBlock::Text(_)));
if let ContentBlock::Text(text) = block {
assert_eq!(text.text, "Hello world");
}
}
#[test]
fn test_parse_tool_use_block() {
let json = r#"{
"type": "tool_use",
"id": "toolu_abc123",
"name": "Read",
"input": {
"file_path": "/test/file.rs"
}
}"#;
let block: ContentBlock = serde_json::from_str(json).unwrap();
assert!(matches!(block, ContentBlock::ToolUse(_)));
if let ContentBlock::ToolUse(tool) = block {
assert_eq!(tool.id, "toolu_abc123");
assert_eq!(tool.name, "Read");
assert_eq!(tool.input["file_path"], "/test/file.rs");
}
}
#[test]
fn test_parse_tool_result_block() {
let json = r#"{
"type": "tool_result",
"tool_use_id": "toolu_abc123",
"content": "File contents here"
}"#;
let block: ContentBlock = serde_json::from_str(json).unwrap();
assert!(matches!(block, ContentBlock::ToolResult(_)));
assert!(block.is_tool_result());
if let ContentBlock::ToolResult(result) = block {
assert_eq!(result.tool_use_id, "toolu_abc123");
assert_eq!(result.content, "File contents here");
}
}
#[test]
fn test_parse_image_block() {
let json = r#"{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "iVBORw0KGgo="
}
}"#;
let block: ContentBlock = serde_json::from_str(json).unwrap();
assert!(matches!(block, ContentBlock::Image(_)));
if let ContentBlock::Image(image) = block {
assert_eq!(image.source.source_type, "base64");
assert_eq!(image.source.media_type, "image/png");
assert_eq!(image.source.data, "iVBORw0KGgo=");
}
}
#[test]
fn test_content_block_helpers() {
let text_block = ContentBlock::Text(TextBlock {
text: "Test".to_string(),
});
assert_eq!(text_block.as_text(), Some("Test"));
assert!(!text_block.is_tool_result());
let tool_block = ContentBlock::ToolUse(ToolUseBlock {
id: "tool-1".to_string(),
name: "Read".to_string(),
input: serde_json::json!({}),
});
let tool_use = tool_block.as_tool_use();
assert!(tool_use.is_some());
let (id, name, _) = tool_use.unwrap();
assert_eq!(id, "tool-1");
assert_eq!(name, "Read");
}
#[test]
fn test_parse_thinking_block() {
let json = r#"{
"type": "thinking",
"thinking": "Let me analyze this request carefully...",
"signature": "ErQYCkYICxgCKkA1FuCoAqSF..."
}"#;
let block: ContentBlock = serde_json::from_str(json).unwrap();
assert!(matches!(block, ContentBlock::Thinking(_)));
assert!(block.is_thinking());
if let ContentBlock::Thinking(thinking) = block {
assert_eq!(thinking.thinking, "Let me analyze this request carefully...");
assert!(thinking.signature.is_some());
assert_eq!(thinking.signature.unwrap(), "ErQYCkYICxgCKkA1FuCoAqSF...");
}
}
#[test]
fn test_parse_thinking_block_without_signature() {
let json = r#"{
"type": "thinking",
"thinking": "Analyzing the problem..."
}"#;
let block: ContentBlock = serde_json::from_str(json).unwrap();
assert!(matches!(block, ContentBlock::Thinking(_)));
if let ContentBlock::Thinking(thinking) = block {
assert_eq!(thinking.thinking, "Analyzing the problem...");
assert!(thinking.signature.is_none());
}
}
#[test]
fn test_content_block_as_thinking() {
let thinking_block = ContentBlock::Thinking(ThinkingBlock {
thinking: "Test reasoning".to_string(),
signature: Some("sig123".to_string()),
});
assert_eq!(thinking_block.as_thinking(), Some("Test reasoning"));
assert!(thinking_block.is_thinking());
let text_block = ContentBlock::Text(TextBlock {
text: "Test".to_string(),
});
assert_eq!(text_block.as_thinking(), None);
assert!(!text_block.is_thinking());
}
#[test]
fn test_message_content() {
let json = r#"{
"role": "assistant",
"content": [
{
"type": "text",
"text": "Let me read the file"
},
{
"type": "tool_use",
"id": "tool-123",
"name": "Read",
"input": {"file_path": "/test.rs"}
}
]
}"#;
let message: MessageContent = serde_json::from_str(json).unwrap();
assert_eq!(message.role, "assistant");
assert_eq!(message.content.len(), 2);
assert!(matches!(message.content[0], ContentBlock::Text(_)));
assert!(matches!(message.content[1], ContentBlock::ToolUse(_)));
}
#[test]
fn test_message_with_thinking() {
let json = r#"{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "I need to analyze this carefully..."
},
{
"type": "text",
"text": "Based on my analysis..."
}
]
}"#;
let message: MessageContent = serde_json::from_str(json).unwrap();
assert_eq!(message.role, "assistant");
assert_eq!(message.content.len(), 2);
assert!(matches!(message.content[0], ContentBlock::Thinking(_)));
assert!(matches!(message.content[1], ContentBlock::Text(_)));
assert!(message.content[0].is_thinking());
assert_eq!(
message.content[0].as_thinking(),
Some("I need to analyze this carefully...")
);
}
}