use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "role")]
pub enum Message {
#[serde(rename = "user")]
User(UserMessage),
#[serde(rename = "assistant")]
Assistant(AssistantMessage),
#[serde(rename = "tool_result")]
ToolResult(ToolResultMessage),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserMessage {
pub content: Vec<ContentBlock>,
pub timestamp: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssistantMessage {
pub content: Vec<ContentBlock>,
pub usage: Option<crate::usage::Usage>,
pub stop_reason: StopReason,
pub timestamp: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResultMessage {
pub tool_call_id: String,
pub tool_name: String,
pub content: Vec<ContentBlock>,
pub is_error: bool,
#[serde(default)]
pub details: serde_json::Value,
pub timestamp: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ContentBlock {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "thinking")]
Thinking { text: String },
#[serde(rename = "tool_call")]
ToolCall {
id: String,
name: String,
arguments: serde_json::Value,
},
#[serde(rename = "image")]
Image { media_type: String, data: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum StopReason {
EndTurn,
ToolUse,
MaxTokens,
Error(String),
}
impl Message {
pub fn user(text: impl Into<String>) -> Self {
Message::User(UserMessage {
content: vec![ContentBlock::Text { text: text.into() }],
timestamp: crate::now(),
})
}
pub fn is_user(&self) -> bool {
matches!(self, Message::User(_))
}
pub fn is_assistant(&self) -> bool {
matches!(self, Message::Assistant(_))
}
pub fn is_tool_result(&self) -> bool {
matches!(self, Message::ToolResult(_))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn message_user_round_trip() {
let msg = Message::User(UserMessage {
content: vec![ContentBlock::Text {
text: "Hello".into(),
}],
timestamp: 1700000000,
});
let json = serde_json::to_string(&msg).unwrap();
let restored: Message = serde_json::from_str(&json).unwrap();
assert!(restored.is_user());
if let Message::User(u) = &restored {
assert_eq!(u.timestamp, 1700000000);
assert_eq!(u.content.len(), 1);
} else {
panic!("expected User variant");
}
}
#[test]
fn message_assistant_round_trip() {
let msg = Message::Assistant(AssistantMessage {
content: vec![
ContentBlock::Text {
text: "Sure!".into(),
},
ContentBlock::Thinking {
text: "Let me think...".into(),
},
],
usage: Some(crate::usage::Usage {
input_tokens: 100,
output_tokens: 50,
cache_read_tokens: 0,
cache_write_tokens: 0,
}),
stop_reason: StopReason::EndTurn,
timestamp: 1700000001,
});
let json = serde_json::to_string(&msg).unwrap();
let restored: Message = serde_json::from_str(&json).unwrap();
assert!(restored.is_assistant());
if let Message::Assistant(a) = &restored {
assert_eq!(a.content.len(), 2);
assert_eq!(a.stop_reason, StopReason::EndTurn);
assert_eq!(a.usage.as_ref().unwrap().input_tokens, 100);
} else {
panic!("expected Assistant variant");
}
}
#[test]
fn message_tool_result_round_trip() {
let msg = Message::ToolResult(ToolResultMessage {
tool_call_id: "call_123".into(),
tool_name: "read_file".into(),
content: vec![ContentBlock::Text {
text: "file contents".into(),
}],
is_error: false,
details: serde_json::json!({"path": "/tmp/test"}),
timestamp: 1700000002,
});
let json = serde_json::to_string(&msg).unwrap();
let restored: Message = serde_json::from_str(&json).unwrap();
assert!(restored.is_tool_result());
if let Message::ToolResult(t) = &restored {
assert_eq!(t.tool_call_id, "call_123");
assert_eq!(t.tool_name, "read_file");
assert!(!t.is_error);
} else {
panic!("expected ToolResult variant");
}
}
#[test]
fn tool_call_content_block_round_trip() {
let block = ContentBlock::ToolCall {
id: "tc_1".into(),
name: "bash".into(),
arguments: serde_json::json!({"command": "ls"}),
};
let json = serde_json::to_string(&block).unwrap();
let restored: ContentBlock = serde_json::from_str(&json).unwrap();
if let ContentBlock::ToolCall {
id,
name,
arguments,
} = restored
{
assert_eq!(id, "tc_1");
assert_eq!(name, "bash");
assert_eq!(arguments["command"], "ls");
} else {
panic!("expected ToolCall variant");
}
}
#[test]
fn image_content_block_round_trip() {
let block = ContentBlock::Image {
media_type: "image/png".into(),
data: "iVBORw0KGgo=".into(),
};
let json = serde_json::to_string(&block).unwrap();
let restored: ContentBlock = serde_json::from_str(&json).unwrap();
if let ContentBlock::Image { media_type, data } = restored {
assert_eq!(media_type, "image/png");
assert_eq!(data, "iVBORw0KGgo=");
} else {
panic!("expected Image variant");
}
}
#[test]
fn empty_content_assistant_message_round_trip() {
let msg = Message::Assistant(AssistantMessage {
content: vec![],
usage: None,
stop_reason: StopReason::EndTurn,
timestamp: 1700000000,
});
let json = serde_json::to_string(&msg).unwrap();
let restored: Message = serde_json::from_str(&json).unwrap();
if let Message::Assistant(a) = restored {
assert!(a.content.is_empty());
assert!(a.usage.is_none());
assert_eq!(a.stop_reason, StopReason::EndTurn);
} else {
panic!("expected Assistant variant");
}
}
#[test]
fn tool_result_with_is_error_round_trip() {
let msg = Message::ToolResult(ToolResultMessage {
tool_call_id: "call_err".into(),
tool_name: "bash".into(),
content: vec![ContentBlock::Text {
text: "command not found".into(),
}],
is_error: true,
details: serde_json::Value::Null,
timestamp: 1700000003,
});
let json = serde_json::to_string(&msg).unwrap();
let restored: Message = serde_json::from_str(&json).unwrap();
if let Message::ToolResult(tr) = restored {
assert!(tr.is_error);
assert_eq!(tr.tool_call_id, "call_err");
} else {
panic!("expected ToolResult variant");
}
}
#[test]
fn message_user_helper() {
let msg = Message::user("test prompt");
assert!(msg.is_user());
assert!(!msg.is_assistant());
assert!(!msg.is_tool_result());
if let Message::User(u) = msg {
assert_eq!(u.content.len(), 1);
if let ContentBlock::Text { text } = &u.content[0] {
assert_eq!(text, "test prompt");
} else {
panic!("expected Text block");
}
}
}
#[test]
fn content_block_variant_discrimination() {
let text_json = r#"{"type":"text","text":"hello"}"#;
let thinking_json = r#"{"type":"thinking","text":"hmm"}"#;
let tool_json = r#"{"type":"tool_call","id":"t1","name":"bash","arguments":{}}"#;
let image_json = r#"{"type":"image","media_type":"image/jpeg","data":"abc"}"#;
let text: ContentBlock = serde_json::from_str(text_json).unwrap();
assert!(matches!(text, ContentBlock::Text { .. }));
let thinking: ContentBlock = serde_json::from_str(thinking_json).unwrap();
assert!(matches!(thinking, ContentBlock::Thinking { .. }));
let tool: ContentBlock = serde_json::from_str(tool_json).unwrap();
assert!(matches!(tool, ContentBlock::ToolCall { .. }));
let image: ContentBlock = serde_json::from_str(image_json).unwrap();
assert!(matches!(image, ContentBlock::Image { .. }));
}
#[test]
fn stop_reason_round_trip() {
let reasons = vec![
StopReason::EndTurn,
StopReason::ToolUse,
StopReason::MaxTokens,
StopReason::Error("rate_limit".into()),
];
for reason in reasons {
let json = serde_json::to_string(&reason).unwrap();
let restored: StopReason = serde_json::from_str(&json).unwrap();
assert_eq!(restored, reason);
}
}
}