Skip to main content

oxicode_ai/
messages.rs

1//! Message types for oxicode-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, PartialEq, 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    /// Whether this message renders in the transcript. `false` for synthetic
208    /// reminders/nudges the model must see but the human should not (they are
209    /// not something the human typed). Always sent to the provider regardless
210    /// of this flag — it is display-only metadata. Missing on old session
211    /// files deserializes to `true` (backward compat).
212    #[serde(default = "default_visible")]
213    pub visible: bool,
214}
215
216fn default_visible() -> bool {
217    true
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(rename = "user")]
222/// UserRole.
223pub enum UserRole {
224    #[serde(rename = "user")]
225    /// user variant.
226    User,
227}
228
229impl UserMessage {
230    /// Create a new user message with the current timestamp.
231    pub fn new(content: impl Into<MessageContent>) -> Self {
232        Self {
233            role: UserRole::User,
234            content: content.into(),
235            timestamp: chrono::Utc::now().timestamp_millis(),
236            visible: true,
237        }
238    }
239
240    /// A message sent to the model but not rendered in the transcript.
241    pub fn hidden(content: impl Into<MessageContent>) -> Self {
242        Self {
243            role: UserRole::User,
244            content: content.into(),
245            timestamp: chrono::Utc::now().timestamp_millis(),
246            visible: false,
247        }
248    }
249}
250
251/// Assistant message returned by the model.
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct AssistantMessage {
254    /// Role discriminator (always `AssistantRole::Assistant`).
255    pub role: AssistantRole,
256    /// Ordered content blocks (text, thinking, tool calls, images).
257    pub content: Vec<ContentBlock>,
258    /// API dialect that produced this message.
259    pub api: super::Api,
260    /// Provider name (e.g. `"anthropic"`, `"openai"`).
261    pub provider: String,
262    /// Model identifier string.
263    pub model: String,
264    /// Token usage statistics.
265    pub usage: super::Usage,
266    /// Why the model stopped generating.
267    pub stop_reason: super::StopReason,
268    /// Non-fatal error message if the provider returned a partial error.
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub error_message: Option<String>,
271    /// Provider-assigned response ID.
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub response_id: Option<String>,
274    /// Unix-epoch milliseconds when the message was created.
275    pub timestamp: i64,
276}
277
278#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(rename = "assistant")]
280/// AssistantRole.
281pub enum AssistantRole {
282    #[serde(rename = "assistant")]
283    /// assistant variant.
284    Assistant,
285}
286
287impl AssistantMessage {
288    /// Create a new assistant message with the current timestamp.
289    pub fn new(api: super::Api, provider: impl Into<String>, model: impl Into<String>) -> Self {
290        Self {
291            role: AssistantRole::Assistant,
292            content: Vec::new(),
293            api,
294            provider: provider.into(),
295            model: model.into(),
296            usage: super::Usage::default(),
297            stop_reason: super::StopReason::Stop,
298            error_message: None,
299            response_id: None,
300            timestamp: chrono::Utc::now().timestamp_millis(),
301        }
302    }
303
304    /// Concatenate all `Text` blocks into a single string.
305    pub fn text_content(&self) -> String {
306        // Pre-compute capacity to avoid reallocations.
307        let estimated_len: usize = self
308            .content
309            .iter()
310            .map(|b| b.as_text().map(|t| t.len()).unwrap_or(0))
311            .sum();
312        let mut result = String::with_capacity(estimated_len);
313        for block in &self.content {
314            if let Some(text) = block.as_text() {
315                result.push_str(text);
316            }
317        }
318        result
319    }
320}
321
322/// Tool result message carrying the output of a tool invocation.
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct ToolResultMessage {
325    /// Role discriminator (always `ToolResultRole::ToolResult`).
326    pub role: ToolResultRole,
327    /// Matches the `ToolCall::id` this result is for.
328    pub tool_call_id: String,
329    /// Name of the tool that was executed.
330    pub tool_name: String,
331    /// Result content blocks.
332    pub content: Vec<ContentBlock>,
333    /// Optional structured details about the result.
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub details: Option<JsonValue>,
336    /// Whether this result represents an error.
337    #[serde(default)]
338    pub is_error: bool,
339    /// Unix-epoch milliseconds when the message was created.
340    pub timestamp: i64,
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
344#[serde(rename = "toolResult")]
345/// ToolResultRole.
346pub enum ToolResultRole {
347    #[serde(rename = "toolResult")]
348    /// tool result variant.
349    ToolResult,
350}
351
352impl ToolResultMessage {
353    /// Create a successful tool result.
354    pub fn new(
355        tool_call_id: impl Into<String>,
356        tool_name: impl Into<String>,
357        content: Vec<ContentBlock>,
358    ) -> Self {
359        Self {
360            role: ToolResultRole::ToolResult,
361            tool_call_id: tool_call_id.into(),
362            tool_name: tool_name.into(),
363            content,
364            details: None,
365            is_error: false,
366            timestamp: chrono::Utc::now().timestamp_millis(),
367        }
368    }
369
370    /// Create an error tool result.
371    pub fn error(
372        tool_call_id: impl Into<String>,
373        tool_name: impl Into<String>,
374        error: impl Into<String>,
375    ) -> Self {
376        Self {
377            role: ToolResultRole::ToolResult,
378            tool_call_id: tool_call_id.into(),
379            tool_name: tool_name.into(),
380            content: vec![ContentBlock::Text(TextContent::new(error))],
381            details: None,
382            is_error: true,
383            timestamp: chrono::Utc::now().timestamp_millis(),
384        }
385    }
386
387    /// Render all content blocks into a human-readable string.
388    pub fn text_content(&self) -> Result<String, crate::error::ProviderError> {
389        // Pre-compute capacity estimate.
390        let estimated_len: usize = self
391            .content
392            .iter()
393            .map(|b| match b {
394                ContentBlock::Text(t) => t.text.len() + 1,
395                ContentBlock::Image(_) => 7,
396                ContentBlock::Thinking(t) => t.thinking.len() + 12,
397                ContentBlock::ToolCall(tc) => tc.name.len() + 8,
398                ContentBlock::Unknown(_) => 0,
399            })
400            .sum();
401        let mut result = String::with_capacity(estimated_len);
402        for block in &self.content {
403            match block {
404                ContentBlock::Text(t) => {
405                    result.push_str(&t.text);
406                    result.push('\n');
407                }
408                ContentBlock::Image(_) => {
409                    result.push_str("[Image]\n");
410                }
411                ContentBlock::Thinking(t) => {
412                    result.push_str(&format!("[Thinking: {}]\n", t.thinking));
413                }
414                ContentBlock::ToolCall(tc) => {
415                    result.push_str(&format!("[Tool: {}]\n", tc.name));
416                }
417                ContentBlock::Unknown(_) => {
418                    // Skip unknown blocks
419                }
420            }
421        }
422        Ok(result.trim().to_string())
423    }
424}
425
426/// Message union tagged by role.
427///
428/// Every conversation turn is one of: a [`UserMessage`], an [`AssistantMessage`],
429/// or a [`ToolResultMessage`].
430#[derive(Debug, Clone, Serialize, Deserialize)]
431#[serde(tag = "role", rename_all = "camelCase")]
432pub enum Message {
433    /// A message from the user.
434    User(UserMessage),
435    /// A response from the assistant.
436    Assistant(AssistantMessage),
437    /// The output of a tool invocation.
438    ToolResult(ToolResultMessage),
439}
440
441impl Message {
442    /// Convenience constructor for a user text message.
443    pub fn user(content: impl Into<MessageContent>) -> Self {
444        Message::User(UserMessage::new(content))
445    }
446
447    /// Convenience constructor for an assistant message.
448    pub fn assistant(content: Vec<ContentBlock>) -> Self {
449        Message::Assistant(AssistantMessage {
450            role: AssistantRole::Assistant,
451            content,
452            api: Api::AnthropicMessages,
453            provider: "assistant".to_string(),
454            model: "assistant".to_string(),
455            usage: super::Usage::default(),
456            stop_reason: super::StopReason::Stop,
457            error_message: None,
458            response_id: None,
459            timestamp: chrono::Utc::now().timestamp_millis(),
460        })
461    }
462
463    /// Convenience constructor for a tool result message.
464    pub fn tool_result(
465        tool_call_id: impl Into<String>,
466        tool_name: impl Into<String>,
467        content: Vec<ContentBlock>,
468    ) -> Self {
469        Message::ToolResult(ToolResultMessage::new(tool_call_id, tool_name, content))
470    }
471
472    /// Return the timestamp (milliseconds since epoch) of this message.
473    pub fn timestamp(&self) -> i64 {
474        match self {
475            Message::User(m) => m.timestamp,
476            Message::Assistant(m) => m.timestamp,
477            Message::ToolResult(m) => m.timestamp,
478        }
479    }
480
481    /// Get the text content of this message
482    pub fn text_content(&self) -> Result<String, crate::error::ProviderError> {
483        match self {
484            Message::User(m) => match &m.content {
485                MessageContent::Text(s) => Ok(s.clone()),
486                MessageContent::Blocks(blocks) => {
487                    let estimated_len: usize = blocks
488                        .iter()
489                        .map(|b| match b {
490                            ContentBlock::Text(t) => t.text.len() + 1,
491                            ContentBlock::Image(_) => 8,
492                            ContentBlock::Thinking(t) => t.thinking.len() + 1,
493                            ContentBlock::ToolCall(_) => 12,
494                            ContentBlock::Unknown(_) => 10,
495                        })
496                        .sum();
497                    let mut result = String::with_capacity(estimated_len);
498                    for block in blocks {
499                        match block {
500                            ContentBlock::Text(t) => {
501                                result.push_str(&t.text);
502                                result.push('\n');
503                            }
504                            ContentBlock::Image(_) => {
505                                result.push_str("[Image]\n");
506                            }
507                            ContentBlock::Thinking(t) => {
508                                result.push_str(&t.thinking);
509                                result.push('\n');
510                            }
511                            ContentBlock::ToolCall(_) => {
512                                result.push_str("[Tool Call]\n");
513                            }
514                            ContentBlock::Unknown(_) => {
515                                result.push_str("[Unknown]\n");
516                            }
517                        }
518                    }
519                    Ok(result.trim().to_string())
520                }
521            },
522            Message::Assistant(m) => Ok(m.text_content()),
523            Message::ToolResult(m) => m.text_content(),
524        }
525    }
526}
527
528/// Message content – either a plain text string or a list of structured blocks.
529#[derive(Debug, Clone, Serialize, Deserialize)]
530#[serde(untagged)]
531pub enum MessageContent {
532    /// A simple text string.
533    Text(String),
534    /// One or more content blocks.
535    Blocks(Vec<ContentBlock>),
536}
537
538impl MessageContent {
539    /// Returns `true` if this is a `Text` variant.
540    pub fn is_text(&self) -> bool {
541        matches!(self, MessageContent::Text(_))
542    }
543
544    /// Returns the inner `&str` if this is a `Text` variant.
545    pub fn as_str(&self) -> Option<&str> {
546        match self {
547            MessageContent::Text(s) => Some(s),
548            MessageContent::Blocks(_) => None,
549        }
550    }
551}
552
553// String conversion for MessageContent
554impl From<String> for MessageContent {
555    fn from(text: String) -> Self {
556        MessageContent::Text(text)
557    }
558}
559
560impl From<&str> for MessageContent {
561    fn from(text: &str) -> Self {
562        MessageContent::Text(text.to_string())
563    }
564}
565
566impl From<Vec<ContentBlock>> for MessageContent {
567    fn from(blocks: Vec<ContentBlock>) -> Self {
568        MessageContent::Blocks(blocks)
569    }
570}
571
572impl From<TextContent> for MessageContent {
573    fn from(block: TextContent) -> Self {
574        MessageContent::Blocks(vec![ContentBlock::Text(block)])
575    }
576}
577
578impl From<ContentBlock> for MessageContent {
579    fn from(block: ContentBlock) -> Self {
580        MessageContent::Blocks(vec![block])
581    }
582}
583
584/// Transform messages for cross-provider compatibility.
585///
586/// When switching models mid-conversation, message history may contain
587/// provider-specific content (e.g. thinking blocks from Anthropic) that
588/// the new provider cannot handle. This function converts messages so
589/// they are compatible with the target provider's API.
590///
591/// Key transformations:
592/// - Thinking blocks → wrapped in `<thinking>` tags as plain text
593/// - Tool calls and tool results are preserved unchanged
594/// - User/assistant message structure is preserved
595pub fn transform_for_provider(
596    messages: &[Message],
597    _from_api: &super::Api,
598    to_api: &super::Api,
599) -> Vec<Message> {
600    messages
601        .iter()
602        .map(|msg| match msg {
603            Message::Assistant(a) => {
604                let mut new_msg = AssistantMessage::new(*to_api, &a.provider, &a.model);
605                new_msg.content = transform_content_blocks(&a.content, to_api);
606                new_msg.usage = a.usage.clone();
607                new_msg.stop_reason = a.stop_reason;
608                new_msg.error_message = a.error_message.clone();
609                new_msg.response_id = a.response_id.clone();
610                new_msg.timestamp = a.timestamp;
611                Message::Assistant(new_msg)
612            }
613            Message::User(u) => Message::User(u.clone()),
614            Message::ToolResult(t) => Message::ToolResult(t.clone()),
615        })
616        .collect()
617}
618
619/// Transform content blocks for a target provider.
620///
621/// Converts provider-specific blocks (like thinking) into formats
622/// the target provider can understand.
623fn transform_content_blocks(blocks: &[ContentBlock], to_api: &super::Api) -> Vec<ContentBlock> {
624    match to_api {
625        // Anthropic natively supports thinking blocks — keep as-is
626        super::Api::AnthropicMessages => blocks.to_vec(),
627
628        // OpenAI-compatible and other providers: convert thinking to text
629        _ => {
630            let mut transformed = Vec::with_capacity(blocks.len());
631            for block in blocks {
632                match block {
633                    ContentBlock::Thinking(t) => {
634                        // Convert thinking block to text wrapped in tags
635                        let text = format!("<thinking>\n{}\n</thinking>", t.thinking);
636                        transformed.push(ContentBlock::Text(TextContent::new(text)));
637                    }
638                    ContentBlock::Text(t) => {
639                        transformed.push(ContentBlock::Text(t.clone()));
640                    }
641                    ContentBlock::ToolCall(tc) => {
642                        transformed.push(ContentBlock::ToolCall(tc.clone()));
643                    }
644                    ContentBlock::Image(img) => {
645                        transformed.push(ContentBlock::Image(img.clone()));
646                    }
647                    ContentBlock::Unknown(v) => {
648                        // Try to extract text from unknown blocks
649                        if let Some(text) = v.get("text").and_then(|t| t.as_str()) {
650                            transformed.push(ContentBlock::Text(TextContent::new(text)));
651                        }
652                        // Otherwise silently drop unknown blocks
653                    }
654                }
655            }
656            // Merge adjacent text blocks
657            merge_adjacent_text_blocks(transformed)
658        }
659    }
660}
661
662/// Merge adjacent `ContentBlock::Text` blocks into a single block.
663fn merge_adjacent_text_blocks(blocks: Vec<ContentBlock>) -> Vec<ContentBlock> {
664    let mut result = Vec::with_capacity(blocks.len());
665    let estimated_len = blocks
666        .iter()
667        .map(|b| match b {
668            ContentBlock::Text(t) => t.text.len() + 1,
669            _ => 0,
670        })
671        .sum::<usize>();
672    let mut pending_text = String::with_capacity(estimated_len.max(256));
673
674    for block in blocks {
675        match block {
676            ContentBlock::Text(t) => {
677                if !pending_text.is_empty() {
678                    pending_text.push('\n');
679                }
680                pending_text.push_str(&t.text);
681            }
682            other => {
683                if !pending_text.is_empty() {
684                    result.push(ContentBlock::Text(TextContent::new(std::mem::take(
685                        &mut pending_text,
686                    ))));
687                }
688                result.push(other);
689            }
690        }
691    }
692
693    if !pending_text.is_empty() {
694        result.push(ContentBlock::Text(TextContent::new(pending_text)));
695    }
696
697    result
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703    use crate::types::{Api, StopReason, Usage};
704
705    #[test]
706    fn user_message_new_defaults_visible_true() {
707        let m = UserMessage::new("hi");
708        assert!(m.visible);
709    }
710
711    #[test]
712    fn user_message_hidden_sets_visible_false() {
713        let m = UserMessage::hidden("system nudge");
714        assert!(!m.visible);
715    }
716
717    #[test]
718    fn user_message_deserializes_missing_visible_as_true() {
719        let json = r#"{"role":"user","content":"hi","timestamp":0}"#;
720        let m: UserMessage = serde_json::from_str(json).unwrap();
721        assert!(m.visible); // backward compat: old session files have no `visible`
722    }
723
724    // ---- ContentBlock serialization roundtrip ----
725
726    #[test]
727    fn text_content_roundtrip() {
728        let block = ContentBlock::Text(TextContent::new("hello world"));
729        let json = serde_json::to_string(&block).unwrap();
730        let back: ContentBlock = serde_json::from_str(&json).unwrap();
731        assert_eq!(back.as_text(), Some("hello world"));
732    }
733
734    #[test]
735    fn thinking_content_roundtrip() {
736        let block = ContentBlock::Thinking(ThinkingContent::new("inner thoughts"));
737        let json = serde_json::to_string(&block).unwrap();
738        let back: ContentBlock = serde_json::from_str(&json).unwrap();
739        assert!(back.as_thinking().is_some());
740        assert_eq!(back.as_thinking().unwrap().thinking, "inner thoughts");
741    }
742
743    #[test]
744    fn image_content_roundtrip() {
745        let block = ContentBlock::Image(ImageContent::new("base64data==", "image/png"));
746        let json = serde_json::to_string(&block).unwrap();
747        let back: ContentBlock = serde_json::from_str(&json).unwrap();
748        match back {
749            ContentBlock::Image(img) => {
750                assert_eq!(img.data, "base64data==");
751                assert_eq!(img.mime_type, "image/png");
752            }
753            _ => panic!("Expected Image block"),
754        }
755    }
756
757    #[test]
758    fn tool_call_roundtrip() {
759        let block = ContentBlock::ToolCall(ToolCall::new(
760            "call_123",
761            "read_file",
762            serde_json::json!({"path": "/foo.rs"}),
763        ));
764        let json = serde_json::to_string(&block).unwrap();
765        let back: ContentBlock = serde_json::from_str(&json).unwrap();
766        let tc = back.as_tool_call().unwrap();
767        assert_eq!(tc.id, "call_123");
768        assert_eq!(tc.name, "read_file");
769        assert_eq!(tc.arguments["path"], "/foo.rs");
770    }
771
772    // ---- Inner message type roundtrip (Message enum has duplicate role key issue) ----
773
774    #[test]
775    fn user_message_inner_roundtrip() {
776        let msg = UserMessage::new("Hello, assistant!");
777        let json = serde_json::to_string(&msg).unwrap();
778        let back: UserMessage = serde_json::from_str(&json).unwrap();
779        assert!(matches!(&back.content, MessageContent::Text(s) if s == "Hello, assistant!"));
780        assert_eq!(back.role, UserRole::User);
781    }
782
783    #[test]
784    fn user_message_blocks_roundtrip() {
785        let blocks = vec![
786            ContentBlock::Text(TextContent::new("part one")),
787            ContentBlock::Text(TextContent::new("part two")),
788        ];
789        let msg = UserMessage::new(MessageContent::Blocks(blocks));
790        let json = serde_json::to_string(&msg).unwrap();
791        let back: UserMessage = serde_json::from_str(&json).unwrap();
792        match &back.content {
793            MessageContent::Blocks(blocks) => assert_eq!(blocks.len(), 2),
794            _ => panic!("Expected Blocks"),
795        }
796    }
797
798    #[test]
799    fn assistant_message_inner_roundtrip() {
800        let mut msg = AssistantMessage::new(Api::AnthropicMessages, "anthropic", "claude-3");
801        msg.content
802            .push(ContentBlock::Text(TextContent::new("Hi!")));
803        msg.content
804            .push(ContentBlock::Thinking(ThinkingContent::new("hmm")));
805        msg.usage = Usage {
806            input: 100,
807            output: 50,
808            ..Default::default()
809        };
810        msg.stop_reason = StopReason::Stop;
811        msg.response_id = Some("resp_abc".to_string());
812
813        let json = serde_json::to_string(&msg).unwrap();
814        let back: AssistantMessage = serde_json::from_str(&json).unwrap();
815
816        assert_eq!(back.content.len(), 2);
817        assert_eq!(back.usage.input, 100);
818        assert_eq!(back.response_id.as_deref(), Some("resp_abc"));
819        assert_eq!(back.role, AssistantRole::Assistant);
820    }
821
822    #[test]
823    fn tool_result_message_inner_roundtrip() {
824        let msg = ToolResultMessage::new(
825            "call_1",
826            "bash",
827            vec![ContentBlock::Text(TextContent::new("output"))],
828        );
829        let json = serde_json::to_string(&msg).unwrap();
830        let back: ToolResultMessage = serde_json::from_str(&json).unwrap();
831        assert_eq!(back.tool_call_id, "call_1");
832        assert_eq!(back.tool_name, "bash");
833        assert!(!back.is_error);
834        assert_eq!(back.role, ToolResultRole::ToolResult);
835    }
836
837    #[test]
838    fn message_construction_and_accessors() {
839        let user = Message::user("test");
840        assert!(matches!(user, Message::User(_)));
841
842        let ts = user.timestamp();
843        assert!(ts > 0);
844    }
845
846    #[test]
847    fn message_content_roundtrip() {
848        // Text variant
849        let mc = MessageContent::Text("hello".to_string());
850        let json = serde_json::to_string(&mc).unwrap();
851        let back: MessageContent = serde_json::from_str(&json).unwrap();
852        assert_eq!(back.as_str(), Some("hello"));
853
854        // Blocks variant
855        let mc = MessageContent::Blocks(vec![ContentBlock::Text(TextContent::new("block"))]);
856        let json = serde_json::to_string(&mc).unwrap();
857        let back: MessageContent = serde_json::from_str(&json).unwrap();
858        assert!(!back.is_text());
859    }
860
861    // ---- text_content() ----
862
863    #[test]
864    fn user_text_content() {
865        let msg = Message::user("Hello!");
866        assert_eq!(msg.text_content().unwrap(), "Hello!");
867    }
868
869    #[test]
870    fn user_blocks_text_content() {
871        let blocks = vec![
872            ContentBlock::Text(TextContent::new("line 1")),
873            ContentBlock::Text(TextContent::new("line 2")),
874        ];
875        let msg = Message::User(UserMessage::new(MessageContent::Blocks(blocks)));
876        assert_eq!(msg.text_content().unwrap(), "line 1\nline 2");
877    }
878
879    #[test]
880    fn assistant_text_content() {
881        let mut a = AssistantMessage::new(Api::OpenAiCompletions, "openai", "gpt-4");
882        a.content
883            .push(ContentBlock::Text(TextContent::new("part A")));
884        a.content
885            .push(ContentBlock::Thinking(ThinkingContent::new("hidden")));
886        a.content
887            .push(ContentBlock::Text(TextContent::new("part B")));
888
889        let msg = Message::Assistant(a);
890        let text = msg.text_content().unwrap();
891        // text_content on assistant only returns Text blocks
892        assert_eq!(text, "part Apart B");
893    }
894
895    #[test]
896    fn tool_result_text_content() {
897        let msg = ToolResultMessage::new(
898            "call_1",
899            "read",
900            vec![
901                ContentBlock::Text(TextContent::new("file contents")),
902                ContentBlock::Image(ImageContent::new("aaa", "image/png")),
903            ],
904        );
905        let text = msg.text_content().unwrap();
906        assert!(text.contains("file contents"));
907        assert!(text.contains("[Image]"));
908    }
909
910    // ---- transform_for_provider ----
911
912    #[test]
913    fn transform_openai_to_anthropic_keeps_thinking() {
914        let mut a = AssistantMessage::new(Api::OpenAiCompletions, "openai", "gpt-4");
915        a.content
916            .push(ContentBlock::Text(TextContent::new("Hello")));
917        a.content
918            .push(ContentBlock::Thinking(ThinkingContent::new("pondering")));
919        let messages = vec![Message::Assistant(a)];
920
921        let transformed =
922            transform_for_provider(&messages, &Api::OpenAiCompletions, &Api::AnthropicMessages);
923        match &transformed[0] {
924            Message::Assistant(a) => {
925                // Anthropic keeps thinking blocks as-is
926                assert_eq!(a.content.len(), 2);
927                assert!(matches!(&a.content[1], ContentBlock::Thinking(_)));
928            }
929            _ => panic!("Expected Assistant"),
930        }
931    }
932
933    #[test]
934    fn transform_anthropic_to_openai_converts_thinking() {
935        let mut a = AssistantMessage::new(Api::AnthropicMessages, "anthropic", "claude-3");
936        a.content
937            .push(ContentBlock::Text(TextContent::new("Hello")));
938        a.content
939            .push(ContentBlock::Thinking(ThinkingContent::new("pondering")));
940        let messages = vec![Message::Assistant(a)];
941
942        let transformed =
943            transform_for_provider(&messages, &Api::AnthropicMessages, &Api::OpenAiCompletions);
944        match &transformed[0] {
945            Message::Assistant(a) => {
946                // Thinking converted to text, then merged with adjacent text
947                assert!(a.content.iter().all(|b| matches!(b, ContentBlock::Text(_))));
948                let full_text: String = a.content.iter().filter_map(|b| b.as_text()).collect();
949                assert!(full_text.contains("Hello"));
950                assert!(full_text.contains("<thinking>"));
951                assert!(full_text.contains("pondering"));
952            }
953            _ => panic!("Expected Assistant"),
954        }
955    }
956
957    #[test]
958    fn transform_roundtrip_openai_anthropic_openai() {
959        let mut a = AssistantMessage::new(Api::OpenAiCompletions, "openai", "gpt-4");
960        a.content
961            .push(ContentBlock::Text(TextContent::new("Hello")));
962        a.content
963            .push(ContentBlock::Thinking(ThinkingContent::new("pondering")));
964        a.content
965            .push(ContentBlock::Text(TextContent::new("World")));
966        let original = vec![Message::Assistant(a)];
967
968        // OpenAI -> Anthropic (keeps thinking)
969        let step1 =
970            transform_for_provider(&original, &Api::OpenAiCompletions, &Api::AnthropicMessages);
971        // Anthropic -> OpenAI (converts thinking to text)
972        let step2 =
973            transform_for_provider(&step1, &Api::AnthropicMessages, &Api::OpenAiCompletions);
974
975        match &step2[0] {
976            Message::Assistant(a) => {
977                let full_text: String = a.content.iter().filter_map(|b| b.as_text()).collect();
978                assert!(full_text.contains("Hello"));
979                assert!(full_text.contains("World"));
980                assert!(full_text.contains("<thinking>"));
981            }
982            _ => panic!("Expected Assistant"),
983        }
984    }
985
986    // ---- Adjacent text block merging ----
987
988    #[test]
989    fn merge_adjacent_text_blocks_basic() {
990        let blocks = vec![
991            ContentBlock::Text(TextContent::new("a")),
992            ContentBlock::Text(TextContent::new("b")),
993            ContentBlock::Text(TextContent::new("c")),
994        ];
995        let merged = merge_adjacent_text_blocks(blocks);
996        assert_eq!(merged.len(), 1);
997        assert_eq!(merged[0].as_text(), Some("a\nb\nc"));
998    }
999
1000    #[test]
1001    fn merge_adjacent_text_blocks_with_intervening() {
1002        let blocks = vec![
1003            ContentBlock::Text(TextContent::new("a")),
1004            ContentBlock::Text(TextContent::new("b")),
1005            ContentBlock::ToolCall(ToolCall::new("1", "tool", serde_json::json!({}))),
1006            ContentBlock::Text(TextContent::new("c")),
1007        ];
1008        let merged = merge_adjacent_text_blocks(blocks);
1009        assert_eq!(merged.len(), 3); // "a\nb", ToolCall, "c"
1010        assert_eq!(merged[0].as_text(), Some("a\nb"));
1011        assert!(merged[1].as_tool_call().is_some());
1012        assert_eq!(merged[2].as_text(), Some("c"));
1013    }
1014
1015    #[test]
1016    fn merge_adjacent_text_blocks_empty() {
1017        let blocks: Vec<ContentBlock> = vec![];
1018        let merged = merge_adjacent_text_blocks(blocks);
1019        assert!(merged.is_empty());
1020    }
1021
1022    #[test]
1023    fn message_content_from_conversions() {
1024        let mc: MessageContent = "hello".into();
1025        assert!(mc.is_text());
1026        assert_eq!(mc.as_str(), Some("hello"));
1027
1028        let mc: MessageContent = "world".to_string().into();
1029        assert!(mc.is_text());
1030
1031        let mc: MessageContent = TextContent::new("block").into();
1032        assert!(!mc.is_text());
1033    }
1034}