Skip to main content

reson_agentic/utils/
message_conversion.rs

1//! Message conversion utilities
2//!
3//! Converts mixed ChatMessage/ToolResult/ToolCall/ReasoningSegment lists to provider-specific formats.
4//! Handles message coalescing for Anthropic/Google providers.
5
6use crate::error::Result;
7use crate::types::{
8    ChatMessage, ChatRole, MediaPart, MediaSource, MultimodalMessage, Provider, ReasoningSegment,
9    ToolCall, ToolResult, VideoMetadata,
10};
11use serde_json::{json, Value};
12use uuid::Uuid;
13
14/// Message type that can appear in conversation history
15#[derive(Debug, Clone, serde::Serialize)]
16pub enum ConversationMessage {
17    /// Regular chat message (user, assistant, system)
18    Chat(ChatMessage),
19    /// Tool call from the assistant
20    ToolCall(ToolCall),
21    /// Tool result from execution
22    ToolResult(ToolResult),
23    /// Reasoning/thinking segment
24    Reasoning(ReasoningSegment),
25    /// Multimodal message with media parts (images, video, audio)
26    Multimodal(MultimodalMessage),
27}
28
29impl From<ChatMessage> for ConversationMessage {
30    fn from(msg: ChatMessage) -> Self {
31        ConversationMessage::Chat(msg)
32    }
33}
34
35impl From<ToolCall> for ConversationMessage {
36    fn from(tool_call: ToolCall) -> Self {
37        ConversationMessage::ToolCall(tool_call)
38    }
39}
40
41impl From<ToolResult> for ConversationMessage {
42    fn from(result: ToolResult) -> Self {
43        ConversationMessage::ToolResult(result)
44    }
45}
46
47impl From<ReasoningSegment> for ConversationMessage {
48    fn from(segment: ReasoningSegment) -> Self {
49        ConversationMessage::Reasoning(segment)
50    }
51}
52
53impl From<MultimodalMessage> for ConversationMessage {
54    fn from(msg: MultimodalMessage) -> Self {
55        ConversationMessage::Multimodal(msg)
56    }
57}
58
59/// Convert a MediaPart to Google's parts format
60pub fn media_part_to_google_format(part: &MediaPart, metadata: Option<&VideoMetadata>) -> Value {
61    match part {
62        MediaPart::Text { text } => json!({ "text": text }),
63
64        MediaPart::Image { source, .. } => media_source_to_google_format(source, None),
65
66        MediaPart::Audio { source, .. } => media_source_to_google_format(source, None),
67
68        MediaPart::Video {
69            source,
70            metadata: video_meta,
71        } => {
72            let meta = video_meta.as_ref().or(metadata);
73            media_source_to_google_format(source, meta)
74        }
75
76        MediaPart::Document { source } => media_source_to_google_format(source, None),
77    }
78}
79
80/// Convert a MediaSource to Google's format
81fn media_source_to_google_format(source: &MediaSource, metadata: Option<&VideoMetadata>) -> Value {
82    let mut part = match source {
83        MediaSource::Base64 { data, mime_type } => {
84            json!({
85                "inline_data": {
86                    "data": data,
87                    "mime_type": mime_type
88                }
89            })
90        }
91        MediaSource::Url { url } => {
92            // Google doesn't support arbitrary URLs directly - must use file_data
93            // This is a fallback that may not work for all cases
94            json!({
95                "file_data": {
96                    "file_uri": url
97                }
98            })
99        }
100        MediaSource::FileId { file_id } => {
101            json!({
102                "file_data": {
103                    "file_uri": file_id
104                }
105            })
106        }
107        MediaSource::FileUri { uri, mime_type } => {
108            let mut fd = json!({
109                "file_data": {
110                    "file_uri": uri
111                }
112            });
113            if let Some(mt) = mime_type {
114                fd["file_data"]["mime_type"] = json!(mt);
115            }
116            fd
117        }
118    };
119
120    // Add video metadata if present
121    if let Some(meta) = metadata {
122        let mut video_metadata = json!({});
123        if let Some(ref start) = meta.start_offset {
124            video_metadata["start_offset"] = json!(start);
125        }
126        if let Some(ref end) = meta.end_offset {
127            video_metadata["end_offset"] = json!(end);
128        }
129        if let Some(fps) = meta.fps {
130            video_metadata["fps"] = json!(fps);
131        }
132        if video_metadata
133            .as_object()
134            .map(|o| !o.is_empty())
135            .unwrap_or(false)
136        {
137            part["video_metadata"] = video_metadata;
138        }
139    }
140
141    part
142}
143
144/// Convert a MediaPart to Anthropic's content format
145fn media_part_to_anthropic_format(part: &MediaPart) -> Value {
146    match part {
147        MediaPart::Text { text } => json!({
148            "type": "text",
149            "text": text
150        }),
151
152        MediaPart::Image { source, .. } => match source {
153            MediaSource::Base64 { data, mime_type } => json!({
154                "type": "image",
155                "source": {
156                    "type": "base64",
157                    "media_type": mime_type,
158                    "data": data
159                }
160            }),
161            MediaSource::Url { url } => json!({
162                "type": "image",
163                "source": {
164                    "type": "url",
165                    "url": url
166                }
167            }),
168            MediaSource::FileId { file_id } => json!({
169                "type": "image",
170                "source": {
171                    "type": "file",
172                    "file_id": file_id
173                }
174            }),
175            MediaSource::FileUri { uri, .. } => json!({
176                "type": "image",
177                "source": {
178                    "type": "url",
179                    "url": uri
180                }
181            }),
182        },
183
184        MediaPart::Document { source } => match source {
185            MediaSource::Base64 { data, mime_type } => json!({
186                "type": "document",
187                "source": {
188                    "type": "base64",
189                    "media_type": mime_type,
190                    "data": data
191                }
192            }),
193            MediaSource::FileId { file_id } => json!({
194                "type": "document",
195                "source": {
196                    "type": "file",
197                    "file_id": file_id
198                }
199            }),
200            _ => json!({
201                "type": "text",
202                "text": "[Unsupported document source for Anthropic]"
203            }),
204        },
205
206        // Anthropic doesn't support audio/video directly
207        MediaPart::Audio { .. } | MediaPart::Video { .. } => json!({
208            "type": "text",
209            "text": "[Audio/Video not supported by this provider]"
210        }),
211    }
212}
213
214/// Convert a MediaPart to OpenAI's content format
215fn media_part_to_openai_format(part: &MediaPart) -> Value {
216    match part {
217        MediaPart::Text { text } => json!({
218            "type": "text",
219            "text": text
220        }),
221
222        MediaPart::Image { source, detail } => {
223            let mut img = match source {
224                MediaSource::Base64 { data, mime_type } => json!({
225                    "type": "image_url",
226                    "image_url": {
227                        "url": format!("data:{};base64,{}", mime_type, data)
228                    }
229                }),
230                MediaSource::Url { url } => json!({
231                    "type": "image_url",
232                    "image_url": {
233                        "url": url
234                    }
235                }),
236                MediaSource::FileId { file_id } => json!({
237                    "type": "image_file",
238                    "image_file": {
239                        "file_id": file_id
240                    }
241                }),
242                MediaSource::FileUri { uri, .. } => json!({
243                    "type": "image_url",
244                    "image_url": {
245                        "url": uri
246                    }
247                }),
248            };
249            if let Some(d) = detail {
250                if let Some(obj) = img.get_mut("image_url") {
251                    obj["detail"] = json!(d);
252                }
253            }
254            img
255        }
256
257        MediaPart::Audio { source, format } => match source {
258            MediaSource::Base64 { data, .. } => {
259                let fmt = format.as_deref().unwrap_or("wav");
260                json!({
261                    "type": "input_audio",
262                    "input_audio": {
263                        "data": data,
264                        "format": fmt
265                    }
266                })
267            }
268            _ => json!({
269                "type": "text",
270                "text": "[Unsupported audio source for OpenAI - use base64]"
271            }),
272        },
273
274        // Video URL support (vLLM-compatible format for Qwen2-VL, etc.)
275        MediaPart::Video {
276            source: MediaSource::Url { url },
277            ..
278        } => json!({
279            "type": "video_url",
280            "video_url": { "url": url }
281        }),
282        MediaPart::Video { .. } | MediaPart::Document { .. } => json!({
283            "type": "text",
284            "text": "[Video/Document not supported by this provider]"
285        }),
286    }
287}
288
289/// Convert a MediaPart to OpenAI Responses API format
290fn media_part_to_openai_responses_format(part: &MediaPart) -> Value {
291    match part {
292        MediaPart::Text { text } => json!({
293            "type": "input_text",
294            "text": text
295        }),
296
297        MediaPart::Image { source, .. } => match source {
298            MediaSource::Base64 { data, mime_type } => json!({
299                "type": "input_image",
300                "image_url": {
301                    "url": format!("data:{};base64,{}", mime_type, data)
302                }
303            }),
304            MediaSource::Url { url } => json!({
305                "type": "input_image",
306                "image_url": {
307                    "url": url
308                }
309            }),
310            MediaSource::FileId { file_id } => json!({
311                "type": "input_image",
312                "image_file": {
313                    "file_id": file_id
314                }
315            }),
316            MediaSource::FileUri { uri, .. } => json!({
317                "type": "input_image",
318                "image_url": {
319                    "url": uri
320                }
321            }),
322        },
323
324        MediaPart::Audio { source, format } => match source {
325            MediaSource::Base64 { data, .. } => {
326                let fmt = format.as_deref().unwrap_or("wav");
327                json!({
328                    "type": "input_audio",
329                    "input_audio": {
330                        "data": data,
331                        "format": fmt
332                    }
333                })
334            }
335            _ => json!({
336                "type": "input_text",
337                "text": "[Unsupported audio source for OpenAI Responses - use base64]"
338            }),
339        },
340
341        // OpenAI Responses doesn't support video/documents in chat input
342        MediaPart::Video { .. } | MediaPart::Document { .. } => json!({
343            "type": "input_text",
344            "text": "[Video/Document not supported by this provider]"
345        }),
346    }
347}
348
349/// Convert a MultimodalMessage to provider-specific format
350fn multimodal_to_provider_format(msg: &MultimodalMessage, provider: Provider) -> Value {
351    let role = match msg.role {
352        ChatRole::System => "system",
353        ChatRole::User => "user",
354        ChatRole::Assistant => "assistant",
355        ChatRole::Tool => "tool",
356    };
357
358    let parts: Vec<Value> = msg
359        .parts
360        .iter()
361        .map(|part| match provider {
362            Provider::GoogleGenAI => media_part_to_google_format(part, None),
363            Provider::Anthropic | Provider::Bedrock | Provider::GoogleAnthropic => {
364                media_part_to_anthropic_format(part)
365            }
366            Provider::OpenAI
367            | Provider::OpenRouter
368            | Provider::OpenAIResponses
369            | Provider::OpenRouterResponses => media_part_to_openai_format(part),
370        })
371        .collect();
372
373    // Google uses "parts", others use "content" array
374    match provider {
375        Provider::GoogleGenAI => json!({
376            "role": if role == "assistant" { "model" } else { role },
377            "parts": parts
378        }),
379        _ => json!({
380            "role": role,
381            "content": parts
382        }),
383    }
384}
385
386/// Convert messages to OpenAI/OpenRouter Responses API input format.
387///
388/// Returns (instructions, input_items).
389pub fn convert_messages_to_responses_input(
390    messages: &[ConversationMessage],
391    provider: Provider,
392) -> Result<(Option<String>, Vec<Value>)> {
393    let mut instructions: Vec<String> = Vec::new();
394    let mut input_items: Vec<Value> = Vec::new();
395
396    for msg in messages.iter() {
397        match msg {
398            ConversationMessage::Chat(chat_msg) => match chat_msg.role {
399                ChatRole::System => {
400                    instructions.push(chat_msg.content.clone());
401                }
402                ChatRole::User => {
403                    input_items.push(json!({
404                        "type": "message",
405                        "role": "user",
406                        "content": [{
407                            "type": "input_text",
408                            "text": chat_msg.content
409                        }]
410                    }));
411                }
412                ChatRole::Assistant => {
413                    input_items.push(json!({
414                        "type": "message",
415                        "role": "assistant",
416                        "id": format!("msg_{}", Uuid::new_v4()),
417                        "status": "completed",
418                        "content": [{
419                            "type": "output_text",
420                            "text": chat_msg.content,
421                            "annotations": []
422                        }]
423                    }));
424                }
425                ChatRole::Tool => {
426                    input_items.push(json!({
427                        "type": "message",
428                        "role": "tool",
429                        "content": [{
430                            "type": "input_text",
431                            "text": chat_msg.content
432                        }]
433                    }));
434                }
435            },
436            ConversationMessage::ToolCall(tool_call) => {
437                let args_str = tool_call.raw_arguments.clone().unwrap_or_else(|| {
438                    serde_json::to_string(&tool_call.args).unwrap_or_else(|_| "{}".to_string())
439                });
440                input_items.push(json!({
441                    "type": "function_call",
442                    "id": format!("fc_{}", tool_call.tool_use_id),
443                    "call_id": tool_call.tool_use_id,
444                    "name": tool_call.tool_name,
445                    "arguments": args_str
446                }));
447            }
448            ConversationMessage::ToolResult(tool_result) => {
449                input_items.push(json!({
450                    "type": "function_call_output",
451                    "call_id": tool_result.tool_use_id,
452                    "output": tool_result.content
453                }));
454            }
455            ConversationMessage::Reasoning(reasoning_segment) => {
456                input_items.push(json!({
457                    "type": "reasoning",
458                    "content": [{
459                        "type": "output_text",
460                        "text": reasoning_segment.content
461                    }]
462                }));
463            }
464            ConversationMessage::Multimodal(multimodal_msg) => {
465                let role = match multimodal_msg.role {
466                    ChatRole::System => "system",
467                    ChatRole::User => "user",
468                    ChatRole::Assistant => "assistant",
469                    ChatRole::Tool => "tool",
470                };
471
472                let parts: Vec<Value> = multimodal_msg
473                    .parts
474                    .iter()
475                    .map(|part| {
476                        if matches!(
477                            provider,
478                            Provider::OpenAIResponses | Provider::OpenRouterResponses
479                        ) {
480                            match part {
481                                MediaPart::Text { text } if role == "assistant" => json!({
482                                    "type": "output_text",
483                                    "text": text,
484                                    "annotations": []
485                                }),
486                                _ => media_part_to_openai_responses_format(part),
487                            }
488                        } else {
489                            media_part_to_openai_format(part)
490                        }
491                    })
492                    .collect();
493
494                let mut item = json!({
495                    "type": "message",
496                    "role": role,
497                    "content": parts
498                });
499
500                if role == "assistant" {
501                    item["id"] = json!(format!("msg_{}", Uuid::new_v4()));
502                    item["status"] = json!("completed");
503                }
504
505                input_items.push(item);
506            }
507        }
508    }
509
510    let instructions = if instructions.is_empty() {
511        None
512    } else {
513        Some(instructions.join("\n\n"))
514    };
515
516    Ok((instructions, input_items))
517}
518
519/// Convert messages to provider-specific format with coalescing
520///
521/// Provider-aware behavior:
522/// - Anthropic/Bedrock/GoogleAnthropic: Coalesce consecutive ToolResults into a single user turn
523///   with a content array of tool_result blocks. If the immediate next user ChatMessage exists,
524///   append it as a trailing {"type":"text","text":"..."} block in the same message.
525///
526/// - Google GenAI: Coalesce consecutive ToolResults into a single user turn where 'content' is a list
527///   of {"functionResponse": {...}} dicts. If the immediate next user ChatMessage exists, append
528///   {"text": "..."} as a trailing entry.
529///
530/// - OpenAI/OpenRouter: Map each ToolResult to a proper tool-role message:
531///   {"role":"tool","tool_call_id": tool_use_id, "content": result}
532///   Keep subsequent user messages separate (no coalescing).
533pub fn convert_messages_to_provider_format(
534    messages: &[ConversationMessage],
535    provider: Provider,
536) -> Result<Vec<Value>> {
537    let mut converted_messages: Vec<Value> = Vec::new();
538    let mut pending_anthropic_blocks: Vec<Value> = Vec::new();
539    let mut pending_google_parts: Vec<Value> = Vec::new();
540
541    let flush_pending = |converted: &mut Vec<Value>,
542                         anthropic_blocks: &mut Vec<Value>,
543                         google_parts: &mut Vec<Value>| {
544        match provider {
545            Provider::Anthropic | Provider::Bedrock | Provider::GoogleAnthropic => {
546                if !anthropic_blocks.is_empty() {
547                    converted.push(json!({
548                        "role": "user",
549                        "content": anthropic_blocks.clone()
550                    }));
551                    anthropic_blocks.clear();
552                }
553            }
554            Provider::GoogleGenAI => {
555                if !google_parts.is_empty() {
556                    converted.push(json!({
557                        "role": "user",
558                        "content": google_parts.clone()
559                    }));
560                    google_parts.clear();
561                }
562            }
563            _ => {}
564        }
565    };
566
567    for msg in messages.iter() {
568        match msg {
569            ConversationMessage::Chat(chat_msg) => {
570                // Check if this is a user message that should be merged with pending tool results
571                if chat_msg.role == ChatRole::User
572                    && matches!(
573                        provider,
574                        Provider::Anthropic
575                            | Provider::Bedrock
576                            | Provider::GoogleAnthropic
577                            | Provider::GoogleGenAI
578                    )
579                {
580                    // Merge with pending tool results
581                    if matches!(
582                        provider,
583                        Provider::Anthropic | Provider::Bedrock | Provider::GoogleAnthropic
584                    ) && !pending_anthropic_blocks.is_empty()
585                    {
586                        // Only add text block if content is non-empty
587                        // (Anthropic rejects "text content blocks must be non-empty")
588                        if !chat_msg.content.is_empty() {
589                            pending_anthropic_blocks.push(json!({
590                                "type": "text",
591                                "text": chat_msg.content
592                            }));
593                        }
594                        flush_pending(
595                            &mut converted_messages,
596                            &mut pending_anthropic_blocks,
597                            &mut pending_google_parts,
598                        );
599                        continue;
600                    } else if provider == Provider::GoogleGenAI && !pending_google_parts.is_empty()
601                    {
602                        // Only add text part if content is non-empty
603                        if !chat_msg.content.is_empty() {
604                            pending_google_parts.push(json!({
605                                "text": chat_msg.content
606                            }));
607                        }
608                        flush_pending(
609                            &mut converted_messages,
610                            &mut pending_anthropic_blocks,
611                            &mut pending_google_parts,
612                        );
613                        continue;
614                    }
615                }
616
617                // Flush pending before adding this message
618                flush_pending(
619                    &mut converted_messages,
620                    &mut pending_anthropic_blocks,
621                    &mut pending_google_parts,
622                );
623
624                // Add regular chat message
625                let role = match chat_msg.role {
626                    ChatRole::System => "system",
627                    ChatRole::User => "user",
628                    ChatRole::Assistant => "assistant",
629                    ChatRole::Tool => "tool",
630                };
631
632                converted_messages.push(json!({
633                    "role": role,
634                    "content": chat_msg.content
635                }));
636            }
637
638            ConversationMessage::ToolCall(tool_call) => {
639                // Flush pending before adding tool call
640                flush_pending(
641                    &mut converted_messages,
642                    &mut pending_anthropic_blocks,
643                    &mut pending_google_parts,
644                );
645
646                // Convert tool call to provider-specific assistant message format
647                converted_messages.push(tool_call.to_provider_assistant_message(provider));
648            }
649
650            ConversationMessage::ToolResult(tool_result) => {
651                match provider {
652                    Provider::OpenAI
653                    | Provider::OpenRouter
654                    | Provider::OpenAIResponses
655                    | Provider::OpenRouterResponses => {
656                        // OpenAI: Each ToolResult becomes a separate tool message
657                        flush_pending(
658                            &mut converted_messages,
659                            &mut pending_anthropic_blocks,
660                            &mut pending_google_parts,
661                        );
662                        converted_messages.push(tool_result.to_provider_format(provider));
663                    }
664                    Provider::Anthropic | Provider::Bedrock | Provider::GoogleAnthropic => {
665                        // Anthropic: Accumulate tool results for coalescing
666                        pending_anthropic_blocks.push(tool_result.to_provider_format(provider));
667                    }
668                    Provider::GoogleGenAI => {
669                        // Google GenAI: Accumulate function responses
670                        pending_google_parts.push(tool_result.to_provider_format(provider));
671                    }
672                }
673            }
674
675            ConversationMessage::Reasoning(reasoning_segment) => {
676                // Flush pending before adding reasoning
677                flush_pending(
678                    &mut converted_messages,
679                    &mut pending_anthropic_blocks,
680                    &mut pending_google_parts,
681                );
682
683                // Add reasoning segment as-is (providers handle differently)
684                converted_messages.push(reasoning_segment.to_provider_format(provider));
685            }
686
687            ConversationMessage::Multimodal(multimodal_msg) => {
688                // Flush pending before adding multimodal message
689                flush_pending(
690                    &mut converted_messages,
691                    &mut pending_anthropic_blocks,
692                    &mut pending_google_parts,
693                );
694
695                // Convert multimodal message to provider format
696                converted_messages.push(multimodal_to_provider_format(multimodal_msg, provider));
697            }
698        }
699    }
700
701    // Flush any trailing pending tool results
702    flush_pending(
703        &mut converted_messages,
704        &mut pending_anthropic_blocks,
705        &mut pending_google_parts,
706    );
707
708    Ok(converted_messages)
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    #[test]
716    fn test_openai_no_coalescing() {
717        // OpenAI should NOT coalesce tool results
718        let messages = vec![
719            ConversationMessage::Chat(ChatMessage::user("Use the weather tool")),
720            ConversationMessage::ToolResult(ToolResult::success("toolu_1", "Sunny, 72°F")),
721            ConversationMessage::ToolResult(ToolResult::success("toolu_2", "Rainy, 55°F")),
722            ConversationMessage::Chat(ChatMessage::user("Thanks!")),
723        ];
724
725        let result = convert_messages_to_provider_format(&messages, Provider::OpenAI).unwrap();
726
727        // Should be 4 separate messages
728        assert_eq!(result.len(), 4);
729
730        // First message: user
731        assert_eq!(result[0]["role"], "user");
732        assert_eq!(result[0]["content"], "Use the weather tool");
733
734        // Second message: tool result
735        assert_eq!(result[1]["role"], "tool");
736        assert_eq!(result[1]["tool_call_id"], "toolu_1");
737
738        // Third message: tool result
739        assert_eq!(result[2]["role"], "tool");
740        assert_eq!(result[2]["tool_call_id"], "toolu_2");
741
742        // Fourth message: user
743        assert_eq!(result[3]["role"], "user");
744        assert_eq!(result[3]["content"], "Thanks!");
745    }
746
747    #[test]
748    fn test_anthropic_coalesces_tool_results() {
749        // Anthropic SHOULD coalesce consecutive tool results
750        let messages = vec![
751            ConversationMessage::Chat(ChatMessage::user("Use the weather tool")),
752            ConversationMessage::ToolResult(ToolResult::success("toolu_1", "Sunny, 72°F")),
753            ConversationMessage::ToolResult(ToolResult::success("toolu_2", "Rainy, 55°F")),
754        ];
755
756        let result = convert_messages_to_provider_format(&messages, Provider::Anthropic).unwrap();
757
758        // Should be 2 messages: original user message + coalesced tool results
759        assert_eq!(result.len(), 2);
760
761        // First message: user
762        assert_eq!(result[0]["role"], "user");
763
764        // Second message: coalesced tool results
765        assert_eq!(result[1]["role"], "user");
766        let content = result[1]["content"].as_array().unwrap();
767        assert_eq!(content.len(), 2);
768        assert_eq!(content[0]["type"], "tool_result");
769        assert_eq!(content[0]["tool_use_id"], "toolu_1");
770        assert_eq!(content[1]["type"], "tool_result");
771        assert_eq!(content[1]["tool_use_id"], "toolu_2");
772    }
773
774    #[test]
775    fn test_anthropic_merges_trailing_user_message() {
776        // Anthropic SHOULD merge trailing user message with tool results
777        let messages = vec![
778            ConversationMessage::Chat(ChatMessage::user("Use the weather tool")),
779            ConversationMessage::ToolResult(ToolResult::success("toolu_1", "Sunny, 72°F")),
780            ConversationMessage::Chat(ChatMessage::user("Thanks!")),
781        ];
782
783        let result = convert_messages_to_provider_format(&messages, Provider::Anthropic).unwrap();
784
785        // Should be 2 messages: original user + coalesced (tool result + trailing user text)
786        assert_eq!(result.len(), 2);
787
788        // Second message should have both tool result AND text
789        let content = result[1]["content"].as_array().unwrap();
790        assert_eq!(content.len(), 2);
791        assert_eq!(content[0]["type"], "tool_result");
792        assert_eq!(content[1]["type"], "text");
793        assert_eq!(content[1]["text"], "Thanks!");
794    }
795
796    #[test]
797    fn test_google_genai_coalescing() {
798        // Google GenAI should coalesce tool results as functionResponse parts
799        let messages = vec![
800            ConversationMessage::Chat(ChatMessage::user("Use the weather tool")),
801            ConversationMessage::ToolResult(ToolResult::success("toolu_1", "Sunny, 72°F")),
802            ConversationMessage::ToolResult(ToolResult::success("toolu_2", "Rainy, 55°F")),
803        ];
804
805        let result = convert_messages_to_provider_format(&messages, Provider::GoogleGenAI).unwrap();
806
807        // Should be 2 messages
808        assert_eq!(result.len(), 2);
809
810        // Second message: coalesced function responses
811        assert_eq!(result[1]["role"], "user");
812        let content = result[1]["content"].as_array().unwrap();
813        assert_eq!(content.len(), 2);
814        assert!(content[0].get("functionResponse").is_some());
815        assert!(content[1].get("functionResponse").is_some());
816    }
817
818    #[test]
819    fn test_responses_input_conversion() {
820        let messages = vec![
821            ConversationMessage::Chat(ChatMessage::system("You are helpful")),
822            ConversationMessage::Chat(ChatMessage::user("Hello")),
823            ConversationMessage::ToolResult(ToolResult::success("call_1", "Result")),
824        ];
825
826        let (instructions, input) =
827            convert_messages_to_responses_input(&messages, Provider::OpenAIResponses).unwrap();
828
829        assert_eq!(instructions.unwrap(), "You are helpful");
830        assert_eq!(input.len(), 2);
831        assert_eq!(input[0]["role"], "user");
832        assert_eq!(input[0]["content"][0]["type"], "input_text");
833        assert_eq!(input[1]["type"], "function_call_output");
834        assert_eq!(input[1]["call_id"], "call_1");
835    }
836
837    #[test]
838    fn test_google_genai_merges_trailing_user_message() {
839        // Google GenAI should merge trailing user message as text part
840        let messages = vec![
841            ConversationMessage::ToolResult(ToolResult::success("toolu_1", "Sunny, 72°F")),
842            ConversationMessage::Chat(ChatMessage::user("Thanks!")),
843        ];
844
845        let result = convert_messages_to_provider_format(&messages, Provider::GoogleGenAI).unwrap();
846
847        // Should be 1 message with both functionResponse and text
848        assert_eq!(result.len(), 1);
849
850        let content = result[0]["content"].as_array().unwrap();
851        assert_eq!(content.len(), 2);
852        assert!(content[0].get("functionResponse").is_some());
853        assert_eq!(content[1]["text"], "Thanks!");
854    }
855
856    #[test]
857    fn test_system_message_preserved() {
858        let messages = vec![
859            ConversationMessage::Chat(ChatMessage::system("You are helpful")),
860            ConversationMessage::Chat(ChatMessage::user("Hello")),
861        ];
862
863        let result = convert_messages_to_provider_format(&messages, Provider::OpenAI).unwrap();
864
865        assert_eq!(result.len(), 2);
866        assert_eq!(result[0]["role"], "system");
867        assert_eq!(result[0]["content"], "You are helpful");
868    }
869
870    #[test]
871    fn test_reasoning_segments_not_coalesced() {
872        let messages = vec![
873            ConversationMessage::Chat(ChatMessage::user("Think about this")),
874            ConversationMessage::Reasoning(ReasoningSegment::with_index("Hmm...", 0)),
875            ConversationMessage::Chat(ChatMessage::assistant("I understand")),
876        ];
877
878        let result = convert_messages_to_provider_format(&messages, Provider::Anthropic).unwrap();
879
880        // Reasoning segments should be separate messages
881        assert_eq!(result.len(), 3);
882        assert_eq!(result[1]["type"], "thinking");
883    }
884}