1use serde::{Deserialize, Serialize};
20
21use super::content::*;
22use super::enums::{Channel, Role};
23use crate::hooks::trait_def::PluginResult;
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct Message {
38 #[serde(default = "default_schema_version")]
40 pub schema_version: String,
41
42 pub role: Role,
44
45 #[serde(default)]
47 pub content: Vec<ContentPart>,
48
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub channel: Option<Channel>,
52}
53
54fn default_schema_version() -> String {
55 super::constants::SCHEMA_VERSION.to_string()
56}
57
58impl Message {
59 pub fn text(role: Role, text: impl Into<String>) -> Self {
61 Self {
62 schema_version: super::constants::SCHEMA_VERSION.to_string(),
63 role,
64 content: vec![ContentPart::Text { text: text.into() }],
65 channel: None,
66 }
67 }
68
69 pub fn with_content(role: Role, content: Vec<ContentPart>) -> Self {
75 Self {
76 schema_version: super::constants::SCHEMA_VERSION.to_string(),
77 role,
78 content,
79 channel: None,
80 }
81 }
82
83 pub fn get_text_content(&self) -> String {
87 let mut texts = Vec::new();
88 for part in &self.content {
89 if let ContentPart::Text { text } = part {
90 texts.push(text.as_str());
91 }
92 }
93 texts.join("")
94 }
95
96 pub fn get_thinking_content(&self) -> Option<String> {
98 let mut texts = Vec::new();
99 for part in &self.content {
100 if let ContentPart::Thinking { text } = part {
101 texts.push(text.as_str());
102 }
103 }
104 if texts.is_empty() {
105 None
106 } else {
107 Some(texts.join(""))
108 }
109 }
110
111 pub fn get_tool_calls(&self) -> Vec<&ToolCall> {
113 self.content
114 .iter()
115 .filter_map(|part| match part {
116 ContentPart::ToolCall { content } => Some(content),
117 _ => None,
118 })
119 .collect()
120 }
121
122 pub fn get_tool_results(&self) -> Vec<&ToolResult> {
124 self.content
125 .iter()
126 .filter_map(|part| match part {
127 ContentPart::ToolResult { content } => Some(content),
128 _ => None,
129 })
130 .collect()
131 }
132
133 pub fn is_tool_call(&self) -> bool {
135 self.content
136 .iter()
137 .any(|p| matches!(p, ContentPart::ToolCall { .. }))
138 }
139
140 pub fn is_tool_result(&self) -> bool {
142 self.content
143 .iter()
144 .any(|p| matches!(p, ContentPart::ToolResult { .. }))
145 }
146
147 pub fn get_resources(&self) -> Vec<&Resource> {
149 self.content
150 .iter()
151 .filter_map(|part| match part {
152 ContentPart::Resource { content } => Some(content),
153 _ => None,
154 })
155 .collect()
156 }
157
158 pub fn get_resource_refs(&self) -> Vec<&ResourceReference> {
160 self.content
161 .iter()
162 .filter_map(|part| match part {
163 ContentPart::ResourceRef { content } => Some(content),
164 _ => None,
165 })
166 .collect()
167 }
168
169 pub fn get_all_resource_uris(&self) -> Vec<&str> {
171 self.content
172 .iter()
173 .filter_map(|part| match part {
174 ContentPart::Resource { content } => Some(content.uri.as_str()),
175 ContentPart::ResourceRef { content } => Some(content.uri.as_str()),
176 _ => None,
177 })
178 .collect()
179 }
180
181 pub fn has_resources(&self) -> bool {
183 self.content.iter().any(|p| {
184 matches!(
185 p,
186 ContentPart::Resource { .. } | ContentPart::ResourceRef { .. }
187 )
188 })
189 }
190
191 pub fn get_prompt_requests(&self) -> Vec<&PromptRequest> {
193 self.content
194 .iter()
195 .filter_map(|part| match part {
196 ContentPart::PromptRequest { content } => Some(content),
197 _ => None,
198 })
199 .collect()
200 }
201
202 pub fn get_prompt_results(&self) -> Vec<&PromptResult> {
204 self.content
205 .iter()
206 .filter_map(|part| match part {
207 ContentPart::PromptResult { content } => Some(content),
208 _ => None,
209 })
210 .collect()
211 }
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct MessagePayload {
225 pub message: Message,
227}
228
229crate::impl_plugin_payload!(MessagePayload);
230
231crate::define_hook! {
236 CmfHook, "cmf" => {
242 payload: MessagePayload,
243 result: PluginResult<MessagePayload>,
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use crate::hooks::payload::PluginPayload;
251 use crate::hooks::trait_def::HookTypeDef;
252
253 #[test]
254 fn test_message_text_helper() {
255 let msg = Message::text(Role::User, "What is the weather?");
256 assert_eq!(msg.get_text_content(), "What is the weather?");
257 assert_eq!(msg.role, Role::User);
258 assert_eq!(msg.schema_version, "2.0");
259 }
260
261 #[test]
262 fn test_message_multi_part_text() {
263 let msg = Message {
264 schema_version: "2.0".into(),
265 role: Role::Assistant,
266 content: vec![
267 ContentPart::Text {
268 text: "Hello ".into(),
269 },
270 ContentPart::Text {
271 text: "world!".into(),
272 },
273 ],
274 channel: None,
275 };
276 assert_eq!(msg.get_text_content(), "Hello world!");
277 }
278
279 #[test]
280 fn test_message_thinking_content() {
281 let msg = Message {
282 schema_version: "2.0".into(),
283 role: Role::Assistant,
284 content: vec![
285 ContentPart::Thinking {
286 text: "Let me think...".into(),
287 },
288 ContentPart::Text {
289 text: "Here's my answer.".into(),
290 },
291 ],
292 channel: Some(Channel::Final),
293 };
294 assert_eq!(
295 msg.get_thinking_content(),
296 Some("Let me think...".to_string())
297 );
298 assert_eq!(msg.get_text_content(), "Here's my answer.");
299 }
300
301 #[test]
302 fn test_message_tool_calls() {
303 let msg = Message {
304 schema_version: "2.0".into(),
305 role: Role::Assistant,
306 content: vec![
307 ContentPart::Text {
308 text: "Let me check.".into(),
309 },
310 ContentPart::ToolCall {
311 content: ToolCall {
312 tool_call_id: "tc_001".into(),
313 name: "get_weather".into(),
314 arguments: [("city".to_string(), serde_json::json!("London"))].into(),
315 namespace: None,
316 },
317 },
318 ContentPart::ToolCall {
319 content: ToolCall {
320 tool_call_id: "tc_002".into(),
321 name: "get_time".into(),
322 arguments: [("timezone".to_string(), serde_json::json!("UTC"))].into(),
323 namespace: None,
324 },
325 },
326 ],
327 channel: None,
328 };
329 assert!(msg.is_tool_call());
330 assert!(!msg.is_tool_result());
331 let calls = msg.get_tool_calls();
332 assert_eq!(calls.len(), 2);
333 assert_eq!(calls[0].name, "get_weather");
334 assert_eq!(calls[1].name, "get_time");
335 }
336
337 #[test]
338 fn test_message_tool_results() {
339 let msg = Message {
340 schema_version: "2.0".into(),
341 role: Role::Tool,
342 content: vec![ContentPart::ToolResult {
343 content: ToolResult {
344 tool_call_id: "tc_001".into(),
345 tool_name: "get_weather".into(),
346 content: serde_json::json!({"temp": 20}),
347 is_error: false,
348 },
349 }],
350 channel: None,
351 };
352 assert!(msg.is_tool_result());
353 assert!(!msg.is_tool_call());
354 let results = msg.get_tool_results();
355 assert_eq!(results.len(), 1);
356 assert_eq!(results[0].tool_name, "get_weather");
357 }
358
359 #[test]
360 fn test_message_resources() {
361 let msg = Message {
362 schema_version: "2.0".into(),
363 role: Role::Assistant,
364 content: vec![
365 ContentPart::Resource {
366 content: Resource {
367 resource_request_id: "rr_001".into(),
368 uri: "file:///data.txt".into(),
369 name: Some("Data File".into()),
370 description: None,
371 resource_type: super::super::enums::ResourceType::File,
372 content: Some("file contents".into()),
373 blob: None,
374 mime_type: None,
375 size_bytes: None,
376 annotations: std::collections::HashMap::new(),
377 version: None,
378 },
379 },
380 ContentPart::ResourceRef {
381 content: ResourceReference {
382 resource_request_id: "rr_002".into(),
383 uri: "db://users/42".into(),
384 name: None,
385 resource_type: super::super::enums::ResourceType::Database,
386 range_start: None,
387 range_end: None,
388 selector: None,
389 },
390 },
391 ],
392 channel: None,
393 };
394 assert!(msg.has_resources());
395 assert_eq!(msg.get_resources().len(), 1);
396 assert_eq!(msg.get_resource_refs().len(), 1);
397 let uris = msg.get_all_resource_uris();
398 assert_eq!(uris.len(), 2);
399 assert!(uris.contains(&"file:///data.txt"));
400 assert!(uris.contains(&"db://users/42"));
401 }
402
403 #[test]
404 fn test_message_no_resources() {
405 let msg = Message::text(Role::User, "Hello");
406 assert!(!msg.has_resources());
407 assert!(msg.get_resources().is_empty());
408 }
409
410 #[test]
411 fn test_message_serde_roundtrip() {
412 let msg = Message {
413 schema_version: "2.0".into(),
414 role: Role::Assistant,
415 content: vec![
416 ContentPart::Thinking {
417 text: "Analyzing...".into(),
418 },
419 ContentPart::Text {
420 text: "Here's the answer.".into(),
421 },
422 ContentPart::ToolCall {
423 content: ToolCall {
424 tool_call_id: "tc_001".into(),
425 name: "search".into(),
426 arguments: [("q".to_string(), serde_json::json!("rust"))].into(),
427 namespace: None,
428 },
429 },
430 ],
431 channel: Some(Channel::Final),
432 };
433
434 let json = serde_json::to_string(&msg).unwrap();
435 let deserialized: Message = serde_json::from_str(&json).unwrap();
436
437 assert_eq!(deserialized.role, Role::Assistant);
438 assert_eq!(deserialized.schema_version, "2.0");
439 assert_eq!(deserialized.channel, Some(Channel::Final));
440 assert_eq!(deserialized.content.len(), 3);
441 assert_eq!(deserialized.get_text_content(), "Here's the answer.");
442 assert_eq!(deserialized.get_tool_calls().len(), 1);
443 }
444
445 #[test]
446 fn test_message_payload_as_plugin_payload() {
447 let payload = MessagePayload {
448 message: Message::text(Role::User, "Hello"),
449 };
450
451 let boxed: Box<dyn PluginPayload> = Box::new(payload.clone());
453 let cloned = boxed.clone_boxed();
454
455 let downcasted = cloned
457 .as_any()
458 .downcast_ref::<MessagePayload>()
459 .expect("should downcast to MessagePayload");
460 assert_eq!(downcasted.message.get_text_content(), "Hello");
461 }
462
463 #[test]
464 fn test_cmf_hook_type_def() {
465 assert_eq!(CmfHook::NAME, "cmf");
466 }
467
468 #[test]
469 fn test_message_default_schema_version() {
470 let json = r#"{"role":"user","content":[]}"#;
471 let msg: Message = serde_json::from_str(json).unwrap();
472 assert_eq!(msg.schema_version, "2.0");
473 }
474}