1use std::collections::HashMap;
6
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum Role {
16 System,
20 User,
22 Assistant,
24 Tool,
26}
27
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct ExternalActor {
36 pub actor_id: String,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub actor_name: Option<String>,
41 pub source: String,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub metadata: Option<HashMap<String, String>>,
46}
47
48impl ExternalActor {
49 pub fn new(source: impl Into<String>, actor_id: impl Into<String>) -> Self {
51 Self {
52 actor_id: actor_id.into(),
53 actor_name: None,
54 source: source.into(),
55 metadata: None,
56 }
57 }
58
59 pub fn name(mut self, name: impl Into<String>) -> Self {
61 self.actor_name = Some(name.into());
62 self
63 }
64
65 pub fn display_label(&self) -> &str {
67 self.actor_name.as_deref().unwrap_or(&self.actor_id)
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73pub struct ToolCall {
74 pub id: String,
76 pub name: String,
79 #[serde(default)]
82 pub arguments: serde_json::Value,
83}
84
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87pub struct TextContentPart {
88 pub text: String,
90}
91
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub struct ImageContentPart {
96 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub url: Option<String>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub base64: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub media_type: Option<String>,
107}
108
109impl ImageContentPart {
110 pub fn from_url(url: impl Into<String>) -> Self {
112 Self {
113 url: Some(url.into()),
114 base64: None,
115 media_type: None,
116 }
117 }
118
119 pub fn from_base64(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
121 Self {
122 url: None,
123 base64: Some(base64.into()),
124 media_type: Some(media_type.into()),
125 }
126 }
127}
128
129impl From<String> for Message {
133 fn from(text: String) -> Self {
134 Message::user(text)
135 }
136}
137
138impl From<&str> for Message {
139 fn from(text: &str) -> Self {
140 Message::user(text)
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148#[serde(tag = "type", rename_all = "snake_case")]
149pub enum ContentPart {
150 Text(TextContentPart),
152 Image(ImageContentPart),
154}
155
156impl ContentPart {
157 pub fn text(text: impl Into<String>) -> Self {
159 ContentPart::Text(TextContentPart { text: text.into() })
160 }
161
162 pub fn image_url(url: impl Into<String>) -> Self {
164 ContentPart::Image(ImageContentPart::from_url(url))
165 }
166
167 pub fn image_base64(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
169 ContentPart::Image(ImageContentPart::from_base64(base64, media_type))
170 }
171
172 pub fn as_text(&self) -> Option<&str> {
174 match self {
175 ContentPart::Text(part) => Some(&part.text),
176 ContentPart::Image(_) => None,
177 }
178 }
179}
180
181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187pub struct Message {
188 pub role: Role,
190 #[serde(default, skip_serializing_if = "Vec::is_empty")]
193 pub content: Vec<ContentPart>,
194 #[serde(default, skip_serializing_if = "Vec::is_empty")]
196 pub tool_calls: Vec<ToolCall>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub tool_call_id: Option<String>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub thinking: Option<String>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub thinking_signature: Option<String>,
212 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
218 pub metadata: serde_json::Value,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub external_actor: Option<ExternalActor>,
225}
226
227impl Message {
228 fn text_content(text: impl Into<String>) -> Vec<ContentPart> {
229 let text = text.into();
230 if text.is_empty() {
231 Vec::new()
232 } else {
233 vec![ContentPart::text(text)]
234 }
235 }
236
237 fn new(role: Role, content: Vec<ContentPart>) -> Self {
238 Self {
239 role,
240 content,
241 tool_calls: Vec::new(),
242 tool_call_id: None,
243 thinking: None,
244 thinking_signature: None,
245 metadata: serde_json::Value::Null,
246 external_actor: None,
247 }
248 }
249
250 pub fn user(text: impl Into<String>) -> Self {
252 Self::new(Role::User, Self::text_content(text))
253 }
254
255 pub fn user_multimodal(content: Vec<ContentPart>) -> Self {
257 Self::new(Role::User, content)
258 }
259
260 pub fn assistant(text: impl Into<String>) -> Self {
262 Self::new(Role::Assistant, Self::text_content(text))
263 }
264
265 pub fn assistant_with_calls(text: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
268 Self {
269 tool_calls,
270 ..Self::new(Role::Assistant, Self::text_content(text))
271 }
272 }
273
274 pub fn tool_result(call_id: impl Into<String>, text: impl Into<String>) -> Self {
278 Self {
279 tool_call_id: Some(call_id.into()),
280 ..Self::new(Role::Tool, Self::text_content(text))
281 }
282 }
283
284 pub fn tool_result_with_parts(
288 call_id: impl Into<String>,
289 text: impl Into<String>,
290 parts: impl IntoIterator<Item = ContentPart>,
291 ) -> Self {
292 let mut content = Self::text_content(text);
293 content.extend(parts);
294 Self {
295 tool_call_id: Some(call_id.into()),
296 ..Self::new(Role::Tool, content)
297 }
298 }
299
300 pub fn with_thinking(mut self, thinking: impl Into<String>, signature: Option<String>) -> Self {
302 self.thinking = Some(thinking.into());
303 self.thinking_signature = signature;
304 self
305 }
306
307 pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
309 self.metadata = metadata;
310 self
311 }
312
313 pub fn with_external_actor(mut self, actor: ExternalActor) -> Self {
315 self.external_actor = Some(actor);
316 self
317 }
318
319 pub fn text(&self) -> String {
323 self.content
324 .iter()
325 .filter_map(ContentPart::as_text)
326 .collect::<Vec<_>>()
327 .join("")
328 }
329
330 pub fn is_text_only(&self) -> bool {
334 self.content
335 .iter()
336 .all(|part| matches!(part, ContentPart::Text(_)))
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[test]
345 fn text_constructors_round_trip_through_text() {
346 assert_eq!(Message::user("hi").text(), "hi");
347 assert_eq!(Message::assistant("").text(), "");
348 assert!(Message::assistant("").content.is_empty());
349 }
350
351 #[test]
352 fn multimodal_message_mixes_text_and_image() {
353 let message = Message::user_multimodal(vec![
354 ContentPart::text("what is this?"),
355 ContentPart::image_url("https://example.com/cat.png"),
356 ]);
357 assert_eq!(message.text(), "what is this?");
358 assert!(!message.is_text_only());
359 }
360
361 #[test]
362 fn serde_roundtrip_preserves_content_parts() {
363 let message = Message::user_multimodal(vec![
364 ContentPart::text("a"),
365 ContentPart::image_base64("Zm9v", "image/png"),
366 ]);
367 let json = serde_json::to_string(&message).unwrap();
368 let back: Message = serde_json::from_str(&json).unwrap();
369 assert_eq!(message, back);
370 }
371
372 #[test]
373 fn thinking_and_metadata_round_trip() {
374 let message = Message::assistant("done")
375 .with_thinking("let me consider…", Some("sig-abc".into()))
376 .with_metadata(serde_json::json!({"phase": "final"}));
377 let json = serde_json::to_string(&message).unwrap();
378 let back: Message = serde_json::from_str(&json).unwrap();
379 assert_eq!(message, back);
380 assert_eq!(back.thinking.as_deref(), Some("let me consider…"));
381 assert_eq!(back.thinking_signature.as_deref(), Some("sig-abc"));
382 assert_eq!(back.metadata["phase"], "final");
383 }
384
385 #[test]
386 fn external_actor_round_trips_and_uses_name_as_label() {
387 let message = Message::user("hello")
388 .with_external_actor(ExternalActor::new("slack", "U123").name("Alice"));
389 let json = serde_json::to_string(&message).unwrap();
390 let back: Message = serde_json::from_str(&json).unwrap();
391
392 assert_eq!(back, message);
393 assert_eq!(
394 back.external_actor.as_ref().unwrap().display_label(),
395 "Alice"
396 );
397 }
398
399 #[test]
400 fn plain_messages_omit_the_new_fields_from_json() {
401 let json = serde_json::to_string(&Message::user("hi")).unwrap();
404 assert!(!json.contains("thinking"));
405 assert!(!json.contains("metadata"));
406 assert!(!json.contains("external_actor"));
407 let back: Message =
408 serde_json::from_str(r#"{"role":"user","content":[{"type":"text","text":"hi"}]}"#)
409 .unwrap();
410 assert_eq!(back, Message::user("hi"));
411 }
412}