Skip to main content

vtcode_core/llm/provider/
message.rs

1use super::ToolCall;
2use crate::llm::providers::clean_reasoning_text;
3use serde::{Deserialize, Serialize};
4
5/// Phase metadata for assistant messages in multi-step Responses-style workflows.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum AssistantPhase {
9    Commentary,
10    FinalAnswer,
11}
12
13impl AssistantPhase {
14    #[must_use]
15    pub const fn as_str(self) -> &'static str {
16        match self {
17            Self::Commentary => "commentary",
18            Self::FinalAnswer => "final_answer",
19        }
20    }
21
22    #[must_use]
23    pub fn from_wire_str(value: &str) -> Option<Self> {
24        match value {
25            "commentary" => Some(Self::Commentary),
26            "final_answer" => Some(Self::FinalAnswer),
27            _ => None,
28        }
29    }
30}
31
32/// Content type for messages that can include both text and images
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(untagged)]
35pub enum ContentPart {
36    Text {
37        text: String,
38    },
39    Image {
40        data: String,      // Base64 encoded image data
41        mime_type: String, // MIME type (e.g., "image/png")
42        #[serde(rename = "type")]
43        content_type: String, // "image"
44    },
45    File {
46        #[serde(rename = "type")]
47        content_type: String, // "file" or "input_file"
48        #[serde(default, skip_serializing_if = "Option::is_none")]
49        filename: Option<String>,
50        #[serde(default, skip_serializing_if = "Option::is_none")]
51        file_id: Option<String>,
52        #[serde(default, skip_serializing_if = "Option::is_none")]
53        file_data: Option<String>,
54        #[serde(default, skip_serializing_if = "Option::is_none")]
55        file_url: Option<String>,
56    },
57}
58
59impl ContentPart {
60    pub fn text(text: String) -> Self {
61        ContentPart::Text { text }
62    }
63
64    pub fn image(data: String, mime_type: String) -> Self {
65        ContentPart::Image {
66            data,
67            mime_type,
68            content_type: "image".to_owned(),
69        }
70    }
71
72    pub fn file_from_id(file_id: String) -> Self {
73        ContentPart::File {
74            content_type: "file".to_owned(),
75            filename: None,
76            file_id: Some(file_id),
77            file_data: None,
78            file_url: None,
79        }
80    }
81
82    pub fn file_from_url(file_url: String) -> Self {
83        ContentPart::File {
84            content_type: "input_file".to_owned(),
85            filename: None,
86            file_id: None,
87            file_data: None,
88            file_url: Some(file_url),
89        }
90    }
91
92    pub fn file_from_data(filename: String, file_data: String) -> Self {
93        ContentPart::File {
94            content_type: "input_file".to_owned(),
95            filename: Some(filename),
96            file_id: None,
97            file_data: Some(file_data),
98            file_url: None,
99        }
100    }
101
102    pub fn as_text(&self) -> Option<&str> {
103        match self {
104            ContentPart::Text { text } => Some(text),
105            _ => None,
106        }
107    }
108
109    pub fn is_image(&self) -> bool {
110        matches!(self, ContentPart::Image { .. })
111    }
112
113    pub fn is_file(&self) -> bool {
114        matches!(self, ContentPart::File { .. })
115    }
116}
117
118/// Universal message structure supporting both text and image content
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
120pub struct Message {
121    #[serde(default)]
122    pub role: MessageRole,
123    /// Content can be a string (for backward compatibility) or an array of content parts
124    #[serde(default)]
125    pub content: MessageContent,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub reasoning: Option<String>,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub reasoning_details: Option<Vec<serde_json::Value>>,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub tool_calls: Option<Vec<ToolCall>>,
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub tool_call_id: Option<String>,
134    /// Optional assistant-only phase metadata used by OpenAI Responses workflows.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub phase: Option<AssistantPhase>,
137    /// Optional origin tool name for tracking which tool generated this message
138    /// Used in tool-aware context retention to preserve results from recently-active tools
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub origin_tool: Option<String>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(untagged)]
145pub enum MessageContent {
146    /// Legacy single text string
147    Text(String),
148    /// Multiple content parts (text and images)
149    Parts(Vec<ContentPart>),
150}
151
152impl MessageContent {
153    pub fn text(text: String) -> Self {
154        MessageContent::Text(text)
155    }
156
157    pub fn parts(parts: Vec<ContentPart>) -> Self {
158        MessageContent::Parts(parts)
159    }
160
161    /// Returns a borrowed reference to the text content if this is a simple Text variant.
162    /// For Parts variant, returns None (use as_text() for combined content).
163    #[inline]
164    pub fn as_text_borrowed(&self) -> Option<&str> {
165        match self {
166            MessageContent::Text(text) => Some(text.as_str()),
167            MessageContent::Parts(_) => None,
168        }
169    }
170
171    /// Returns the text content, avoiding allocation if possible.
172    /// For Parts variant, concatenates text parts in order without adding spacing.
173    pub fn as_text(&self) -> std::borrow::Cow<'_, str> {
174        match self {
175            MessageContent::Text(text) => std::borrow::Cow::Borrowed(text),
176            MessageContent::Parts(parts) => {
177                let mut first_text = None;
178                let mut text_count = 0usize;
179                let mut total_len = 0usize;
180
181                for text in parts.iter().filter_map(ContentPart::as_text) {
182                    if first_text.is_none() {
183                        first_text = Some(text);
184                    }
185                    text_count += 1;
186                    total_len += text.len();
187                }
188
189                if text_count == 0 {
190                    return std::borrow::Cow::Borrowed("");
191                }
192                if text_count == 1 {
193                    return std::borrow::Cow::Borrowed(first_text.unwrap_or(""));
194                }
195
196                let mut result = String::with_capacity(total_len);
197                for text in parts.iter().filter_map(ContentPart::as_text) {
198                    result.push_str(text);
199                }
200                std::borrow::Cow::Owned(result)
201            }
202        }
203    }
204
205    /// Returns trimmed text content. Avoids allocation when possible.
206    pub fn trim(&self) -> std::borrow::Cow<'_, str> {
207        match self {
208            MessageContent::Text(text) => {
209                let trimmed = text.trim();
210                // Optimization: Only allocate if trim actually changed the string
211                if trimmed.len() == text.len() {
212                    std::borrow::Cow::Borrowed(text)
213                } else {
214                    std::borrow::Cow::Borrowed(trimmed)
215                }
216            }
217            MessageContent::Parts(_) => {
218                // For Parts, we need to get text first, then trim
219                match self.as_text() {
220                    std::borrow::Cow::Borrowed(s) => std::borrow::Cow::Borrowed(s.trim()),
221                    std::borrow::Cow::Owned(s) => {
222                        let trimmed = s.trim();
223                        if trimmed.len() == s.len() {
224                            std::borrow::Cow::Owned(s)
225                        } else {
226                            std::borrow::Cow::Owned(trimmed.to_owned())
227                        }
228                    }
229                }
230            }
231        }
232    }
233
234    pub fn is_empty(&self) -> bool {
235        match self {
236            MessageContent::Text(text) => text.is_empty(),
237            MessageContent::Parts(parts) => {
238                parts.is_empty()
239                    || parts.iter().all(|part| match part {
240                        ContentPart::Text { text } => text.is_empty(),
241                        ContentPart::Image { .. } | ContentPart::File { .. } => false,
242                    })
243            }
244        }
245    }
246
247    pub fn has_images(&self) -> bool {
248        match self {
249            MessageContent::Text(_) => false,
250            MessageContent::Parts(parts) => parts.iter().any(|part| part.is_image()),
251        }
252    }
253
254    /// Returns content with images stripped, preserving text and file parts.
255    /// Returns `None` if the content already contains no images.
256    pub fn without_images(&self) -> Option<MessageContent> {
257        match self {
258            MessageContent::Text(_) => None,
259            MessageContent::Parts(parts) => {
260                let has_image = parts.iter().any(|part| part.is_image());
261                if !has_image {
262                    return None;
263                }
264                let text_parts: Vec<ContentPart> = parts
265                    .iter()
266                    .filter(|part| !part.is_image())
267                    .cloned()
268                    .collect();
269                if text_parts.is_empty() {
270                    Some(MessageContent::Text(String::new()))
271                } else if text_parts.len() == 1 {
272                    if let ContentPart::Text { text } = &text_parts[0] {
273                        Some(MessageContent::Text(text.clone()))
274                    } else {
275                        Some(MessageContent::Parts(text_parts))
276                    }
277                } else {
278                    Some(MessageContent::Parts(text_parts))
279                }
280            }
281        }
282    }
283
284    pub fn get_images(&self) -> Vec<&ContentPart> {
285        match self {
286            MessageContent::Text(_) => vec![],
287            MessageContent::Parts(parts) => parts.iter().filter(|part| part.is_image()).collect(),
288        }
289    }
290}
291
292impl Default for MessageContent {
293    fn default() -> Self {
294        MessageContent::Text(String::new())
295    }
296}
297
298impl From<String> for MessageContent {
299    fn from(value: String) -> Self {
300        MessageContent::Text(value)
301    }
302}
303
304impl From<&str> for MessageContent {
305    fn from(value: &str) -> Self {
306        MessageContent::Text(value.to_owned())
307    }
308}
309
310impl Message {
311    /// Estimate the number of tokens in this message (rough approximation).
312    pub fn estimate_tokens(&self) -> usize {
313        let mut count = 0;
314
315        // Role overhead (approximate)
316        count += 4;
317
318        // Content tokens
319        match &self.content {
320            MessageContent::Text(text) => count += crate::llm::utils::estimate_token_count(text),
321            MessageContent::Parts(parts) => {
322                for part in parts {
323                    match part {
324                        ContentPart::Text { text } => {
325                            count += crate::llm::utils::estimate_token_count(text)
326                        }
327                        ContentPart::Image { .. } | ContentPart::File { .. } => count += 1000, // Rough estimate for images/files
328                    }
329                }
330            }
331        }
332
333        // Tool calls tokens
334        if let Some(tool_calls) = &self.tool_calls {
335            for call in tool_calls {
336                count += 20; // Base overhead per call
337                if let Some(func) = &call.function {
338                    count += crate::llm::utils::estimate_token_count(&func.name);
339                    count += crate::llm::utils::estimate_token_count(&func.arguments);
340                }
341                if let Some(sig) = &call.thought_signature {
342                    count += crate::llm::utils::estimate_token_count(sig);
343                }
344            }
345        }
346
347        // Tool call ID (for responses)
348        if let Some(id) = &self.tool_call_id {
349            count += crate::llm::utils::estimate_token_count(id);
350        }
351
352        if let Some(phase) = self.phase {
353            count += crate::llm::utils::estimate_token_count(phase.as_str());
354        }
355
356        count
357    }
358
359    /// Helper to create a base message with common defaults.
360    /// Public for use in provider implementations.
361    #[inline]
362    pub const fn base(role: MessageRole, content: MessageContent) -> Self {
363        Self {
364            role,
365            content,
366            reasoning: None,
367            reasoning_details: None,
368            tool_calls: None,
369            tool_call_id: None,
370            phase: None,
371            origin_tool: None,
372        }
373    }
374
375    /// Create a user message with text content
376    #[inline]
377    pub fn user(content: String) -> Self {
378        Self::base(MessageRole::User, MessageContent::Text(content))
379    }
380
381    /// Create a user message with multiple content parts (text and images)
382    #[inline]
383    pub fn user_with_parts(content_parts: Vec<ContentPart>) -> Self {
384        Self::base(MessageRole::User, MessageContent::Parts(content_parts))
385    }
386
387    /// Create an assistant message with text content
388    #[inline]
389    pub fn assistant(content: String) -> Self {
390        Self::base(MessageRole::Assistant, MessageContent::Text(content))
391    }
392
393    /// Create an assistant message with multiple content parts
394    #[inline]
395    pub fn assistant_with_parts(content_parts: Vec<ContentPart>) -> Self {
396        Self::base(MessageRole::Assistant, MessageContent::Parts(content_parts))
397    }
398
399    /// Create an assistant message with tool calls
400    /// Based on OpenAI Cookbook patterns for function calling
401    #[inline]
402    pub fn assistant_with_tools(content: String, tool_calls: Vec<ToolCall>) -> Self {
403        Self {
404            tool_calls: Some(tool_calls),
405            ..Self::base(MessageRole::Assistant, MessageContent::Text(content))
406        }
407    }
408
409    /// Create an assistant message with tool calls and multiple content parts
410    #[inline]
411    pub fn assistant_with_tools_and_parts(
412        content_parts: Vec<ContentPart>,
413        tool_calls: Vec<ToolCall>,
414    ) -> Self {
415        Self {
416            tool_calls: Some(tool_calls),
417            ..Self::base(MessageRole::Assistant, MessageContent::Parts(content_parts))
418        }
419    }
420
421    /// Create an assistant message with tool calls and reasoning details
422    /// Used for preserving reasoning state in multi-turn conversations
423    #[inline]
424    pub fn assistant_with_tools_and_reasoning(
425        content: String,
426        tool_calls: Vec<ToolCall>,
427        reasoning_details: Option<Vec<serde_json::Value>>,
428    ) -> Self {
429        Self {
430            tool_calls: Some(tool_calls),
431            reasoning_details,
432            ..Self::base(MessageRole::Assistant, MessageContent::Text(content))
433        }
434    }
435
436    /// Create a system message
437    #[inline]
438    pub fn system(content: String) -> Self {
439        Self::base(MessageRole::System, MessageContent::Text(content))
440    }
441
442    /// Create a tool response message
443    /// This follows the exact pattern from OpenAI Cookbook:
444    /// ```json
445    /// {
446    ///   "role": "tool",
447    ///   "tool_call_id": "call_123",
448    ///   "content": "Function result"
449    /// }
450    /// ```
451    #[inline]
452    pub fn tool_response(tool_call_id: String, content: String) -> Self {
453        Self {
454            tool_call_id: Some(tool_call_id),
455            ..Self::base(MessageRole::Tool, MessageContent::Text(content))
456        }
457    }
458
459    /// Create a tool response message with function name (for compatibility)
460    /// Some providers might need the function name in addition to tool_call_id
461    #[inline]
462    pub fn tool_response_with_name(
463        tool_call_id: String,
464        _function_name: String,
465        content: String,
466    ) -> Self {
467        // We can store the function name in the content metadata or handle it provider-specifically
468        Self::tool_response(tool_call_id, content)
469    }
470
471    /// Create a tool response message with origin tool tracking
472    /// The origin_tool field helps with tool-aware context retention
473    #[inline]
474    pub fn tool_response_with_origin(
475        tool_call_id: String,
476        content: String,
477        origin_tool: String,
478    ) -> Self {
479        Self {
480            tool_call_id: Some(tool_call_id),
481            origin_tool: Some(origin_tool),
482            ..Self::base(MessageRole::Tool, MessageContent::Text(content))
483        }
484    }
485
486    /// Create a user message with image from a local file
487    pub async fn user_with_local_image<P: AsRef<std::path::Path>>(
488        file_path: P,
489    ) -> Result<Self, anyhow::Error> {
490        let image_data = crate::utils::image_processing::read_image_file(file_path).await?;
491        let image_part = ContentPart::image(image_data.base64_data, image_data.mime_type);
492        Ok(Self::user_with_parts(vec![image_part]))
493    }
494
495    /// Create a user message with text and a local image
496    pub async fn user_with_text_and_local_image<P: AsRef<std::path::Path>>(
497        text: String,
498        file_path: P,
499    ) -> Result<Self, anyhow::Error> {
500        let image_data = crate::utils::image_processing::read_image_file(file_path).await?;
501        let text_part = ContentPart::text(text);
502        let image_part = ContentPart::image(image_data.base64_data, image_data.mime_type);
503        Ok(Self::user_with_parts(vec![text_part, image_part]))
504    }
505
506    /// Attach provider-visible reasoning trace for archival without affecting payloads.
507    pub fn with_reasoning(mut self, reasoning: Option<String>) -> Self {
508        if self.role == MessageRole::Assistant
509            && let Some(reasoning_text) = reasoning.as_ref()
510        {
511            let cleaned_reasoning = clean_reasoning_text(reasoning_text);
512            if !cleaned_reasoning.is_empty() {
513                let cleaned_content = clean_reasoning_text(self.content.as_text().as_ref());
514                if !cleaned_content.is_empty() && cleaned_reasoning == cleaned_content {
515                    self.reasoning = None;
516                    return self;
517                }
518            }
519        }
520        self.reasoning = reasoning;
521        self
522    }
523
524    /// Attach tool calls to this message.
525    pub fn with_tool_calls(mut self, tool_calls: Vec<ToolCall>) -> Self {
526        self.tool_calls = Some(tool_calls);
527        self
528    }
529
530    /// Attach reasoning details for providers that support structured reasoning
531    pub fn with_reasoning_details(
532        mut self,
533        reasoning_details: Option<Vec<serde_json::Value>>,
534    ) -> Self {
535        self.reasoning_details = reasoning_details;
536        self
537    }
538
539    /// Attach assistant phase metadata for providers that support it.
540    #[must_use]
541    pub fn with_phase(mut self, phase: Option<AssistantPhase>) -> Self {
542        self.phase = if self.role == MessageRole::Assistant {
543            phase
544        } else {
545            None
546        };
547        self
548    }
549
550    /// Validate this message for a specific provider
551    /// Based on official API documentation constraints
552    pub fn validate_for_provider(&self, provider: &str) -> Result<(), String> {
553        // Check role-specific constraints
554        self.role
555            .validate_for_provider(provider, self.tool_call_id.is_some())?;
556
557        // Check tool call constraints
558        if let Some(tool_calls) = &self.tool_calls {
559            if !self.role.can_make_tool_calls() {
560                return Err(format!("Role {:?} cannot make tool calls", self.role));
561            }
562
563            if tool_calls.is_empty() {
564                return Err("Tool calls array should not be empty".to_owned());
565            }
566
567            // Validate each tool call
568            for tool_call in tool_calls {
569                tool_call.validate()?;
570            }
571        }
572
573        // Provider-specific validations based on official docs
574        match provider {
575            "openai" | "openrouter" | "zai" | "stepfun" | "evolink" => {
576                if self.role == MessageRole::Tool && self.tool_call_id.is_none() {
577                    return Err(format!(
578                        "{} requires tool_call_id for tool messages",
579                        provider
580                    ));
581                }
582            }
583            "gemini" => {
584                if self.role == MessageRole::Tool && self.tool_call_id.is_none() {
585                    return Err(
586                        "Gemini tool responses need tool_call_id for function name mapping"
587                            .to_owned(),
588                    );
589                }
590                // Gemini has additional constraints on content structure
591                if self.role == MessageRole::System && !self.content.as_text().is_empty() {
592                    // System messages should be handled as systemInstruction, not in contents
593                }
594            }
595            "anthropic" => {
596                // Anthropic is more flexible with tool message format
597                // Tool messages are converted to user messages anyway
598            }
599            _ => {} // Generic validation already done above
600        }
601
602        Ok(())
603    }
604
605    /// Check if this message has tool calls
606    pub fn has_tool_calls(&self) -> bool {
607        self.tool_calls
608            .as_ref()
609            .is_some_and(|calls| !calls.is_empty())
610    }
611
612    /// Get the tool calls if present
613    pub fn get_tool_calls(&self) -> Option<&[ToolCall]> {
614        self.tool_calls.as_deref()
615    }
616
617    /// Check if this is a tool response message
618    pub fn is_tool_response(&self) -> bool {
619        self.role == MessageRole::Tool
620    }
621
622    /// Get the text content of the message (for backward compatibility)
623    pub fn get_text_content(&self) -> std::borrow::Cow<'_, str> {
624        self.content.as_text()
625    }
626
627    /// Check if this message contains images
628    pub fn has_images(&self) -> bool {
629        self.content.has_images()
630    }
631
632    /// Get all images in this message
633    pub fn get_images(&self) -> Vec<&ContentPart> {
634        self.content.get_images()
635    }
636}
637
638#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
639pub enum MessageRole {
640    System,
641    #[default]
642    User,
643    Assistant,
644    Tool,
645}
646
647impl std::fmt::Display for MessageRole {
648    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
649        match self {
650            MessageRole::System => write!(f, "system"),
651            MessageRole::User => write!(f, "user"),
652            MessageRole::Assistant => write!(f, "assistant"),
653            MessageRole::Tool => write!(f, "tool"),
654        }
655    }
656}
657
658impl MessageRole {
659    /// Get the role string for Gemini API
660    /// Note: Gemini API has specific constraints on message roles
661    /// - Only accepts "user" and "model" roles in conversations
662    /// - System messages are handled separately as system instructions
663    /// - Tool responses are sent as "user" role with function response format
664    pub fn as_gemini_str(&self) -> &'static str {
665        match self {
666            MessageRole::System => "system", // Handled as systemInstruction, not in contents
667            MessageRole::User => "user",
668            MessageRole::Assistant => "model", // Gemini uses "model" instead of "assistant"
669            MessageRole::Tool => "user", // Tool responses are sent as user messages with functionResponse
670        }
671    }
672
673    /// Get the role string for OpenAI API
674    /// OpenAI supports all standard role types including:
675    /// - system, user, assistant, tool
676    /// - function (legacy, now replaced by tool)
677    pub fn as_openai_str(&self) -> &'static str {
678        match self {
679            MessageRole::System => "system",
680            MessageRole::User => "user",
681            MessageRole::Assistant => "assistant",
682            MessageRole::Tool => "tool", // Full support for tool role with tool_call_id
683        }
684    }
685
686    /// Get the role string for Anthropic API
687    /// Anthropic has specific handling for tool messages:
688    /// - Supports user, assistant roles normally
689    /// - Tool responses are treated as user messages
690    /// - System messages can be handled as system parameter or hoisted
691    pub fn as_anthropic_str(&self) -> &'static str {
692        match self {
693            MessageRole::System => "system", // Can be hoisted to system parameter
694            MessageRole::User => "user",
695            MessageRole::Assistant => "assistant",
696            MessageRole::Tool => "user", // Anthropic treats tool responses as user messages
697        }
698    }
699
700    /// Get the role string for generic OpenAI-compatible providers
701    /// Most providers follow OpenAI's role conventions
702    pub fn as_generic_str(&self) -> &'static str {
703        match self {
704            MessageRole::System => "system",
705            MessageRole::User => "user",
706            MessageRole::Assistant => "assistant",
707            MessageRole::Tool => "tool",
708        }
709    }
710
711    /// Check if this role supports tool calls
712    /// Only Assistant role can initiate tool calls in most APIs
713    pub fn can_make_tool_calls(&self) -> bool {
714        matches!(self, MessageRole::Assistant)
715    }
716
717    /// Check if this role represents a tool response
718    pub fn is_tool_response(&self) -> bool {
719        matches!(self, MessageRole::Tool)
720    }
721
722    /// Validate message role constraints for a given provider
723    /// Based on official API documentation requirements
724    pub fn validate_for_provider(
725        &self,
726        provider: &str,
727        has_tool_call_id: bool,
728    ) -> Result<(), String> {
729        match (self, provider) {
730            (MessageRole::Tool, provider)
731                if matches!(provider, "openai" | "openrouter" | "deepseek" | "zai")
732                    && !has_tool_call_id =>
733            {
734                Err(format!("{} tool messages must have tool_call_id", provider))
735            }
736            (MessageRole::Tool, "gemini") if !has_tool_call_id => {
737                Err("Gemini tool messages need tool_call_id for function mapping".to_owned())
738            }
739            _ => Ok(()),
740        }
741    }
742}
743
744#[cfg(test)]
745mod tests {
746    use super::{AssistantPhase, ContentPart, Message, MessageContent, MessageRole, ToolCall};
747
748    #[test]
749    fn message_content_parts_concatenate_without_extra_spaces() {
750        let parts = vec![
751            ContentPart::text("Andre".to_string()),
752            ContentPart::text("j".to_string()),
753            ContentPart::text(" Kar".to_string()),
754            ContentPart::text("pathy".to_string()),
755            ContentPart::text("'s".to_string()),
756        ];
757        let content = MessageContent::Parts(parts);
758
759        assert_eq!(content.as_text().as_ref() as &str, "Andrej Karpathy's");
760    }
761
762    #[test]
763    fn message_content_parts_with_single_text_stays_borrowed() {
764        let content = MessageContent::Parts(vec![ContentPart::text("borrowed".to_string())]);
765
766        assert!(matches!(
767            content.as_text(),
768            std::borrow::Cow::Borrowed("borrowed")
769        ));
770    }
771
772    #[test]
773    fn message_content_parts_without_text_stays_borrowed_empty() {
774        let content = MessageContent::Parts(vec![ContentPart::image(
775            "encoded".to_string(),
776            "image/png".to_string(),
777        )]);
778
779        assert!(matches!(content.as_text(), std::borrow::Cow::Borrowed("")));
780    }
781
782    #[test]
783    fn assistant_phase_parses_wire_strings() {
784        assert_eq!(
785            AssistantPhase::from_wire_str("commentary"),
786            Some(AssistantPhase::Commentary)
787        );
788        assert_eq!(
789            AssistantPhase::from_wire_str("final_answer"),
790            Some(AssistantPhase::FinalAnswer)
791        );
792        assert_eq!(AssistantPhase::from_wire_str("other"), None);
793    }
794
795    #[test]
796    fn with_phase_ignores_non_assistant_roles() {
797        let user = Message::user("hello".to_string()).with_phase(Some(AssistantPhase::Commentary));
798        let tool = Message::tool_response("call_1".to_string(), "ok".to_string())
799            .with_phase(Some(AssistantPhase::FinalAnswer));
800
801        assert_eq!(user.role, MessageRole::User);
802        assert!(user.phase.is_none());
803        assert_eq!(tool.role, MessageRole::Tool);
804        assert!(tool.phase.is_none());
805    }
806
807    #[test]
808    fn validate_for_provider_accepts_recovered_tool_arguments() {
809        let message = Message::assistant_with_tools(
810            String::new(),
811            vec![ToolCall::function(
812                "call_search".to_string(),
813                "unified_search".to_string(),
814                "{\"action\": \"grep\", \"pattern\": \"persistent_memory\", \"path\": \"vtcode-core/src</parameter>\n<</invoke>\n</minimax:tool_call>".to_string(),
815            )],
816        );
817
818        message.validate_for_provider("anthropic").unwrap();
819    }
820}