Skip to main content

oxi_ai/
messages.rs

1//! Message types for oxi-ai
2
3use crate::Api;
4use serde::{Deserialize, Serialize};
5use serde_json::Value as JsonValue;
6
7/// Text content block
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct TextContent {
10    /// Discriminator for untagged deserialization.
11    #[serde(rename = "type")]
12    pub content_type: TextContentType,
13    /// The text payload.
14    pub text: String,
15    /// Optional signature carrying provider-specific metadata (e.g. OpenAI message ID, phase).
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub text_signature: Option<String>,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename = "text")]
22/// TextContentType.
23pub enum TextContentType {
24    /// text variant.
25    Text,
26}
27
28impl TextContent {
29    /// Create a plain text content block.
30    pub fn new(text: impl Into<String>) -> Self {
31        Self {
32            content_type: TextContentType::Text,
33            text: text.into(),
34            text_signature: None,
35        }
36    }
37
38    /// Create a text content block with a signature.
39    pub fn with_signature(text: impl Into<String>, signature: impl Into<String>) -> Self {
40        Self {
41            content_type: TextContentType::Text,
42            text: text.into(),
43            text_signature: Some(signature.into()),
44        }
45    }
46}
47
48/// Thinking content block (extended thinking / chain-of-thought output).
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ThinkingContent {
51    /// Discriminator for untagged deserialization.
52    #[serde(rename = "type")]
53    pub content_type: ThinkingContentType,
54    /// The raw thinking text from the model.
55    pub thinking: String,
56    /// Optional provider-specific signature for the thinking block.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub thinking_signature: Option<String>,
59    /// Whether the thinking content was redacted by the provider.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub redacted: Option<bool>,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename = "thinking")]
66/// ThinkingContentType.
67pub enum ThinkingContentType {
68    /// thinking variant.
69    Thinking,
70}
71
72impl ThinkingContent {
73    /// Create a new thinking content block.
74    pub fn new(thinking: impl Into<String>) -> Self {
75        Self {
76            content_type: ThinkingContentType::Thinking,
77            thinking: thinking.into(),
78            thinking_signature: None,
79            redacted: None,
80        }
81    }
82}
83
84/// Image content block (base64-encoded).
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct ImageContent {
87    /// Discriminator for untagged deserialization.
88    #[serde(rename = "type")]
89    pub content_type: ImageContentType,
90    /// Base64-encoded image data.
91    pub data: String,
92    /// MIME type of the image (e.g. `"image/png"`).
93    pub mime_type: String,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename = "image")]
98/// ImageContentType.
99pub enum ImageContentType {
100    /// image variant.
101    Image,
102}
103
104impl ImageContent {
105    /// Create a new image content block.
106    pub fn new(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
107        Self {
108            content_type: ImageContentType::Image,
109            data: data.into(),
110            mime_type: mime_type.into(),
111        }
112    }
113}
114
115/// Tool call content block emitted by the model.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct ToolCall {
118    /// Discriminator for untagged deserialization.
119    #[serde(rename = "type")]
120    pub content_type: ToolCallType,
121    /// Provider-assigned tool call identifier.
122    pub id: String,
123    /// Name of the tool being invoked.
124    pub name: String,
125    /// JSON arguments for the tool invocation.
126    pub arguments: JsonValue,
127    /// Optional provider-specific signature linking to a thinking block.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub thought_signature: Option<String>,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename = "toolCall")]
134/// ToolCallType.
135pub enum ToolCallType {
136    /// tool call variant.
137    ToolCall,
138}
139
140impl ToolCall {
141    /// Create a new tool call.
142    pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: JsonValue) -> Self {
143        Self {
144            content_type: ToolCallType::ToolCall,
145            id: id.into(),
146            name: name.into(),
147            arguments,
148            thought_signature: None,
149        }
150    }
151}
152
153/// Content block union (untagged for flexibility).
154///
155/// Represents a single piece of content inside a message – text, thinking,
156/// an image, a tool call, or an unrecognized block kept as raw JSON.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158#[serde(untagged)]
159pub enum ContentBlock {
160    /// Plain text content.
161    Text(TextContent),
162    /// Extended thinking / chain-of-thought output.
163    Thinking(ThinkingContent),
164    /// Base64-encoded image.
165    Image(ImageContent),
166    /// Tool invocation requested by the model.
167    ToolCall(ToolCall),
168    /// Unrecognised block preserved as raw JSON.
169    Unknown(JsonValue),
170}
171
172impl ContentBlock {
173    /// Returns the inner text if this is a `Text` block.
174    pub fn as_text(&self) -> Option<&str> {
175        match self {
176            ContentBlock::Text(t) => Some(&t.text),
177            _ => None,
178        }
179    }
180
181    /// Returns a reference to the `ToolCall` if this is a `ToolCall` block.
182    pub fn as_tool_call(&self) -> Option<&ToolCall> {
183        match self {
184            ContentBlock::ToolCall(t) => Some(t),
185            _ => None,
186        }
187    }
188
189    /// Returns a reference to the `ThinkingContent` if this is a `Thinking` block.
190    pub fn as_thinking(&self) -> Option<&ThinkingContent> {
191        match self {
192            ContentBlock::Thinking(t) => Some(t),
193            _ => None,
194        }
195    }
196}
197
198/// User message sent to the model.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct UserMessage {
201    /// Role discriminator (always `UserRole::User`).
202    pub role: UserRole,
203    /// Message content – either a plain string or a list of content blocks.
204    pub content: MessageContent,
205    /// Unix-epoch milliseconds when the message was created.
206    pub timestamp: i64,
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename = "user")]
211/// UserRole.
212pub enum UserRole {
213    #[serde(rename = "user")]
214    /// user variant.
215    User,
216}
217
218impl UserMessage {
219    /// Create a new user message with the current timestamp.
220    pub fn new(content: impl Into<MessageContent>) -> Self {
221        Self {
222            role: UserRole::User,
223            content: content.into(),
224            timestamp: chrono::Utc::now().timestamp_millis(),
225        }
226    }
227}
228
229/// Assistant message returned by the model.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct AssistantMessage {
232    /// Role discriminator (always `AssistantRole::Assistant`).
233    pub role: AssistantRole,
234    /// Ordered content blocks (text, thinking, tool calls, images).
235    pub content: Vec<ContentBlock>,
236    /// API dialect that produced this message.
237    pub api: super::Api,
238    /// Provider name (e.g. `"anthropic"`, `"openai"`).
239    pub provider: String,
240    /// Model identifier string.
241    pub model: String,
242    /// Token usage statistics.
243    pub usage: super::Usage,
244    /// Why the model stopped generating.
245    pub stop_reason: super::StopReason,
246    /// Non-fatal error message if the provider returned a partial error.
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub error_message: Option<String>,
249    /// Provider-assigned response ID.
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub response_id: Option<String>,
252    /// Unix-epoch milliseconds when the message was created.
253    pub timestamp: i64,
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
257#[serde(rename = "assistant")]
258/// AssistantRole.
259pub enum AssistantRole {
260    #[serde(rename = "assistant")]
261    /// assistant variant.
262    Assistant,
263}
264
265impl AssistantMessage {
266    /// Create a new assistant message with the current timestamp.
267    pub fn new(api: super::Api, provider: impl Into<String>, model: impl Into<String>) -> Self {
268        Self {
269            role: AssistantRole::Assistant,
270            content: Vec::new(),
271            api,
272            provider: provider.into(),
273            model: model.into(),
274            usage: super::Usage::default(),
275            stop_reason: super::StopReason::Stop,
276            error_message: None,
277            response_id: None,
278            timestamp: chrono::Utc::now().timestamp_millis(),
279        }
280    }
281
282    /// Concatenate all `Text` blocks into a single string.
283    pub fn text_content(&self) -> String {
284        // Pre-compute capacity to avoid reallocations.
285        let estimated_len: usize = self
286            .content
287            .iter()
288            .map(|b| b.as_text().map(|t| t.len()).unwrap_or(0))
289            .sum();
290        let mut result = String::with_capacity(estimated_len);
291        for block in &self.content {
292            if let Some(text) = block.as_text() {
293                result.push_str(text);
294            }
295        }
296        result
297    }
298}
299
300/// Tool result message carrying the output of a tool invocation.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct ToolResultMessage {
303    /// Role discriminator (always `ToolResultRole::ToolResult`).
304    pub role: ToolResultRole,
305    /// Matches the `ToolCall::id` this result is for.
306    pub tool_call_id: String,
307    /// Name of the tool that was executed.
308    pub tool_name: String,
309    /// Result content blocks.
310    pub content: Vec<ContentBlock>,
311    /// Optional structured details about the result.
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub details: Option<JsonValue>,
314    /// Whether this result represents an error.
315    #[serde(default)]
316    pub is_error: bool,
317    /// Unix-epoch milliseconds when the message was created.
318    pub timestamp: i64,
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
322#[serde(rename = "toolResult")]
323/// ToolResultRole.
324pub enum ToolResultRole {
325    #[serde(rename = "toolResult")]
326    /// tool result variant.
327    ToolResult,
328}
329
330impl ToolResultMessage {
331    /// Create a successful tool result.
332    pub fn new(
333        tool_call_id: impl Into<String>,
334        tool_name: impl Into<String>,
335        content: Vec<ContentBlock>,
336    ) -> Self {
337        Self {
338            role: ToolResultRole::ToolResult,
339            tool_call_id: tool_call_id.into(),
340            tool_name: tool_name.into(),
341            content,
342            details: None,
343            is_error: false,
344            timestamp: chrono::Utc::now().timestamp_millis(),
345        }
346    }
347
348    /// Create an error tool result.
349    pub fn error(
350        tool_call_id: impl Into<String>,
351        tool_name: impl Into<String>,
352        error: impl Into<String>,
353    ) -> Self {
354        Self {
355            role: ToolResultRole::ToolResult,
356            tool_call_id: tool_call_id.into(),
357            tool_name: tool_name.into(),
358            content: vec![ContentBlock::Text(TextContent::new(error))],
359            details: None,
360            is_error: true,
361            timestamp: chrono::Utc::now().timestamp_millis(),
362        }
363    }
364
365    /// Render all content blocks into a human-readable string.
366    pub fn text_content(&self) -> Result<String, crate::error::ProviderError> {
367        // Pre-compute capacity estimate.
368        let estimated_len: usize = self
369            .content
370            .iter()
371            .map(|b| match b {
372                ContentBlock::Text(t) => t.text.len() + 1,
373                ContentBlock::Image(_) => 7,
374                ContentBlock::Thinking(t) => t.thinking.len() + 12,
375                ContentBlock::ToolCall(tc) => tc.name.len() + 8,
376                ContentBlock::Unknown(_) => 0,
377            })
378            .sum();
379        let mut result = String::with_capacity(estimated_len);
380        for block in &self.content {
381            match block {
382                ContentBlock::Text(t) => {
383                    result.push_str(&t.text);
384                    result.push('\n');
385                }
386                ContentBlock::Image(_) => {
387                    result.push_str("[Image]\n");
388                }
389                ContentBlock::Thinking(t) => {
390                    result.push_str(&format!("[Thinking: {}]\n", t.thinking));
391                }
392                ContentBlock::ToolCall(tc) => {
393                    result.push_str(&format!("[Tool: {}]\n", tc.name));
394                }
395                ContentBlock::Unknown(_) => {
396                    // Skip unknown blocks
397                }
398            }
399        }
400        Ok(result.trim().to_string())
401    }
402}
403
404/// Message union tagged by role.
405///
406/// Every conversation turn is one of: a [`UserMessage`], an [`AssistantMessage`],
407/// or a [`ToolResultMessage`].
408#[derive(Debug, Clone, Serialize, Deserialize)]
409#[serde(tag = "role", rename_all = "camelCase")]
410pub enum Message {
411    /// A message from the user.
412    User(UserMessage),
413    /// A response from the assistant.
414    Assistant(AssistantMessage),
415    /// The output of a tool invocation.
416    ToolResult(ToolResultMessage),
417}
418
419impl Message {
420    /// Convenience constructor for a user text message.
421    pub fn user(content: impl Into<MessageContent>) -> Self {
422        Message::User(UserMessage::new(content))
423    }
424
425    /// Convenience constructor for an assistant message.
426    pub fn assistant(content: Vec<ContentBlock>) -> Self {
427        Message::Assistant(AssistantMessage {
428            role: AssistantRole::Assistant,
429            content,
430            api: Api::AnthropicMessages,
431            provider: "assistant".to_string(),
432            model: "assistant".to_string(),
433            usage: super::Usage::default(),
434            stop_reason: super::StopReason::Stop,
435            error_message: None,
436            response_id: None,
437            timestamp: chrono::Utc::now().timestamp_millis(),
438        })
439    }
440
441    /// Convenience constructor for a tool result message.
442    pub fn tool_result(
443        tool_call_id: impl Into<String>,
444        tool_name: impl Into<String>,
445        content: Vec<ContentBlock>,
446    ) -> Self {
447        Message::ToolResult(ToolResultMessage::new(tool_call_id, tool_name, content))
448    }
449
450    /// Return the timestamp (milliseconds since epoch) of this message.
451    pub fn timestamp(&self) -> i64 {
452        match self {
453            Message::User(m) => m.timestamp,
454            Message::Assistant(m) => m.timestamp,
455            Message::ToolResult(m) => m.timestamp,
456        }
457    }
458
459    /// Get the text content of this message
460    pub fn text_content(&self) -> Result<String, crate::error::ProviderError> {
461        match self {
462            Message::User(m) => match &m.content {
463                MessageContent::Text(s) => Ok(s.clone()),
464                MessageContent::Blocks(blocks) => {
465                    let estimated_len: usize = blocks
466                        .iter()
467                        .map(|b| match b {
468                            ContentBlock::Text(t) => t.text.len() + 1,
469                            ContentBlock::Image(_) => 8,
470                            ContentBlock::Thinking(t) => t.thinking.len() + 1,
471                            ContentBlock::ToolCall(_) => 12,
472                            ContentBlock::Unknown(_) => 10,
473                        })
474                        .sum();
475                    let mut result = String::with_capacity(estimated_len);
476                    for block in blocks {
477                        match block {
478                            ContentBlock::Text(t) => {
479                                result.push_str(&t.text);
480                                result.push('\n');
481                            }
482                            ContentBlock::Image(_) => {
483                                result.push_str("[Image]\n");
484                            }
485                            ContentBlock::Thinking(t) => {
486                                result.push_str(&t.thinking);
487                                result.push('\n');
488                            }
489                            ContentBlock::ToolCall(_) => {
490                                result.push_str("[Tool Call]\n");
491                            }
492                            ContentBlock::Unknown(_) => {
493                                result.push_str("[Unknown]\n");
494                            }
495                        }
496                    }
497                    Ok(result.trim().to_string())
498                }
499            },
500            Message::Assistant(m) => Ok(m.text_content()),
501            Message::ToolResult(m) => m.text_content(),
502        }
503    }
504}
505
506/// Message content – either a plain text string or a list of structured blocks.
507#[derive(Debug, Clone, Serialize, Deserialize)]
508#[serde(untagged)]
509pub enum MessageContent {
510    /// A simple text string.
511    Text(String),
512    /// One or more content blocks.
513    Blocks(Vec<ContentBlock>),
514}
515
516impl MessageContent {
517    /// Returns `true` if this is a `Text` variant.
518    pub fn is_text(&self) -> bool {
519        matches!(self, MessageContent::Text(_))
520    }
521
522    /// Returns the inner `&str` if this is a `Text` variant.
523    pub fn as_str(&self) -> Option<&str> {
524        match self {
525            MessageContent::Text(s) => Some(s),
526            MessageContent::Blocks(_) => None,
527        }
528    }
529}
530
531// String conversion for MessageContent
532impl From<String> for MessageContent {
533    fn from(text: String) -> Self {
534        MessageContent::Text(text)
535    }
536}
537
538impl From<&str> for MessageContent {
539    fn from(text: &str) -> Self {
540        MessageContent::Text(text.to_string())
541    }
542}
543
544impl From<Vec<ContentBlock>> for MessageContent {
545    fn from(blocks: Vec<ContentBlock>) -> Self {
546        MessageContent::Blocks(blocks)
547    }
548}
549
550impl From<TextContent> for MessageContent {
551    fn from(block: TextContent) -> Self {
552        MessageContent::Blocks(vec![ContentBlock::Text(block)])
553    }
554}
555
556impl From<ContentBlock> for MessageContent {
557    fn from(block: ContentBlock) -> Self {
558        MessageContent::Blocks(vec![block])
559    }
560}
561
562/// Transform messages for cross-provider compatibility.
563///
564/// When switching models mid-conversation, message history may contain
565/// provider-specific content (e.g. thinking blocks from Anthropic) that
566/// the new provider cannot handle. This function converts messages so
567/// they are compatible with the target provider's API.
568///
569/// Key transformations:
570/// - Thinking blocks → wrapped in `<thinking>` tags as plain text
571/// - Tool calls and tool results are preserved unchanged
572/// - User/assistant message structure is preserved
573pub fn transform_for_provider(
574    messages: &[Message],
575    _from_api: &super::Api,
576    to_api: &super::Api,
577) -> Vec<Message> {
578    messages
579        .iter()
580        .map(|msg| match msg {
581            Message::Assistant(a) => {
582                let mut new_msg = AssistantMessage::new(*to_api, &a.provider, &a.model);
583                new_msg.content = transform_content_blocks(&a.content, to_api);
584                new_msg.usage = a.usage.clone();
585                new_msg.stop_reason = a.stop_reason;
586                new_msg.error_message = a.error_message.clone();
587                new_msg.response_id = a.response_id.clone();
588                new_msg.timestamp = a.timestamp;
589                Message::Assistant(new_msg)
590            }
591            Message::User(u) => Message::User(u.clone()),
592            Message::ToolResult(t) => Message::ToolResult(t.clone()),
593        })
594        .collect()
595}
596
597/// Transform content blocks for a target provider.
598///
599/// Converts provider-specific blocks (like thinking) into formats
600/// the target provider can understand.
601fn transform_content_blocks(blocks: &[ContentBlock], to_api: &super::Api) -> Vec<ContentBlock> {
602    match to_api {
603        // Anthropic natively supports thinking blocks — keep as-is
604        super::Api::AnthropicMessages => blocks.to_vec(),
605
606        // OpenAI-compatible and other providers: convert thinking to text
607        _ => {
608            let mut transformed = Vec::with_capacity(blocks.len());
609            for block in blocks {
610                match block {
611                    ContentBlock::Thinking(t) => {
612                        // Convert thinking block to text wrapped in tags
613                        let text = format!("<thinking>\n{}\n</thinking>", t.thinking);
614                        transformed.push(ContentBlock::Text(TextContent::new(text)));
615                    }
616                    ContentBlock::Text(t) => {
617                        transformed.push(ContentBlock::Text(t.clone()));
618                    }
619                    ContentBlock::ToolCall(tc) => {
620                        transformed.push(ContentBlock::ToolCall(tc.clone()));
621                    }
622                    ContentBlock::Image(img) => {
623                        transformed.push(ContentBlock::Image(img.clone()));
624                    }
625                    ContentBlock::Unknown(v) => {
626                        // Try to extract text from unknown blocks
627                        if let Some(text) = v.get("text").and_then(|t| t.as_str()) {
628                            transformed.push(ContentBlock::Text(TextContent::new(text)));
629                        }
630                        // Otherwise silently drop unknown blocks
631                    }
632                }
633            }
634            // Merge adjacent text blocks
635            merge_adjacent_text_blocks(transformed)
636        }
637    }
638}
639
640/// Merge adjacent `ContentBlock::Text` blocks into a single block.
641fn merge_adjacent_text_blocks(blocks: Vec<ContentBlock>) -> Vec<ContentBlock> {
642    let mut result = Vec::with_capacity(blocks.len());
643    let estimated_len = blocks
644        .iter()
645        .map(|b| match b {
646            ContentBlock::Text(t) => t.text.len() + 1,
647            _ => 0,
648        })
649        .sum::<usize>();
650    let mut pending_text = String::with_capacity(estimated_len.max(256));
651
652    for block in blocks {
653        match block {
654            ContentBlock::Text(t) => {
655                if !pending_text.is_empty() {
656                    pending_text.push('\n');
657                }
658                pending_text.push_str(&t.text);
659            }
660            other => {
661                if !pending_text.is_empty() {
662                    result.push(ContentBlock::Text(TextContent::new(std::mem::take(
663                        &mut pending_text,
664                    ))));
665                }
666                result.push(other);
667            }
668        }
669    }
670
671    if !pending_text.is_empty() {
672        result.push(ContentBlock::Text(TextContent::new(pending_text)));
673    }
674
675    result
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681    use crate::types::{Api, StopReason, Usage};
682
683    // ---- ContentBlock serialization roundtrip ----
684
685    #[test]
686    fn text_content_roundtrip() {
687        let block = ContentBlock::Text(TextContent::new("hello world"));
688        let json = serde_json::to_string(&block).unwrap();
689        let back: ContentBlock = serde_json::from_str(&json).unwrap();
690        assert_eq!(back.as_text(), Some("hello world"));
691    }
692
693    #[test]
694    fn thinking_content_roundtrip() {
695        let block = ContentBlock::Thinking(ThinkingContent::new("inner thoughts"));
696        let json = serde_json::to_string(&block).unwrap();
697        let back: ContentBlock = serde_json::from_str(&json).unwrap();
698        assert!(back.as_thinking().is_some());
699        assert_eq!(back.as_thinking().unwrap().thinking, "inner thoughts");
700    }
701
702    #[test]
703    fn image_content_roundtrip() {
704        let block = ContentBlock::Image(ImageContent::new("base64data==", "image/png"));
705        let json = serde_json::to_string(&block).unwrap();
706        let back: ContentBlock = serde_json::from_str(&json).unwrap();
707        match back {
708            ContentBlock::Image(img) => {
709                assert_eq!(img.data, "base64data==");
710                assert_eq!(img.mime_type, "image/png");
711            }
712            _ => panic!("Expected Image block"),
713        }
714    }
715
716    #[test]
717    fn tool_call_roundtrip() {
718        let block = ContentBlock::ToolCall(ToolCall::new(
719            "call_123",
720            "read_file",
721            serde_json::json!({"path": "/foo.rs"}),
722        ));
723        let json = serde_json::to_string(&block).unwrap();
724        let back: ContentBlock = serde_json::from_str(&json).unwrap();
725        let tc = back.as_tool_call().unwrap();
726        assert_eq!(tc.id, "call_123");
727        assert_eq!(tc.name, "read_file");
728        assert_eq!(tc.arguments["path"], "/foo.rs");
729    }
730
731    // ---- Inner message type roundtrip (Message enum has duplicate role key issue) ----
732
733    #[test]
734    fn user_message_inner_roundtrip() {
735        let msg = UserMessage::new("Hello, assistant!");
736        let json = serde_json::to_string(&msg).unwrap();
737        let back: UserMessage = serde_json::from_str(&json).unwrap();
738        assert!(matches!(&back.content, MessageContent::Text(s) if s == "Hello, assistant!"));
739        assert_eq!(back.role, UserRole::User);
740    }
741
742    #[test]
743    fn user_message_blocks_roundtrip() {
744        let blocks = vec![
745            ContentBlock::Text(TextContent::new("part one")),
746            ContentBlock::Text(TextContent::new("part two")),
747        ];
748        let msg = UserMessage::new(MessageContent::Blocks(blocks));
749        let json = serde_json::to_string(&msg).unwrap();
750        let back: UserMessage = serde_json::from_str(&json).unwrap();
751        match &back.content {
752            MessageContent::Blocks(blocks) => assert_eq!(blocks.len(), 2),
753            _ => panic!("Expected Blocks"),
754        }
755    }
756
757    #[test]
758    fn assistant_message_inner_roundtrip() {
759        let mut msg = AssistantMessage::new(Api::AnthropicMessages, "anthropic", "claude-3");
760        msg.content
761            .push(ContentBlock::Text(TextContent::new("Hi!")));
762        msg.content
763            .push(ContentBlock::Thinking(ThinkingContent::new("hmm")));
764        msg.usage = Usage {
765            input: 100,
766            output: 50,
767            ..Default::default()
768        };
769        msg.stop_reason = StopReason::Stop;
770        msg.response_id = Some("resp_abc".to_string());
771
772        let json = serde_json::to_string(&msg).unwrap();
773        let back: AssistantMessage = serde_json::from_str(&json).unwrap();
774
775        assert_eq!(back.content.len(), 2);
776        assert_eq!(back.usage.input, 100);
777        assert_eq!(back.response_id.as_deref(), Some("resp_abc"));
778        assert_eq!(back.role, AssistantRole::Assistant);
779    }
780
781    #[test]
782    fn tool_result_message_inner_roundtrip() {
783        let msg = ToolResultMessage::new(
784            "call_1",
785            "bash",
786            vec![ContentBlock::Text(TextContent::new("output"))],
787        );
788        let json = serde_json::to_string(&msg).unwrap();
789        let back: ToolResultMessage = serde_json::from_str(&json).unwrap();
790        assert_eq!(back.tool_call_id, "call_1");
791        assert_eq!(back.tool_name, "bash");
792        assert!(!back.is_error);
793        assert_eq!(back.role, ToolResultRole::ToolResult);
794    }
795
796    #[test]
797    fn message_construction_and_accessors() {
798        let user = Message::user("test");
799        assert!(matches!(user, Message::User(_)));
800
801        let ts = user.timestamp();
802        assert!(ts > 0);
803    }
804
805    #[test]
806    fn message_content_roundtrip() {
807        // Text variant
808        let mc = MessageContent::Text("hello".to_string());
809        let json = serde_json::to_string(&mc).unwrap();
810        let back: MessageContent = serde_json::from_str(&json).unwrap();
811        assert_eq!(back.as_str(), Some("hello"));
812
813        // Blocks variant
814        let mc = MessageContent::Blocks(vec![ContentBlock::Text(TextContent::new("block"))]);
815        let json = serde_json::to_string(&mc).unwrap();
816        let back: MessageContent = serde_json::from_str(&json).unwrap();
817        assert!(!back.is_text());
818    }
819
820    // ---- text_content() ----
821
822    #[test]
823    fn user_text_content() {
824        let msg = Message::user("Hello!");
825        assert_eq!(msg.text_content().unwrap(), "Hello!");
826    }
827
828    #[test]
829    fn user_blocks_text_content() {
830        let blocks = vec![
831            ContentBlock::Text(TextContent::new("line 1")),
832            ContentBlock::Text(TextContent::new("line 2")),
833        ];
834        let msg = Message::User(UserMessage::new(MessageContent::Blocks(blocks)));
835        assert_eq!(msg.text_content().unwrap(), "line 1\nline 2");
836    }
837
838    #[test]
839    fn assistant_text_content() {
840        let mut a = AssistantMessage::new(Api::OpenAiCompletions, "openai", "gpt-4");
841        a.content
842            .push(ContentBlock::Text(TextContent::new("part A")));
843        a.content
844            .push(ContentBlock::Thinking(ThinkingContent::new("hidden")));
845        a.content
846            .push(ContentBlock::Text(TextContent::new("part B")));
847
848        let msg = Message::Assistant(a);
849        let text = msg.text_content().unwrap();
850        // text_content on assistant only returns Text blocks
851        assert_eq!(text, "part Apart B");
852    }
853
854    #[test]
855    fn tool_result_text_content() {
856        let msg = ToolResultMessage::new(
857            "call_1",
858            "read",
859            vec![
860                ContentBlock::Text(TextContent::new("file contents")),
861                ContentBlock::Image(ImageContent::new("aaa", "image/png")),
862            ],
863        );
864        let text = msg.text_content().unwrap();
865        assert!(text.contains("file contents"));
866        assert!(text.contains("[Image]"));
867    }
868
869    // ---- transform_for_provider ----
870
871    #[test]
872    fn transform_openai_to_anthropic_keeps_thinking() {
873        let mut a = AssistantMessage::new(Api::OpenAiCompletions, "openai", "gpt-4");
874        a.content
875            .push(ContentBlock::Text(TextContent::new("Hello")));
876        a.content
877            .push(ContentBlock::Thinking(ThinkingContent::new("pondering")));
878        let messages = vec![Message::Assistant(a)];
879
880        let transformed =
881            transform_for_provider(&messages, &Api::OpenAiCompletions, &Api::AnthropicMessages);
882        match &transformed[0] {
883            Message::Assistant(a) => {
884                // Anthropic keeps thinking blocks as-is
885                assert_eq!(a.content.len(), 2);
886                assert!(matches!(&a.content[1], ContentBlock::Thinking(_)));
887            }
888            _ => panic!("Expected Assistant"),
889        }
890    }
891
892    #[test]
893    fn transform_anthropic_to_openai_converts_thinking() {
894        let mut a = AssistantMessage::new(Api::AnthropicMessages, "anthropic", "claude-3");
895        a.content
896            .push(ContentBlock::Text(TextContent::new("Hello")));
897        a.content
898            .push(ContentBlock::Thinking(ThinkingContent::new("pondering")));
899        let messages = vec![Message::Assistant(a)];
900
901        let transformed =
902            transform_for_provider(&messages, &Api::AnthropicMessages, &Api::OpenAiCompletions);
903        match &transformed[0] {
904            Message::Assistant(a) => {
905                // Thinking converted to text, then merged with adjacent text
906                assert!(a.content.iter().all(|b| matches!(b, ContentBlock::Text(_))));
907                let full_text: String = a.content.iter().filter_map(|b| b.as_text()).collect();
908                assert!(full_text.contains("Hello"));
909                assert!(full_text.contains("<thinking>"));
910                assert!(full_text.contains("pondering"));
911            }
912            _ => panic!("Expected Assistant"),
913        }
914    }
915
916    #[test]
917    fn transform_roundtrip_openai_anthropic_openai() {
918        let mut a = AssistantMessage::new(Api::OpenAiCompletions, "openai", "gpt-4");
919        a.content
920            .push(ContentBlock::Text(TextContent::new("Hello")));
921        a.content
922            .push(ContentBlock::Thinking(ThinkingContent::new("pondering")));
923        a.content
924            .push(ContentBlock::Text(TextContent::new("World")));
925        let original = vec![Message::Assistant(a)];
926
927        // OpenAI -> Anthropic (keeps thinking)
928        let step1 =
929            transform_for_provider(&original, &Api::OpenAiCompletions, &Api::AnthropicMessages);
930        // Anthropic -> OpenAI (converts thinking to text)
931        let step2 =
932            transform_for_provider(&step1, &Api::AnthropicMessages, &Api::OpenAiCompletions);
933
934        match &step2[0] {
935            Message::Assistant(a) => {
936                let full_text: String = a.content.iter().filter_map(|b| b.as_text()).collect();
937                assert!(full_text.contains("Hello"));
938                assert!(full_text.contains("World"));
939                assert!(full_text.contains("<thinking>"));
940            }
941            _ => panic!("Expected Assistant"),
942        }
943    }
944
945    // ---- Adjacent text block merging ----
946
947    #[test]
948    fn merge_adjacent_text_blocks_basic() {
949        let blocks = vec![
950            ContentBlock::Text(TextContent::new("a")),
951            ContentBlock::Text(TextContent::new("b")),
952            ContentBlock::Text(TextContent::new("c")),
953        ];
954        let merged = merge_adjacent_text_blocks(blocks);
955        assert_eq!(merged.len(), 1);
956        assert_eq!(merged[0].as_text(), Some("a\nb\nc"));
957    }
958
959    #[test]
960    fn merge_adjacent_text_blocks_with_intervening() {
961        let blocks = vec![
962            ContentBlock::Text(TextContent::new("a")),
963            ContentBlock::Text(TextContent::new("b")),
964            ContentBlock::ToolCall(ToolCall::new("1", "tool", serde_json::json!({}))),
965            ContentBlock::Text(TextContent::new("c")),
966        ];
967        let merged = merge_adjacent_text_blocks(blocks);
968        assert_eq!(merged.len(), 3); // "a\nb", ToolCall, "c"
969        assert_eq!(merged[0].as_text(), Some("a\nb"));
970        assert!(merged[1].as_tool_call().is_some());
971        assert_eq!(merged[2].as_text(), Some("c"));
972    }
973
974    #[test]
975    fn merge_adjacent_text_blocks_empty() {
976        let blocks: Vec<ContentBlock> = vec![];
977        let merged = merge_adjacent_text_blocks(blocks);
978        assert!(merged.is_empty());
979    }
980
981    #[test]
982    fn message_content_from_conversions() {
983        let mc: MessageContent = "hello".into();
984        assert!(mc.is_text());
985        assert_eq!(mc.as_str(), Some("hello"));
986
987        let mc: MessageContent = "world".to_string().into();
988        assert!(mc.is_text());
989
990        let mc: MessageContent = TextContent::new("block").into();
991        assert!(!mc.is_text());
992    }
993}