1use super::ToolCall;
2use crate::providers::clean_reasoning_text;
3use serde::{Deserialize, Serialize};
4use vtcode_commons::message_metadata::MessageMetadata;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum AssistantPhase {
10 Commentary,
11 FinalAnswer,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum MessageClearAt {
22 NextUserMessage,
23}
24
25impl AssistantPhase {
26 #[must_use]
27 pub(crate) const fn as_str(self) -> &'static str {
28 match self {
29 Self::Commentary => "commentary",
30 Self::FinalAnswer => "final_answer",
31 }
32 }
33
34 #[must_use]
35 pub(crate) fn from_wire_str(value: &str) -> Option<Self> {
36 match value {
37 "commentary" => Some(Self::Commentary),
38 "final_answer" => Some(Self::FinalAnswer),
39 _ => None,
40 }
41 }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "lowercase")]
49pub enum ImageDetail {
50 #[serde(rename = "low")]
51 Low,
52 #[serde(rename = "high")]
53 High,
54 #[serde(rename = "original")]
55 Original,
56 #[serde(rename = "auto")]
57 Auto,
58}
59
60impl ImageDetail {
61 pub fn as_str(&self) -> &'static str {
62 match self {
63 Self::Low => "low",
64 Self::High => "high",
65 Self::Original => "original",
66 Self::Auto => "auto",
67 }
68 }
69
70 #[allow(
71 clippy::should_implement_trait,
72 reason = "preserve the public compatibility parser API"
73 )]
74 pub fn from_str(s: &str) -> Option<Self> {
75 match s.trim().to_ascii_lowercase().as_str() {
76 "low" => Some(Self::Low),
77 "high" => Some(Self::High),
78 "original" => Some(Self::Original),
79 "auto" => Some(Self::Auto),
80 _ => None,
81 }
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum ImageSource<'a> {
92 Base64 { data: &'a str, mime_type: &'a str },
93 Url { url: &'a str },
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(untagged)]
99pub enum ContentPart {
100 Text {
101 text: String,
102 },
103 Image {
104 data: String, mime_type: String, #[serde(rename = "type")]
107 content_type: String, #[serde(default, skip_serializing_if = "Option::is_none")]
109 detail: Option<ImageDetail>, #[serde(default, skip_serializing_if = "Option::is_none")]
111 image_url: Option<String>, },
113 File {
114 #[serde(rename = "type")]
115 content_type: String, #[serde(default, skip_serializing_if = "Option::is_none")]
117 filename: Option<String>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 file_id: Option<String>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 file_data: Option<String>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 file_url: Option<String>,
124 },
125}
126
127impl ContentPart {
128 pub fn text(text: String) -> Self {
129 ContentPart::Text { text }
130 }
131
132 pub fn image(data: String, mime_type: String) -> Self {
133 ContentPart::Image {
134 data,
135 mime_type,
136 content_type: "image".to_owned(),
137 detail: None,
138 image_url: None,
139 }
140 }
141
142 pub fn image_with_detail(data: String, mime_type: String, detail: ImageDetail) -> Self {
143 ContentPart::Image {
144 data,
145 mime_type,
146 content_type: "image".to_owned(),
147 detail: Some(detail),
148 image_url: None,
149 }
150 }
151
152 pub fn image_from_url(url: String, detail: Option<ImageDetail>) -> Result<Self, String> {
157 let trimmed = url.trim();
158 if trimmed.is_empty() {
159 return Err("image URL must not be empty".to_owned());
160 }
161 if trimmed.len() > 8192 {
162 return Err(format!("image URL exceeds 8192 char limit (len={})", trimmed.len()));
163 }
164 if !(trimmed.starts_with("https://") || trimmed.starts_with("http://")) {
165 return Err(format!("image URL must be https://, got {trimmed:?}"));
166 }
167 Ok(ContentPart::Image {
168 data: String::new(),
169 mime_type: String::new(),
170 content_type: "image".to_owned(),
171 detail,
172 image_url: Some(url),
173 })
174 }
175
176 pub fn image_source(&self) -> Option<ImageSource<'_>> {
181 match self {
182 ContentPart::Image { data, mime_type, image_url, .. } => {
183 if let Some(url) = image_url {
184 Some(ImageSource::Url { url })
185 } else if !data.is_empty() && !mime_type.is_empty() {
186 Some(ImageSource::Base64 { data, mime_type })
187 } else {
188 None
189 }
190 }
191 _ => None,
192 }
193 }
194
195 pub fn validate_image(&self) -> Result<(), String> {
200 match self {
201 ContentPart::Image { data, mime_type, image_url, .. } => {
202 if let Some(url) = image_url {
203 if url.len() > 8192 {
204 return Err(format!("image URL exceeds 8192 chars: {}", url.len()));
205 }
206 if !(url.starts_with("https://") || url.starts_with("http://")) {
207 return Err(format!("image URL must be https://: {url:?}"));
208 }
209 } else {
210 if data.is_empty() || mime_type.is_empty() {
211 return Err("image data and mime_type must be non-empty for base64 images".to_owned());
212 }
213 const ALLOWED: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
214 if !ALLOWED.contains(&mime_type.as_str()) {
215 tracing::warn!(mime_type = %mime_type, "image MIME type not in DeepSeek allowlist");
216 }
217 let decoded_approx = data.len() * 3 / 4;
219 if decoded_approx > 32 * 1024 * 1024 {
220 return Err(format!("image exceeds 32MiB limit: ~{} bytes", decoded_approx));
221 }
222 }
223 Ok(())
224 }
225 _ => Ok(()),
226 }
227 }
228
229 pub fn image_with_detail_str(data: String, mime_type: String, detail_str: &str) -> Option<Self> {
232 match ImageDetail::from_str(detail_str) {
233 Some(d) => Some(Self::image_with_detail(data, mime_type, d)),
234 None => {
235 tracing::warn!(detail = %detail_str, "invalid image detail, expected low|high|original|auto");
236 None
237 }
238 }
239 }
240
241 pub(crate) fn file_from_id(file_id: String) -> Self {
242 ContentPart::File {
243 content_type: "file".to_owned(),
244 filename: None,
245 file_id: Some(file_id),
246 file_data: None,
247 file_url: None,
248 }
249 }
250
251 pub fn file_from_url(file_url: String) -> Self {
252 ContentPart::File {
253 content_type: "input_file".to_owned(),
254 filename: None,
255 file_id: None,
256 file_data: None,
257 file_url: Some(file_url),
258 }
259 }
260
261 pub fn file_from_data(filename: String, file_data: String) -> Self {
262 ContentPart::File {
263 content_type: "input_file".to_owned(),
264 filename: Some(filename),
265 file_id: None,
266 file_data: Some(file_data),
267 file_url: None,
268 }
269 }
270
271 pub fn as_text(&self) -> Option<&str> {
272 match self {
273 ContentPart::Text { text } => Some(text),
274 _ => None,
275 }
276 }
277
278 pub(crate) fn is_image(&self) -> bool {
279 matches!(self, ContentPart::Image { .. })
280 }
281
282 pub(crate) fn is_file(&self) -> bool {
283 matches!(self, ContentPart::File { .. })
284 }
285}
286
287#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
289pub struct Message {
290 #[serde(default)]
291 pub role: MessageRole,
292 #[serde(default)]
294 pub content: MessageContent,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub reasoning: Option<String>,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub reasoning_details: Option<Vec<serde_json::Value>>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub tool_calls: Option<Vec<ToolCall>>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub tool_call_id: Option<String>,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub phase: Option<AssistantPhase>,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub origin_tool: Option<String>,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub metadata: Option<MessageMetadata>,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub clear_at: Option<MessageClearAt>,
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(untagged)]
320pub enum MessageContent {
321 Text(String),
323 Parts(Vec<ContentPart>),
325}
326
327impl MessageContent {
328 pub fn text(text: String) -> Self {
329 MessageContent::Text(text)
330 }
331
332 pub fn parts(parts: Vec<ContentPart>) -> Self {
333 MessageContent::Parts(parts)
334 }
335
336 #[inline]
339 pub fn as_text_borrowed(&self) -> Option<&str> {
340 match self {
341 MessageContent::Text(text) => Some(text.as_str()),
342 MessageContent::Parts(_) => None,
343 }
344 }
345
346 pub fn as_text(&self) -> std::borrow::Cow<'_, str> {
349 match self {
350 MessageContent::Text(text) => std::borrow::Cow::Borrowed(text),
351 MessageContent::Parts(parts) => {
352 let mut first_text = None;
353 let mut text_count = 0usize;
354 let mut total_len = 0usize;
355
356 for text in parts.iter().filter_map(ContentPart::as_text) {
357 if first_text.is_none() {
358 first_text = Some(text);
359 }
360 text_count += 1;
361 total_len += text.len();
362 }
363
364 if text_count == 0 {
365 return std::borrow::Cow::Borrowed("");
366 }
367 if text_count == 1 {
368 return std::borrow::Cow::Borrowed(first_text.unwrap_or(""));
369 }
370
371 let mut result = String::with_capacity(total_len);
372 for text in parts.iter().filter_map(ContentPart::as_text) {
373 result.push_str(text);
374 }
375 std::borrow::Cow::Owned(result)
376 }
377 }
378 }
379
380 pub fn trim(&self) -> std::borrow::Cow<'_, str> {
382 match self {
383 MessageContent::Text(text) => {
384 let trimmed = text.trim();
385 if trimmed.len() == text.len() {
387 std::borrow::Cow::Borrowed(text)
388 } else {
389 std::borrow::Cow::Borrowed(trimmed)
390 }
391 }
392 MessageContent::Parts(_) => {
393 match self.as_text() {
395 std::borrow::Cow::Borrowed(s) => std::borrow::Cow::Borrowed(s.trim()),
396 std::borrow::Cow::Owned(s) => {
397 let trimmed = s.trim();
398 if trimmed.len() == s.len() {
399 std::borrow::Cow::Owned(s)
400 } else {
401 std::borrow::Cow::Owned(trimmed.to_owned())
402 }
403 }
404 }
405 }
406 }
407 }
408
409 pub(crate) fn is_empty(&self) -> bool {
410 match self {
411 MessageContent::Text(text) => text.is_empty(),
412 MessageContent::Parts(parts) => {
413 parts.is_empty()
414 || parts.iter().all(|part| match part {
415 ContentPart::Text { text } => text.is_empty(),
416 ContentPart::Image { .. } | ContentPart::File { .. } => false,
417 })
418 }
419 }
420 }
421
422 pub(crate) fn has_images(&self) -> bool {
423 match self {
424 MessageContent::Text(_) => false,
425 MessageContent::Parts(parts) => parts.iter().any(|part| part.is_image()),
426 }
427 }
428
429 pub(crate) fn without_images(&self) -> Option<MessageContent> {
432 match self {
433 MessageContent::Text(_) => None,
434 MessageContent::Parts(parts) => {
435 let has_image = parts.iter().any(|part| part.is_image());
436 if !has_image {
437 return None;
438 }
439 let text_parts: Vec<ContentPart> = parts.iter().filter(|part| !part.is_image()).cloned().collect();
440 if text_parts.is_empty() {
441 Some(MessageContent::Text(String::new()))
442 } else if text_parts.len() == 1 {
443 if let ContentPart::Text { text } = &text_parts[0] {
444 Some(MessageContent::Text(text.clone()))
445 } else {
446 Some(MessageContent::Parts(text_parts))
447 }
448 } else {
449 Some(MessageContent::Parts(text_parts))
450 }
451 }
452 }
453 }
454
455 pub fn get_images(&self) -> Vec<&ContentPart> {
456 match self {
457 MessageContent::Text(_) => vec![],
458 MessageContent::Parts(parts) => parts.iter().filter(|part| part.is_image()).collect(),
459 }
460 }
461}
462
463impl Default for MessageContent {
464 fn default() -> Self {
465 MessageContent::Text(String::new())
466 }
467}
468
469impl From<String> for MessageContent {
470 fn from(value: String) -> Self {
471 MessageContent::Text(value)
472 }
473}
474
475impl From<&str> for MessageContent {
476 fn from(value: &str) -> Self {
477 MessageContent::Text(value.to_owned())
478 }
479}
480
481impl Message {
482 pub fn estimate_tokens(&self) -> usize {
484 let mut count = 0;
485
486 count += 4;
488
489 match &self.content {
491 MessageContent::Text(text) => count += crate::utils::estimate_token_count(text),
492 MessageContent::Parts(parts) => {
493 for part in parts {
494 match part {
495 ContentPart::Text { text } => count += crate::utils::estimate_token_count(text),
496 ContentPart::Image { .. } | ContentPart::File { .. } => count += 1000, }
498 }
499 }
500 }
501
502 if let Some(tool_calls) = &self.tool_calls {
504 for call in tool_calls {
505 count += 20; if let Some(func) = &call.function {
507 count += crate::utils::estimate_token_count(&func.name);
508 count += crate::utils::estimate_token_count(&func.arguments);
509 }
510 if let Some(sig) = &call.thought_signature {
511 count += crate::utils::estimate_token_count(sig);
512 }
513 }
514 }
515
516 if let Some(id) = &self.tool_call_id {
518 count += crate::utils::estimate_token_count(id);
519 }
520
521 if let Some(phase) = self.phase {
522 count += crate::utils::estimate_token_count(phase.as_str());
523 }
524
525 count
526 }
527
528 #[inline]
531 pub(crate) const fn base(role: MessageRole, content: MessageContent) -> Self {
532 Self {
533 role,
534 content,
535 reasoning: None,
536 reasoning_details: None,
537 tool_calls: None,
538 tool_call_id: None,
539 phase: None,
540 origin_tool: None,
541 metadata: None,
542 clear_at: None,
543 }
544 }
545
546 #[inline]
548 pub fn user(content: String) -> Self {
549 Self::base(MessageRole::User, MessageContent::Text(content))
550 }
551
552 #[inline]
554 pub fn user_with_parts(content_parts: Vec<ContentPart>) -> Self {
555 Self::base(MessageRole::User, MessageContent::Parts(content_parts))
556 }
557
558 #[inline]
560 pub fn assistant(content: String) -> Self {
561 Self::base(MessageRole::Assistant, MessageContent::Text(content))
562 }
563
564 #[inline]
566 pub fn assistant_with_parts(content_parts: Vec<ContentPart>) -> Self {
567 Self::base(MessageRole::Assistant, MessageContent::Parts(content_parts))
568 }
569
570 #[inline]
573 pub fn assistant_with_tools(content: String, tool_calls: Vec<ToolCall>) -> Self {
574 Self {
575 tool_calls: Some(tool_calls),
576 ..Self::base(MessageRole::Assistant, MessageContent::Text(content))
577 }
578 }
579
580 #[inline]
582 pub fn assistant_with_tools_and_parts(content_parts: Vec<ContentPart>, tool_calls: Vec<ToolCall>) -> Self {
583 Self {
584 tool_calls: Some(tool_calls),
585 ..Self::base(MessageRole::Assistant, MessageContent::Parts(content_parts))
586 }
587 }
588
589 #[inline]
592 pub fn assistant_with_tools_and_reasoning(
593 content: String,
594 tool_calls: Vec<ToolCall>,
595 reasoning_details: Option<Vec<serde_json::Value>>,
596 ) -> Self {
597 Self {
598 tool_calls: Some(tool_calls),
599 reasoning_details,
600 ..Self::base(MessageRole::Assistant, MessageContent::Text(content))
601 }
602 }
603
604 #[inline]
606 pub fn system(content: String) -> Self {
607 Self::base(MessageRole::System, MessageContent::Text(content))
608 }
609
610 #[inline]
617 pub fn turn_scoped_system(content: String) -> Self {
618 Self {
619 clear_at: Some(MessageClearAt::NextUserMessage),
620 ..Self::system(content)
621 }
622 }
623
624 #[inline]
634 pub fn tool_response(tool_call_id: String, content: String) -> Self {
635 Self {
636 tool_call_id: Some(tool_call_id),
637 ..Self::base(MessageRole::Tool, MessageContent::Text(content))
638 }
639 }
640
641 #[inline]
644 pub fn tool_response_with_name(tool_call_id: String, _function_name: String, content: String) -> Self {
645 Self::tool_response(tool_call_id, content)
647 }
648
649 #[inline]
652 pub fn tool_response_with_origin(tool_call_id: String, content: String, origin_tool: String) -> Self {
653 Self {
654 tool_call_id: Some(tool_call_id),
655 origin_tool: Some(origin_tool),
656 ..Self::base(MessageRole::Tool, MessageContent::Text(content))
657 }
658 }
659
660 pub async fn user_with_local_image<P: AsRef<std::path::Path>>(file_path: P) -> Result<Self, anyhow::Error> {
662 let image_data = vtcode_commons::image::read_image_file(file_path).await?;
663 let image_part = ContentPart::image(image_data.base64_data, image_data.mime_type);
664 Ok(Self::user_with_parts(vec![image_part]))
665 }
666
667 pub async fn user_with_text_and_local_image<P: AsRef<std::path::Path>>(
669 text: String,
670 file_path: P,
671 ) -> Result<Self, anyhow::Error> {
672 let image_data = vtcode_commons::image::read_image_file(file_path).await?;
673 let text_part = ContentPart::text(text);
674 let image_part = ContentPart::image(image_data.base64_data, image_data.mime_type);
675 Ok(Self::user_with_parts(vec![text_part, image_part]))
676 }
677
678 pub fn with_reasoning(mut self, reasoning: Option<String>) -> Self {
680 if self.role == MessageRole::Assistant
681 && let Some(reasoning_text) = reasoning.as_ref()
682 {
683 let cleaned_reasoning = clean_reasoning_text(reasoning_text);
684 if !cleaned_reasoning.is_empty() {
685 let cleaned_content = clean_reasoning_text(self.content.as_text().as_ref());
686 if !cleaned_content.is_empty() && cleaned_reasoning == cleaned_content {
687 self.reasoning = None;
688 return self;
689 }
690 }
691 }
692 self.reasoning = reasoning;
693 self
694 }
695
696 pub fn with_tool_calls(mut self, tool_calls: Vec<ToolCall>) -> Self {
698 self.tool_calls = Some(tool_calls);
699 self
700 }
701
702 pub fn with_reasoning_details(mut self, reasoning_details: Option<Vec<serde_json::Value>>) -> Self {
704 self.reasoning_details = reasoning_details;
705 self
706 }
707
708 #[must_use]
710 pub fn with_phase(mut self, phase: Option<AssistantPhase>) -> Self {
711 self.phase = if self.role == MessageRole::Assistant {
712 phase
713 } else {
714 None
715 };
716 self
717 }
718
719 #[must_use]
721 pub fn with_metadata(mut self, metadata: MessageMetadata) -> Self {
722 self.metadata = Some(metadata);
723 self
724 }
725
726 pub(crate) fn validate_for_provider(&self, provider: &str) -> Result<(), String> {
729 if self.clear_at.is_some() {
730 if self.role != MessageRole::System {
731 return Err("clear_at is only valid on system messages".to_owned());
732 }
733 let text_only = match &self.content {
734 MessageContent::Text(_) => true,
735 MessageContent::Parts(parts) => parts.iter().all(|part| matches!(part, ContentPart::Text { .. })),
736 };
737 if !text_only {
738 return Err("clear_at system messages must contain text-only content".to_owned());
739 }
740 if !provider.eq_ignore_ascii_case("anthropic") {
741 return Err(format!("clear_at system messages are only supported by Anthropic, got {provider}"));
742 }
743 }
744
745 self.role.validate_for_provider(provider, self.tool_call_id.is_some())?;
747
748 if let Some(tool_calls) = &self.tool_calls {
750 if !self.role.can_make_tool_calls() {
751 return Err(format!("Role {:?} cannot make tool calls", self.role));
752 }
753
754 if tool_calls.is_empty() {
755 return Err("Tool calls array should not be empty".to_owned());
756 }
757
758 for tool_call in tool_calls {
760 tool_call.validate()?;
761 }
762 }
763
764 match provider {
766 "openai" | "openrouter" | "meta" | "zai" | "stepfun" | "evolink" | "deepseek" => {
767 if self.role == MessageRole::Tool && self.tool_call_id.is_none() {
768 return Err(format!("{provider} requires tool_call_id for tool messages"));
769 }
770 }
771 "gemini" => {
772 if self.role == MessageRole::Tool && self.tool_call_id.is_none() {
773 return Err("Gemini tool responses need tool_call_id for function name mapping".to_owned());
774 }
775 if self.role == MessageRole::System && !self.content.as_text().is_empty() {
777 }
779 }
780 "anthropic" => {
781 }
784 _ => {} }
786
787 if provider == "deepseek" && self.has_images() {
789 if self.role != MessageRole::User {
790 return Err("DeepSeek vision images are only supported in user messages".to_owned());
791 }
792 for part in self.content.get_images() {
793 if let Err(e) = part.validate_image() {
794 return Err(format!("DeepSeek image validation failed: {e}"));
795 }
796 }
797 }
798
799 Ok(())
800 }
801
802 pub(crate) fn has_tool_calls(&self) -> bool {
804 self.tool_calls.as_ref().is_some_and(|calls| !calls.is_empty())
805 }
806
807 pub fn get_tool_calls(&self) -> Option<&[ToolCall]> {
809 self.tool_calls.as_deref()
810 }
811
812 pub fn is_tool_response(&self) -> bool {
814 self.role == MessageRole::Tool
815 }
816
817 pub fn get_text_content(&self) -> std::borrow::Cow<'_, str> {
819 self.content.as_text()
820 }
821
822 pub fn has_images(&self) -> bool {
824 self.content.has_images()
825 }
826
827 pub fn get_images(&self) -> Vec<&ContentPart> {
829 self.content.get_images()
830 }
831}
832
833#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
834pub enum MessageRole {
835 System,
836 #[default]
837 User,
838 Assistant,
839 Tool,
840}
841
842impl std::fmt::Display for MessageRole {
843 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
844 match self {
845 MessageRole::System => write!(f, "system"),
846 MessageRole::User => write!(f, "user"),
847 MessageRole::Assistant => write!(f, "assistant"),
848 MessageRole::Tool => write!(f, "tool"),
849 }
850 }
851}
852
853impl MessageRole {
854 pub(crate) fn as_gemini_str(&self) -> &'static str {
860 match self {
861 MessageRole::System => "system", MessageRole::User => "user",
863 MessageRole::Assistant => "model", MessageRole::Tool => "user", }
866 }
867
868 pub(crate) fn as_openai_str(&self) -> &'static str {
873 match self {
874 MessageRole::System => "system",
875 MessageRole::User => "user",
876 MessageRole::Assistant => "assistant",
877 MessageRole::Tool => "tool", }
879 }
880
881 pub(crate) fn as_anthropic_str(&self) -> &'static str {
887 match self {
888 MessageRole::System => "system", MessageRole::User => "user",
890 MessageRole::Assistant => "assistant",
891 MessageRole::Tool => "user", }
893 }
894
895 pub fn as_generic_str(&self) -> &'static str {
898 match self {
899 MessageRole::System => "system",
900 MessageRole::User => "user",
901 MessageRole::Assistant => "assistant",
902 MessageRole::Tool => "tool",
903 }
904 }
905
906 fn can_make_tool_calls(&self) -> bool {
909 matches!(self, MessageRole::Assistant)
910 }
911
912 pub fn is_tool_response(&self) -> bool {
914 matches!(self, MessageRole::Tool)
915 }
916
917 fn validate_for_provider(&self, provider: &str, has_tool_call_id: bool) -> Result<(), String> {
920 match (self, provider) {
921 (MessageRole::Tool, provider)
922 if matches!(provider, "openai" | "openrouter" | "meta" | "deepseek" | "zai") && !has_tool_call_id =>
923 {
924 Err(format!("{provider} tool messages must have tool_call_id"))
925 }
926 (MessageRole::Tool, "gemini") if !has_tool_call_id => {
927 Err("Gemini tool messages need tool_call_id for function mapping".to_owned())
928 }
929 _ => Ok(()),
930 }
931 }
932}
933
934#[cfg(test)]
935mod tests {
936 use super::{
937 AssistantPhase, ContentPart, ImageDetail, Message, MessageClearAt, MessageContent, MessageRole, ToolCall,
938 };
939
940 #[test]
941 fn message_content_parts_concatenate_without_extra_spaces() {
942 let parts = vec![
943 ContentPart::text("Andre".to_string()),
944 ContentPart::text("j".to_string()),
945 ContentPart::text(" Kar".to_string()),
946 ContentPart::text("pathy".to_string()),
947 ContentPart::text("'s".to_string()),
948 ];
949 let content = MessageContent::Parts(parts);
950
951 assert_eq!(content.as_text().as_ref() as &str, "Andrej Karpathy's");
952 }
953
954 #[test]
955 fn message_content_parts_with_single_text_stays_borrowed() {
956 let content = MessageContent::Parts(vec![ContentPart::text("borrowed".to_string())]);
957
958 assert!(matches!(content.as_text(), std::borrow::Cow::Borrowed("borrowed")));
959 }
960
961 #[test]
962 fn message_content_parts_without_text_stays_borrowed_empty() {
963 let content = MessageContent::Parts(vec![ContentPart::image("encoded".to_string(), "image/png".to_string())]);
964
965 assert!(matches!(content.as_text(), std::borrow::Cow::Borrowed("")));
966 }
967
968 #[test]
969 fn assistant_phase_parses_wire_strings() {
970 assert_eq!(AssistantPhase::from_wire_str("commentary"), Some(AssistantPhase::Commentary));
971 assert_eq!(AssistantPhase::from_wire_str("final_answer"), Some(AssistantPhase::FinalAnswer));
972 assert_eq!(AssistantPhase::from_wire_str("other"), None);
973 }
974
975 #[test]
979 fn assistant_phase_string_paths_stay_in_lockstep() {
980 for phase in [AssistantPhase::Commentary, AssistantPhase::FinalAnswer] {
981 let expected = phase.as_str();
982 assert_eq!(
983 serde_json::to_value(phase).expect("serializes"),
984 serde_json::json!(expected),
985 "serde form drifted for {phase:?}"
986 );
987 assert_eq!(AssistantPhase::from_wire_str(expected), Some(phase), "from_wire_str drift");
988 }
989 }
990
991 #[test]
994 fn image_detail_string_paths_stay_in_lockstep() {
995 for detail in [
996 ImageDetail::Low,
997 ImageDetail::High,
998 ImageDetail::Original,
999 ImageDetail::Auto,
1000 ] {
1001 let expected = detail.as_str();
1002 assert_eq!(
1003 serde_json::to_value(&detail).expect("serializes"),
1004 serde_json::json!(expected),
1005 "serde form drifted for {detail:?}"
1006 );
1007 assert_eq!(ImageDetail::from_str(expected), Some(detail), "from_str drift");
1008 }
1009 }
1010
1011 #[test]
1012 fn with_phase_ignores_non_assistant_roles() {
1013 let user = Message::user("hello".to_string()).with_phase(Some(AssistantPhase::Commentary));
1014 let tool = Message::tool_response("call_1".to_string(), "ok".to_string())
1015 .with_phase(Some(AssistantPhase::FinalAnswer));
1016
1017 assert_eq!(user.role, MessageRole::User);
1018 assert!(user.phase.is_none());
1019 assert_eq!(tool.role, MessageRole::Tool);
1020 assert!(tool.phase.is_none());
1021 }
1022
1023 #[test]
1024 fn turn_scoped_system_message_round_trips_clear_scope() {
1025 let message = Message::turn_scoped_system("Only you see the output".to_string());
1026 let encoded = serde_json::to_value(&message).expect("message serialization");
1027
1028 assert_eq!(encoded["role"], "System");
1029 assert_eq!(encoded["clear_at"], "next_user_message");
1030 assert_eq!(message.clear_at, Some(MessageClearAt::NextUserMessage));
1031 assert_eq!(serde_json::from_value::<Message>(encoded).expect("message deserialization"), message);
1032 }
1033
1034 #[test]
1035 fn turn_scoped_system_message_requires_non_anthropic_wire_translation() {
1036 let error = Message::turn_scoped_system("notice".to_string())
1037 .validate_for_provider("openai")
1038 .expect_err("raw Anthropic lifecycle fields are not valid on an OpenAI wire");
1039 assert!(error.contains("only supported by Anthropic"));
1040 }
1041
1042 #[test]
1043 fn validate_for_provider_accepts_recovered_tool_arguments() {
1044 let message = Message::assistant_with_tools(
1045 String::new(),
1046 vec![ToolCall::function(
1047 "call_search".to_string(),
1048 "code_search".to_string(),
1049 "{\"query\": \"persistent_memory\", \"path\": \"crates/codegen/vtcode-core/src</parameter>\n<</invoke>\n</minimax:tool_call>".to_string(),
1050 )],
1051 );
1052
1053 message.validate_for_provider("anthropic").unwrap();
1054 }
1055
1056 #[test]
1057 fn validate_for_provider_requires_tool_call_id_for_meta() {
1058 let message = Message {
1059 role: MessageRole::Tool,
1060 content: MessageContent::text("result".to_owned()),
1061 ..Default::default()
1062 };
1063
1064 let error = message
1065 .validate_for_provider("meta")
1066 .expect_err("Meta tool messages need an id");
1067 assert!(error.contains("tool_call_id"));
1068 }
1069}