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    pub fn get_images(&self) -> Vec<&ContentPart> {
255        match self {
256            MessageContent::Text(_) => vec![],
257            MessageContent::Parts(parts) => parts.iter().filter(|part| part.is_image()).collect(),
258        }
259    }
260}
261
262impl Default for MessageContent {
263    fn default() -> Self {
264        MessageContent::Text(String::new())
265    }
266}
267
268impl From<String> for MessageContent {
269    fn from(value: String) -> Self {
270        MessageContent::Text(value)
271    }
272}
273
274impl From<&str> for MessageContent {
275    fn from(value: &str) -> Self {
276        MessageContent::Text(value.to_owned())
277    }
278}
279
280impl Message {
281    /// Estimate the number of tokens in this message (rough approximation).
282    pub fn estimate_tokens(&self) -> usize {
283        let mut count = 0;
284
285        // Role overhead (approximate)
286        count += 4;
287
288        // Content tokens
289        match &self.content {
290            MessageContent::Text(text) => count += crate::llm::utils::estimate_token_count(text),
291            MessageContent::Parts(parts) => {
292                for part in parts {
293                    match part {
294                        ContentPart::Text { text } => {
295                            count += crate::llm::utils::estimate_token_count(text)
296                        }
297                        ContentPart::Image { .. } | ContentPart::File { .. } => count += 1000, // Rough estimate for images/files
298                    }
299                }
300            }
301        }
302
303        // Tool calls tokens
304        if let Some(tool_calls) = &self.tool_calls {
305            for call in tool_calls {
306                count += 20; // Base overhead per call
307                if let Some(func) = &call.function {
308                    count += crate::llm::utils::estimate_token_count(&func.name);
309                    count += crate::llm::utils::estimate_token_count(&func.arguments);
310                }
311                if let Some(sig) = &call.thought_signature {
312                    count += crate::llm::utils::estimate_token_count(sig);
313                }
314            }
315        }
316
317        // Tool call ID (for responses)
318        if let Some(id) = &self.tool_call_id {
319            count += crate::llm::utils::estimate_token_count(id);
320        }
321
322        if let Some(phase) = self.phase {
323            count += crate::llm::utils::estimate_token_count(phase.as_str());
324        }
325
326        count
327    }
328
329    /// Helper to create a base message with common defaults.
330    /// Public for use in provider implementations.
331    #[inline]
332    pub const fn base(role: MessageRole, content: MessageContent) -> Self {
333        Self {
334            role,
335            content,
336            reasoning: None,
337            reasoning_details: None,
338            tool_calls: None,
339            tool_call_id: None,
340            phase: None,
341            origin_tool: None,
342        }
343    }
344
345    /// Create a user message with text content
346    #[inline]
347    pub fn user(content: String) -> Self {
348        Self::base(MessageRole::User, MessageContent::Text(content))
349    }
350
351    /// Create a user message with multiple content parts (text and images)
352    #[inline]
353    pub fn user_with_parts(content_parts: Vec<ContentPart>) -> Self {
354        Self::base(MessageRole::User, MessageContent::Parts(content_parts))
355    }
356
357    /// Create an assistant message with text content
358    #[inline]
359    pub fn assistant(content: String) -> Self {
360        Self::base(MessageRole::Assistant, MessageContent::Text(content))
361    }
362
363    /// Create an assistant message with multiple content parts
364    #[inline]
365    pub fn assistant_with_parts(content_parts: Vec<ContentPart>) -> Self {
366        Self::base(MessageRole::Assistant, MessageContent::Parts(content_parts))
367    }
368
369    /// Create an assistant message with tool calls
370    /// Based on OpenAI Cookbook patterns for function calling
371    #[inline]
372    pub fn assistant_with_tools(content: String, tool_calls: Vec<ToolCall>) -> Self {
373        Self {
374            tool_calls: Some(tool_calls),
375            ..Self::base(MessageRole::Assistant, MessageContent::Text(content))
376        }
377    }
378
379    /// Create an assistant message with tool calls and multiple content parts
380    #[inline]
381    pub fn assistant_with_tools_and_parts(
382        content_parts: Vec<ContentPart>,
383        tool_calls: Vec<ToolCall>,
384    ) -> Self {
385        Self {
386            tool_calls: Some(tool_calls),
387            ..Self::base(MessageRole::Assistant, MessageContent::Parts(content_parts))
388        }
389    }
390
391    /// Create an assistant message with tool calls and reasoning details
392    /// Used for preserving reasoning state in multi-turn conversations
393    #[inline]
394    pub fn assistant_with_tools_and_reasoning(
395        content: String,
396        tool_calls: Vec<ToolCall>,
397        reasoning_details: Option<Vec<serde_json::Value>>,
398    ) -> Self {
399        Self {
400            tool_calls: Some(tool_calls),
401            reasoning_details,
402            ..Self::base(MessageRole::Assistant, MessageContent::Text(content))
403        }
404    }
405
406    /// Create a system message
407    #[inline]
408    pub fn system(content: String) -> Self {
409        Self::base(MessageRole::System, MessageContent::Text(content))
410    }
411
412    /// Create a tool response message
413    /// This follows the exact pattern from OpenAI Cookbook:
414    /// ```json
415    /// {
416    ///   "role": "tool",
417    ///   "tool_call_id": "call_123",
418    ///   "content": "Function result"
419    /// }
420    /// ```
421    #[inline]
422    pub fn tool_response(tool_call_id: String, content: String) -> Self {
423        Self {
424            tool_call_id: Some(tool_call_id),
425            ..Self::base(MessageRole::Tool, MessageContent::Text(content))
426        }
427    }
428
429    /// Create a tool response message with function name (for compatibility)
430    /// Some providers might need the function name in addition to tool_call_id
431    #[inline]
432    pub fn tool_response_with_name(
433        tool_call_id: String,
434        _function_name: String,
435        content: String,
436    ) -> Self {
437        // We can store the function name in the content metadata or handle it provider-specifically
438        Self::tool_response(tool_call_id, content)
439    }
440
441    /// Create a tool response message with origin tool tracking
442    /// The origin_tool field helps with tool-aware context retention
443    #[inline]
444    pub fn tool_response_with_origin(
445        tool_call_id: String,
446        content: String,
447        origin_tool: String,
448    ) -> Self {
449        Self {
450            tool_call_id: Some(tool_call_id),
451            origin_tool: Some(origin_tool),
452            ..Self::base(MessageRole::Tool, MessageContent::Text(content))
453        }
454    }
455
456    /// Create a user message with image from a local file
457    pub async fn user_with_local_image<P: AsRef<std::path::Path>>(
458        file_path: P,
459    ) -> Result<Self, anyhow::Error> {
460        let image_data = crate::utils::image_processing::read_image_file(file_path).await?;
461        let image_part = ContentPart::image(image_data.base64_data, image_data.mime_type);
462        Ok(Self::user_with_parts(vec![image_part]))
463    }
464
465    /// Create a user message with text and a local image
466    pub async fn user_with_text_and_local_image<P: AsRef<std::path::Path>>(
467        text: String,
468        file_path: P,
469    ) -> Result<Self, anyhow::Error> {
470        let image_data = crate::utils::image_processing::read_image_file(file_path).await?;
471        let text_part = ContentPart::text(text);
472        let image_part = ContentPart::image(image_data.base64_data, image_data.mime_type);
473        Ok(Self::user_with_parts(vec![text_part, image_part]))
474    }
475
476    /// Attach provider-visible reasoning trace for archival without affecting payloads.
477    pub fn with_reasoning(mut self, reasoning: Option<String>) -> Self {
478        if self.role == MessageRole::Assistant
479            && let Some(reasoning_text) = reasoning.as_ref()
480        {
481            let cleaned_reasoning = clean_reasoning_text(reasoning_text);
482            if !cleaned_reasoning.is_empty() {
483                let cleaned_content = clean_reasoning_text(self.content.as_text().as_ref());
484                if !cleaned_content.is_empty() && cleaned_reasoning == cleaned_content {
485                    self.reasoning = None;
486                    return self;
487                }
488            }
489        }
490        self.reasoning = reasoning;
491        self
492    }
493
494    /// Attach tool calls to this message.
495    pub fn with_tool_calls(mut self, tool_calls: Vec<ToolCall>) -> Self {
496        self.tool_calls = Some(tool_calls);
497        self
498    }
499
500    /// Attach reasoning details for providers that support structured reasoning
501    pub fn with_reasoning_details(
502        mut self,
503        reasoning_details: Option<Vec<serde_json::Value>>,
504    ) -> Self {
505        self.reasoning_details = reasoning_details;
506        self
507    }
508
509    /// Attach assistant phase metadata for providers that support it.
510    #[must_use]
511    pub fn with_phase(mut self, phase: Option<AssistantPhase>) -> Self {
512        self.phase = if self.role == MessageRole::Assistant {
513            phase
514        } else {
515            None
516        };
517        self
518    }
519
520    /// Validate this message for a specific provider
521    /// Based on official API documentation constraints
522    pub fn validate_for_provider(&self, provider: &str) -> Result<(), String> {
523        // Check role-specific constraints
524        self.role
525            .validate_for_provider(provider, self.tool_call_id.is_some())?;
526
527        // Check tool call constraints
528        if let Some(tool_calls) = &self.tool_calls {
529            if !self.role.can_make_tool_calls() {
530                return Err(format!("Role {:?} cannot make tool calls", self.role));
531            }
532
533            if tool_calls.is_empty() {
534                return Err("Tool calls array should not be empty".to_owned());
535            }
536
537            // Validate each tool call
538            for tool_call in tool_calls {
539                tool_call.validate()?;
540            }
541        }
542
543        // Provider-specific validations based on official docs
544        match provider {
545            "openai" | "openrouter" | "zai" | "stepfun" | "evolink" => {
546                if self.role == MessageRole::Tool && self.tool_call_id.is_none() {
547                    return Err(format!(
548                        "{} requires tool_call_id for tool messages",
549                        provider
550                    ));
551                }
552            }
553            "gemini" => {
554                if self.role == MessageRole::Tool && self.tool_call_id.is_none() {
555                    return Err(
556                        "Gemini tool responses need tool_call_id for function name mapping"
557                            .to_owned(),
558                    );
559                }
560                // Gemini has additional constraints on content structure
561                if self.role == MessageRole::System && !self.content.as_text().is_empty() {
562                    // System messages should be handled as systemInstruction, not in contents
563                }
564            }
565            "anthropic" => {
566                // Anthropic is more flexible with tool message format
567                // Tool messages are converted to user messages anyway
568            }
569            _ => {} // Generic validation already done above
570        }
571
572        Ok(())
573    }
574
575    /// Check if this message has tool calls
576    pub fn has_tool_calls(&self) -> bool {
577        self.tool_calls
578            .as_ref()
579            .is_some_and(|calls| !calls.is_empty())
580    }
581
582    /// Get the tool calls if present
583    pub fn get_tool_calls(&self) -> Option<&[ToolCall]> {
584        self.tool_calls.as_deref()
585    }
586
587    /// Check if this is a tool response message
588    pub fn is_tool_response(&self) -> bool {
589        self.role == MessageRole::Tool
590    }
591
592    /// Get the text content of the message (for backward compatibility)
593    pub fn get_text_content(&self) -> std::borrow::Cow<'_, str> {
594        self.content.as_text()
595    }
596
597    /// Check if this message contains images
598    pub fn has_images(&self) -> bool {
599        self.content.has_images()
600    }
601
602    /// Get all images in this message
603    pub fn get_images(&self) -> Vec<&ContentPart> {
604        self.content.get_images()
605    }
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
609pub enum MessageRole {
610    System,
611    #[default]
612    User,
613    Assistant,
614    Tool,
615}
616
617impl std::fmt::Display for MessageRole {
618    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
619        match self {
620            MessageRole::System => write!(f, "system"),
621            MessageRole::User => write!(f, "user"),
622            MessageRole::Assistant => write!(f, "assistant"),
623            MessageRole::Tool => write!(f, "tool"),
624        }
625    }
626}
627
628impl MessageRole {
629    /// Get the role string for Gemini API
630    /// Note: Gemini API has specific constraints on message roles
631    /// - Only accepts "user" and "model" roles in conversations
632    /// - System messages are handled separately as system instructions
633    /// - Tool responses are sent as "user" role with function response format
634    pub fn as_gemini_str(&self) -> &'static str {
635        match self {
636            MessageRole::System => "system", // Handled as systemInstruction, not in contents
637            MessageRole::User => "user",
638            MessageRole::Assistant => "model", // Gemini uses "model" instead of "assistant"
639            MessageRole::Tool => "user", // Tool responses are sent as user messages with functionResponse
640        }
641    }
642
643    /// Get the role string for OpenAI API
644    /// OpenAI supports all standard role types including:
645    /// - system, user, assistant, tool
646    /// - function (legacy, now replaced by tool)
647    pub fn as_openai_str(&self) -> &'static str {
648        match self {
649            MessageRole::System => "system",
650            MessageRole::User => "user",
651            MessageRole::Assistant => "assistant",
652            MessageRole::Tool => "tool", // Full support for tool role with tool_call_id
653        }
654    }
655
656    /// Get the role string for Anthropic API
657    /// Anthropic has specific handling for tool messages:
658    /// - Supports user, assistant roles normally
659    /// - Tool responses are treated as user messages
660    /// - System messages can be handled as system parameter or hoisted
661    pub fn as_anthropic_str(&self) -> &'static str {
662        match self {
663            MessageRole::System => "system", // Can be hoisted to system parameter
664            MessageRole::User => "user",
665            MessageRole::Assistant => "assistant",
666            MessageRole::Tool => "user", // Anthropic treats tool responses as user messages
667        }
668    }
669
670    /// Get the role string for generic OpenAI-compatible providers
671    /// Most providers follow OpenAI's role conventions
672    pub fn as_generic_str(&self) -> &'static str {
673        match self {
674            MessageRole::System => "system",
675            MessageRole::User => "user",
676            MessageRole::Assistant => "assistant",
677            MessageRole::Tool => "tool",
678        }
679    }
680
681    /// Check if this role supports tool calls
682    /// Only Assistant role can initiate tool calls in most APIs
683    pub fn can_make_tool_calls(&self) -> bool {
684        matches!(self, MessageRole::Assistant)
685    }
686
687    /// Check if this role represents a tool response
688    pub fn is_tool_response(&self) -> bool {
689        matches!(self, MessageRole::Tool)
690    }
691
692    /// Validate message role constraints for a given provider
693    /// Based on official API documentation requirements
694    pub fn validate_for_provider(
695        &self,
696        provider: &str,
697        has_tool_call_id: bool,
698    ) -> Result<(), String> {
699        match (self, provider) {
700            (MessageRole::Tool, provider)
701                if matches!(provider, "openai" | "openrouter" | "deepseek" | "zai")
702                    && !has_tool_call_id =>
703            {
704                Err(format!("{} tool messages must have tool_call_id", provider))
705            }
706            (MessageRole::Tool, "gemini") if !has_tool_call_id => {
707                Err("Gemini tool messages need tool_call_id for function mapping".to_owned())
708            }
709            _ => Ok(()),
710        }
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use super::{AssistantPhase, ContentPart, Message, MessageContent, MessageRole, ToolCall};
717
718    #[test]
719    fn message_content_parts_concatenate_without_extra_spaces() {
720        let parts = vec![
721            ContentPart::text("Andre".to_string()),
722            ContentPart::text("j".to_string()),
723            ContentPart::text(" Kar".to_string()),
724            ContentPart::text("pathy".to_string()),
725            ContentPart::text("'s".to_string()),
726        ];
727        let content = MessageContent::Parts(parts);
728
729        assert_eq!(content.as_text().as_ref() as &str, "Andrej Karpathy's");
730    }
731
732    #[test]
733    fn message_content_parts_with_single_text_stays_borrowed() {
734        let content = MessageContent::Parts(vec![ContentPart::text("borrowed".to_string())]);
735
736        assert!(matches!(
737            content.as_text(),
738            std::borrow::Cow::Borrowed("borrowed")
739        ));
740    }
741
742    #[test]
743    fn message_content_parts_without_text_stays_borrowed_empty() {
744        let content = MessageContent::Parts(vec![ContentPart::image(
745            "encoded".to_string(),
746            "image/png".to_string(),
747        )]);
748
749        assert!(matches!(content.as_text(), std::borrow::Cow::Borrowed("")));
750    }
751
752    #[test]
753    fn assistant_phase_parses_wire_strings() {
754        assert_eq!(
755            AssistantPhase::from_wire_str("commentary"),
756            Some(AssistantPhase::Commentary)
757        );
758        assert_eq!(
759            AssistantPhase::from_wire_str("final_answer"),
760            Some(AssistantPhase::FinalAnswer)
761        );
762        assert_eq!(AssistantPhase::from_wire_str("other"), None);
763    }
764
765    #[test]
766    fn with_phase_ignores_non_assistant_roles() {
767        let user = Message::user("hello".to_string()).with_phase(Some(AssistantPhase::Commentary));
768        let tool = Message::tool_response("call_1".to_string(), "ok".to_string())
769            .with_phase(Some(AssistantPhase::FinalAnswer));
770
771        assert_eq!(user.role, MessageRole::User);
772        assert!(user.phase.is_none());
773        assert_eq!(tool.role, MessageRole::Tool);
774        assert!(tool.phase.is_none());
775    }
776
777    #[test]
778    fn validate_for_provider_accepts_recovered_tool_arguments() {
779        let message = Message::assistant_with_tools(
780            String::new(),
781            vec![ToolCall::function(
782                "call_search".to_string(),
783                "unified_search".to_string(),
784                "{\"action\": \"grep\", \"pattern\": \"persistent_memory\", \"path\": \"vtcode-core/src</parameter>\n<</invoke>\n</minimax:tool_call>".to_string(),
785            )],
786        );
787
788        message.validate_for_provider("anthropic").unwrap();
789    }
790}