use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::types::{FinishReason, MessageId, Role, ToolCallId};
use crate::usage::Usage;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlock {
Text {
text: String,
},
Image {
media_type: String,
data: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AssistantBlock {
Text {
text: String,
},
Reasoning {
text: String,
#[serde(skip_serializing_if = "Option::is_none")]
signature: Option<String>,
},
ToolCall {
id: ToolCallId,
name: String,
arguments: serde_json::Value,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum UserContent {
Text(String),
Blocks(Vec<ContentBlock>),
}
impl UserContent {
pub fn into_blocks(self) -> Vec<ContentBlock> {
match self {
Self::Text(text) => vec![ContentBlock::Text { text }],
Self::Blocks(blocks) => blocks,
}
}
pub fn text(&self) -> String {
match self {
Self::Text(text) => text.clone(),
Self::Blocks(blocks) => blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n"),
}
}
}
impl From<String> for UserContent {
fn from(text: String) -> Self {
Self::Text(text)
}
}
impl From<&str> for UserContent {
fn from(text: &str) -> Self {
Self::Text(text.to_owned())
}
}
impl From<Vec<ContentBlock>> for UserContent {
fn from(blocks: Vec<ContentBlock>) -> Self {
Self::Blocks(blocks)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UserMessage {
pub id: MessageId,
pub content: UserContent,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AssistantMessage {
pub id: MessageId,
pub content: Vec<AssistantBlock>,
pub model: String,
pub provider: String,
pub finish_reason: FinishReason,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolResultMessage {
pub id: MessageId,
pub tool_call_id: ToolCallId,
pub tool_name: String,
pub content: Vec<ContentBlock>,
pub is_error: bool,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "role", rename_all = "snake_case")]
pub enum Message {
User(UserMessage),
Assistant(AssistantMessage),
ToolResult(ToolResultMessage),
}
impl Message {
pub fn role(&self) -> Role {
match self {
Self::User(_) => Role::User,
Self::Assistant(_) => Role::Assistant,
Self::ToolResult(_) => Role::Tool,
}
}
pub fn id(&self) -> &MessageId {
match self {
Self::User(m) => &m.id,
Self::Assistant(m) => &m.id,
Self::ToolResult(m) => &m.id,
}
}
pub fn timestamp(&self) -> DateTime<Utc> {
match self {
Self::User(m) => m.timestamp,
Self::Assistant(m) => m.timestamp,
Self::ToolResult(m) => m.timestamp,
}
}
}
impl Message {
pub fn user(content: impl Into<UserContent>) -> Self {
Self::User(UserMessage {
id: MessageId::new(),
content: content.into(),
timestamp: Utc::now(),
})
}
pub fn assistant(text: impl Into<String>) -> Self {
Self::Assistant(AssistantMessage {
id: MessageId::new(),
content: vec![AssistantBlock::Text {
text: text.into(),
}],
model: String::new(),
provider: String::new(),
finish_reason: FinishReason::Stop,
usage: None,
timestamp: Utc::now(),
})
}
pub fn tool_result(
tool_call_id: ToolCallId,
tool_name: impl Into<String>,
content: impl Into<String>,
is_error: bool,
) -> Self {
Self::ToolResult(ToolResultMessage {
id: MessageId::new(),
tool_call_id,
tool_name: tool_name.into(),
content: vec![ContentBlock::Text {
text: content.into(),
}],
is_error,
timestamp: Utc::now(),
})
}
}
impl AssistantMessage {
pub fn text(&self) -> String {
self.content
.iter()
.filter_map(|b| match b {
AssistantBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("")
}
pub fn reasoning(&self) -> String {
self.content
.iter()
.filter_map(|b| match b {
AssistantBlock::Reasoning { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("")
}
pub fn tool_calls(&self) -> Vec<&AssistantBlock> {
self.content
.iter()
.filter(|b| matches!(b, AssistantBlock::ToolCall { .. }))
.collect()
}
pub fn has_tool_calls(&self) -> bool {
self.content
.iter()
.any(|b| matches!(b, AssistantBlock::ToolCall { .. }))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_content_block_text_serde() {
let block = ContentBlock::Text {
text: "hello".into(),
};
let json = serde_json::to_string(&block).unwrap();
assert!(json.contains(r#""type":"text""#));
let restored: ContentBlock = serde_json::from_str(&json).unwrap();
assert_eq!(block, restored);
}
#[test]
fn test_content_block_image_serde() {
let block = ContentBlock::Image {
media_type: "image/png".into(),
data: "iVBOR...".into(),
};
let json = serde_json::to_string(&block).unwrap();
assert!(json.contains(r#""type":"image""#));
let restored: ContentBlock = serde_json::from_str(&json).unwrap();
assert_eq!(block, restored);
}
#[test]
fn test_assistant_block_text_serde() {
let block = AssistantBlock::Text {
text: "hi".into(),
};
let json = serde_json::to_string(&block).unwrap();
assert!(json.contains(r#""type":"text""#));
let restored: AssistantBlock = serde_json::from_str(&json).unwrap();
assert_eq!(block, restored);
}
#[test]
fn test_assistant_block_reasoning_serde() {
let block = AssistantBlock::Reasoning {
text: "let me think...".into(),
signature: Some("sig123".into()),
};
let json = serde_json::to_string(&block).unwrap();
assert!(json.contains(r#""type":"reasoning""#));
let restored: AssistantBlock = serde_json::from_str(&json).unwrap();
assert_eq!(block, restored);
}
#[test]
fn test_assistant_block_reasoning_no_signature() {
let block = AssistantBlock::Reasoning {
text: "thinking".into(),
signature: None,
};
let json = serde_json::to_string(&block).unwrap();
assert!(!json.contains("signature"));
}
#[test]
fn test_assistant_block_tool_call_serde() {
let block = AssistantBlock::ToolCall {
id: ToolCallId::new("call_123"),
name: "read_file".into(),
arguments: serde_json::json!({"path": "/tmp/test.rs"}),
};
let json = serde_json::to_string(&block).unwrap();
assert!(json.contains(r#""type":"tool_call""#));
let restored: AssistantBlock = serde_json::from_str(&json).unwrap();
assert_eq!(block, restored);
}
#[test]
fn test_user_content_text() {
let content = UserContent::from("hello");
assert_eq!(content.text(), "hello");
let blocks = content.into_blocks();
assert_eq!(blocks.len(), 1);
assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "hello"));
}
#[test]
fn test_user_content_blocks() {
let content = UserContent::Blocks(vec![
ContentBlock::Text {
text: "look at this".into(),
},
ContentBlock::Image {
media_type: "image/png".into(),
data: "base64data".into(),
},
]);
assert_eq!(content.text(), "look at this");
}
#[test]
fn test_user_content_serde_text() {
let content = UserContent::Text("hello".into());
let json = serde_json::to_string(&content).unwrap();
assert_eq!(json, r#""hello""#);
let restored: UserContent = serde_json::from_str(&json).unwrap();
assert_eq!(content, restored);
}
#[test]
fn test_user_content_serde_blocks() {
let content = UserContent::Blocks(vec![ContentBlock::Text {
text: "hi".into(),
}]);
let json = serde_json::to_string(&content).unwrap();
let restored: UserContent = serde_json::from_str(&json).unwrap();
assert_eq!(content, restored);
}
#[test]
fn test_message_user_constructor() {
let msg = Message::user("hello");
assert_eq!(msg.role(), Role::User);
if let Message::User(u) = &msg {
assert_eq!(u.content.text(), "hello");
} else {
panic!("expected User");
}
}
#[test]
fn test_message_assistant_constructor() {
let msg = Message::assistant("hi there");
assert_eq!(msg.role(), Role::Assistant);
if let Message::Assistant(a) = &msg {
assert_eq!(a.text(), "hi there");
assert_eq!(a.finish_reason, FinishReason::Stop);
} else {
panic!("expected Assistant");
}
}
#[test]
fn test_message_tool_result_constructor() {
let msg = Message::tool_result(
ToolCallId::new("call_1"),
"read_file",
"file contents here",
false,
);
assert_eq!(msg.role(), Role::Tool);
if let Message::ToolResult(t) = &msg {
assert_eq!(t.tool_call_id, ToolCallId::new("call_1"));
assert_eq!(t.tool_name, "read_file");
assert!(!t.is_error);
} else {
panic!("expected ToolResult");
}
}
#[test]
fn test_message_tool_result_error() {
let msg = Message::tool_result(
ToolCallId::new("call_2"),
"write_file",
"permission denied",
true,
);
if let Message::ToolResult(t) = &msg {
assert!(t.is_error);
} else {
panic!("expected ToolResult");
}
}
#[test]
fn test_message_id_accessor() {
let msg = Message::user("test");
let id = msg.id().clone();
assert_eq!(msg.id(), &id);
}
#[test]
fn test_message_timestamp_accessor() {
let before = Utc::now();
let msg = Message::user("test");
let after = Utc::now();
assert!(msg.timestamp() >= before);
assert!(msg.timestamp() <= after);
}
#[test]
fn test_assistant_message_text() {
let msg = AssistantMessage {
id: MessageId::new(),
content: vec![
AssistantBlock::Reasoning {
text: "hmm".into(),
signature: None,
},
AssistantBlock::Text {
text: "Hello ".into(),
},
AssistantBlock::Text {
text: "World".into(),
},
],
model: "gpt-4o".into(),
provider: "openai".into(),
finish_reason: FinishReason::Stop,
usage: None,
timestamp: Utc::now(),
};
assert_eq!(msg.text(), "Hello World");
assert_eq!(msg.reasoning(), "hmm");
}
#[test]
fn test_assistant_message_tool_calls() {
let msg = AssistantMessage {
id: MessageId::new(),
content: vec![
AssistantBlock::Text {
text: "I'll read that file.".into(),
},
AssistantBlock::ToolCall {
id: ToolCallId::new("call_1"),
name: "read_file".into(),
arguments: serde_json::json!({"path": "foo.rs"}),
},
AssistantBlock::ToolCall {
id: ToolCallId::new("call_2"),
name: "grep".into(),
arguments: serde_json::json!({"pattern": "fn main"}),
},
],
model: "claude-sonnet-4-20250514".into(),
provider: "anthropic".into(),
finish_reason: FinishReason::ToolCalls,
usage: None,
timestamp: Utc::now(),
};
assert!(msg.has_tool_calls());
assert_eq!(msg.tool_calls().len(), 2);
}
#[test]
fn test_assistant_message_no_tool_calls() {
let msg = AssistantMessage {
id: MessageId::new(),
content: vec![AssistantBlock::Text {
text: "done".into(),
}],
model: String::new(),
provider: String::new(),
finish_reason: FinishReason::Stop,
usage: None,
timestamp: Utc::now(),
};
assert!(!msg.has_tool_calls());
assert!(msg.tool_calls().is_empty());
}
#[test]
fn test_message_serde_roundtrip_user() {
let msg = Message::user("hello world");
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains(r#""role":"user""#));
let restored: Message = serde_json::from_str(&json).unwrap();
assert_eq!(msg.role(), restored.role());
}
#[test]
fn test_message_serde_roundtrip_assistant() {
let msg = Message::assistant("reply");
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains(r#""role":"assistant""#));
let restored: Message = serde_json::from_str(&json).unwrap();
assert_eq!(msg.role(), restored.role());
}
#[test]
fn test_message_serde_roundtrip_tool_result() {
let msg = Message::tool_result(
ToolCallId::new("call_x"),
"bash",
"exit code 0",
false,
);
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains(r#""role":"tool_result""#));
let restored: Message = serde_json::from_str(&json).unwrap();
assert_eq!(msg.role(), restored.role());
}
}