use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
System,
User,
Assistant,
Tool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExternalActor {
pub actor_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor_name: Option<String>,
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, String>>,
}
impl ExternalActor {
pub fn new(source: impl Into<String>, actor_id: impl Into<String>) -> Self {
Self {
actor_id: actor_id.into(),
actor_name: None,
source: source.into(),
metadata: None,
}
}
pub fn name(mut self, name: impl Into<String>) -> Self {
self.actor_name = Some(name.into());
self
}
pub fn display_label(&self) -> &str {
self.actor_name.as_deref().unwrap_or(&self.actor_id)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
#[serde(default)]
pub arguments: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TextContentPart {
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ImageContentPart {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base64: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub media_type: Option<String>,
}
impl ImageContentPart {
pub fn from_url(url: impl Into<String>) -> Self {
Self {
url: Some(url.into()),
base64: None,
media_type: None,
}
}
pub fn from_base64(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
Self {
url: None,
base64: Some(base64.into()),
media_type: Some(media_type.into()),
}
}
}
impl From<String> for Message {
fn from(text: String) -> Self {
Message::user(text)
}
}
impl From<&str> for Message {
fn from(text: &str) -> Self {
Message::user(text)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentPart {
Text(TextContentPart),
Image(ImageContentPart),
}
impl ContentPart {
pub fn text(text: impl Into<String>) -> Self {
ContentPart::Text(TextContentPart { text: text.into() })
}
pub fn image_url(url: impl Into<String>) -> Self {
ContentPart::Image(ImageContentPart::from_url(url))
}
pub fn image_base64(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
ContentPart::Image(ImageContentPart::from_base64(base64, media_type))
}
pub fn as_text(&self) -> Option<&str> {
match self {
ContentPart::Text(part) => Some(&part.text),
ContentPart::Image(_) => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Message {
pub role: Role,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub content: Vec<ContentPart>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_calls: Vec<ToolCall>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking_signature: Option<String>,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub metadata: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub external_actor: Option<ExternalActor>,
}
impl Message {
fn text_content(text: impl Into<String>) -> Vec<ContentPart> {
let text = text.into();
if text.is_empty() {
Vec::new()
} else {
vec![ContentPart::text(text)]
}
}
fn new(role: Role, content: Vec<ContentPart>) -> Self {
Self {
role,
content,
tool_calls: Vec::new(),
tool_call_id: None,
thinking: None,
thinking_signature: None,
metadata: serde_json::Value::Null,
external_actor: None,
}
}
pub fn user(text: impl Into<String>) -> Self {
Self::new(Role::User, Self::text_content(text))
}
pub fn user_multimodal(content: Vec<ContentPart>) -> Self {
Self::new(Role::User, content)
}
pub fn assistant(text: impl Into<String>) -> Self {
Self::new(Role::Assistant, Self::text_content(text))
}
pub fn assistant_with_calls(text: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
Self {
tool_calls,
..Self::new(Role::Assistant, Self::text_content(text))
}
}
pub fn tool_result(call_id: impl Into<String>, text: impl Into<String>) -> Self {
Self {
tool_call_id: Some(call_id.into()),
..Self::new(Role::Tool, Self::text_content(text))
}
}
pub fn tool_result_with_parts(
call_id: impl Into<String>,
text: impl Into<String>,
parts: impl IntoIterator<Item = ContentPart>,
) -> Self {
let mut content = Self::text_content(text);
content.extend(parts);
Self {
tool_call_id: Some(call_id.into()),
..Self::new(Role::Tool, content)
}
}
pub fn with_thinking(mut self, thinking: impl Into<String>, signature: Option<String>) -> Self {
self.thinking = Some(thinking.into());
self.thinking_signature = signature;
self
}
pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
self.metadata = metadata;
self
}
pub fn with_external_actor(mut self, actor: ExternalActor) -> Self {
self.external_actor = Some(actor);
self
}
pub fn text(&self) -> String {
self.content
.iter()
.filter_map(ContentPart::as_text)
.collect::<Vec<_>>()
.join("")
}
pub fn is_text_only(&self) -> bool {
self.content
.iter()
.all(|part| matches!(part, ContentPart::Text(_)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_constructors_round_trip_through_text() {
assert_eq!(Message::user("hi").text(), "hi");
assert_eq!(Message::assistant("").text(), "");
assert!(Message::assistant("").content.is_empty());
}
#[test]
fn multimodal_message_mixes_text_and_image() {
let message = Message::user_multimodal(vec![
ContentPart::text("what is this?"),
ContentPart::image_url("https://example.com/cat.png"),
]);
assert_eq!(message.text(), "what is this?");
assert!(!message.is_text_only());
}
#[test]
fn serde_roundtrip_preserves_content_parts() {
let message = Message::user_multimodal(vec![
ContentPart::text("a"),
ContentPart::image_base64("Zm9v", "image/png"),
]);
let json = serde_json::to_string(&message).unwrap();
let back: Message = serde_json::from_str(&json).unwrap();
assert_eq!(message, back);
}
#[test]
fn thinking_and_metadata_round_trip() {
let message = Message::assistant("done")
.with_thinking("let me consider…", Some("sig-abc".into()))
.with_metadata(serde_json::json!({"phase": "final"}));
let json = serde_json::to_string(&message).unwrap();
let back: Message = serde_json::from_str(&json).unwrap();
assert_eq!(message, back);
assert_eq!(back.thinking.as_deref(), Some("let me consider…"));
assert_eq!(back.thinking_signature.as_deref(), Some("sig-abc"));
assert_eq!(back.metadata["phase"], "final");
}
#[test]
fn external_actor_round_trips_and_uses_name_as_label() {
let message = Message::user("hello")
.with_external_actor(ExternalActor::new("slack", "U123").name("Alice"));
let json = serde_json::to_string(&message).unwrap();
let back: Message = serde_json::from_str(&json).unwrap();
assert_eq!(back, message);
assert_eq!(
back.external_actor.as_ref().unwrap().display_label(),
"Alice"
);
}
#[test]
fn plain_messages_omit_the_new_fields_from_json() {
let json = serde_json::to_string(&Message::user("hi")).unwrap();
assert!(!json.contains("thinking"));
assert!(!json.contains("metadata"));
assert!(!json.contains("external_actor"));
let back: Message =
serde_json::from_str(r#"{"role":"user","content":[{"type":"text","text":"hi"}]}"#)
.unwrap();
assert_eq!(back, Message::user("hi"));
}
}