Skip to main content

robit_ai/
client.rs

1//! LlmClient: a thin wrapper around async-openai with unified config support.
2
3use async_openai::config::OpenAIConfig;
4use async_openai::types::chat::{
5    ChatCompletionMessageToolCalls, ChatCompletionRequestMessage,
6    ChatCompletionRequestToolMessage, ChatCompletionResponseStream,
7    ChatCompletionTools, CreateChatCompletionRequest, CreateChatCompletionResponse,
8};
9
10use crate::config::{resolve_profile, ResolvedModel, RobitConfig};
11use crate::error::LlmError;
12
13/// Validate that all messages are valid before sending to LLM.
14/// Returns a filtered list of messages with invalid messages removed.
15fn validate_and_filter_messages(mut messages: Vec<ChatCompletionRequestMessage>) -> Vec<ChatCompletionRequestMessage> {
16    let original_len = messages.len();
17    messages.retain(|msg| {
18        match msg {
19            ChatCompletionRequestMessage::Assistant(assistant_msg) => {
20                // Assistant message must have either content or tool_calls
21                let has_content = assistant_msg.content.is_some();
22                let has_tool_calls = assistant_msg.tool_calls.is_some();
23                if !has_content && !has_tool_calls {
24                    tracing::warn!("Filtering out invalid assistant message (has neither content nor tool_calls)");
25                    false
26                } else {
27                    true
28                }
29            }
30            _ => true
31        }
32    });
33    let filtered_len = messages.len();
34    if filtered_len < original_len {
35        tracing::info!("Filtered {} invalid messages from history", original_len - filtered_len);
36    }
37    messages
38}
39
40/// Repair tool-message pairing so the history satisfies the OpenAI-protocol
41/// invariants enforced by providers (including DeepSeek, which rejects
42/// violations with a 400 "Messages with role 'tool' must be a response to a
43/// preceding message with 'tool_calls'"):
44///
45/// 1. Every `tool` message must reference a `tool_call_id` declared by a
46///    preceding assistant message's `tool_calls`. Orphaned tool messages
47///    (e.g. history restored from a database that persisted tool results but
48///    not the assistant message that requested them) are dropped.
49/// 2. Every `tool_calls` entry in an assistant message must have a matching
50///    `tool` response. Missing responses (e.g. the process was killed
51///    mid-step before all results were recorded) are synthesized as
52///    placeholder tool messages right after the assistant message.
53fn repair_tool_pairing(
54    messages: Vec<ChatCompletionRequestMessage>,
55) -> Vec<ChatCompletionRequestMessage> {
56    use std::collections::HashSet;
57
58    // Pass 1 (forward scan): decide which tool messages are matched.
59    let mut open_ids: HashSet<String> = HashSet::new();
60    let mut keep: Vec<bool> = Vec::with_capacity(messages.len());
61    let mut dropped = 0usize;
62    for msg in &messages {
63        match msg {
64            ChatCompletionRequestMessage::Assistant(a) => {
65                if let Some(tool_calls) = &a.tool_calls {
66                    for tc in tool_calls {
67                        if let ChatCompletionMessageToolCalls::Function(f) = tc {
68                            open_ids.insert(f.id.clone());
69                        }
70                    }
71                }
72                keep.push(true);
73            }
74            ChatCompletionRequestMessage::Tool(t) => {
75                if open_ids.remove(&t.tool_call_id) {
76                    keep.push(true);
77                } else {
78                    tracing::trace!(
79                        "repair_tool_pairing: dropping orphaned tool message \
80                         (tool_call_id='{}' not declared by any preceding assistant tool_calls)",
81                        t.tool_call_id
82                    );
83                    keep.push(false);
84                    dropped += 1;
85                }
86            }
87            _ => keep.push(true),
88        }
89    }
90    // Ids still in `open_ids` were declared but never got a tool response.
91    let mut missing = open_ids;
92
93    if dropped == 0 && missing.is_empty() {
94        return messages; // fast path: nothing to repair
95    }
96    if dropped > 0 {
97        // Warn once per call (not per message): orphaned tool messages are a
98        // sign of incomplete history persistence and would otherwise spam
99        // the log on every LLM call of a restored session.
100        tracing::warn!(
101            "repair_tool_pairing: dropped {} orphaned tool message(s) not declared by any assistant tool_calls",
102            dropped
103        );
104    }
105
106    // Pass 2: rebuild, inserting placeholder responses for missing ids.
107    let mut synthesized = 0usize;
108    let mut result: Vec<ChatCompletionRequestMessage> = Vec::with_capacity(messages.len());
109    for (msg, keep) in messages.into_iter().zip(keep.into_iter()) {
110        if !keep {
111            continue;
112        }
113        // Collect the still-missing ids declared by this assistant message.
114        let missing_here: Vec<String> = match &msg {
115            ChatCompletionRequestMessage::Assistant(a) => a
116                .tool_calls
117                .as_ref()
118                .map(|tool_calls| {
119                    tool_calls
120                        .iter()
121                        .filter_map(|tc| {
122                            if let ChatCompletionMessageToolCalls::Function(f) = tc {
123                                // `remove` guarantees at most one placeholder per id.
124                                if missing.remove(&f.id) {
125                                    Some(f.id.clone())
126                                } else {
127                                    None
128                                }
129                            } else {
130                                None
131                            }
132                        })
133                        .collect()
134                })
135                .unwrap_or_default(),
136            _ => Vec::new(),
137        };
138        result.push(msg);
139        for id in missing_here {
140            tracing::trace!(
141                "repair_tool_pairing: synthesizing missing tool response for tool_call_id='{}'",
142                id
143            );
144            synthesized += 1;
145            result.push(ChatCompletionRequestMessage::Tool(
146                ChatCompletionRequestToolMessage {
147                    content: "[Tool result unavailable — session history was restored without this result]"
148                        .to_string()
149                        .into(),
150                    tool_call_id: id,
151                }
152                .into(),
153            ));
154        }
155    }
156    if synthesized > 0 {
157        tracing::warn!(
158            "repair_tool_pairing: synthesized {} missing tool response(s) for declared tool_calls",
159            synthesized
160        );
161    }
162    result
163}
164
165/// Repair histories where a non-tool message (e.g. a user message carrying
166/// tool-result images) is interleaved between an assistant `tool_calls`
167/// message and its tool responses.
168///
169/// Providers enforce that the messages immediately following an assistant
170/// message with `tool_calls` are the `tool` responses for each declared
171/// `tool_call_id`; DeepSeek rejects violations with a 400 "insufficient tool
172/// messages following tool_calls message". Such histories can come from
173/// sessions written by older builds that injected image user messages
174/// per-tool-call, or from restored databases.
175///
176/// Deferred non-tool messages are re-inserted after the batch's last tool
177/// response (or at the end of the history if the batch is truncated),
178/// preserving their relative order.
179fn repair_interleaved_tool_responses(
180    messages: Vec<ChatCompletionRequestMessage>,
181) -> Vec<ChatCompletionRequestMessage> {
182    use std::collections::HashSet;
183
184    // Quick pre-scan: is there any non-tool message inside a tool_calls →
185    // tool-responses window? If not, pass through without rebuilding.
186    {
187        let mut pending: HashSet<&String> = HashSet::new();
188        let mut interleaved = false;
189        'scan: for msg in &messages {
190            match msg {
191                ChatCompletionRequestMessage::Assistant(a) => {
192                    if let Some(tool_calls) = &a.tool_calls {
193                        pending = tool_calls
194                            .iter()
195                            .filter_map(|tc| {
196                                if let ChatCompletionMessageToolCalls::Function(f) = tc {
197                                    Some(&f.id)
198                                } else {
199                                    None
200                                }
201                            })
202                            .collect();
203                    }
204                }
205                ChatCompletionRequestMessage::Tool(t) => {
206                    pending.remove(&t.tool_call_id);
207                }
208                _ => {
209                    if !pending.is_empty() {
210                        interleaved = true;
211                        break 'scan;
212                    }
213                }
214            }
215        }
216        if !interleaved {
217            return messages;
218        }
219    }
220
221    tracing::warn!(
222        "repair_interleaved_tool_responses: moving non-tool message(s) out of a \
223         tool_calls → tool-responses window (providers reject interleaved messages \
224         with a 400 error)"
225    );
226
227    // Rebuild: defer non-tool messages that arrive while a batch is still
228    // unanswered; flush them after the batch's last tool response.
229    let mut result: Vec<ChatCompletionRequestMessage> = Vec::with_capacity(messages.len());
230    let mut deferred: Vec<ChatCompletionRequestMessage> = Vec::new();
231    let mut pending: HashSet<String> = HashSet::new();
232
233    for msg in messages {
234        match &msg {
235            ChatCompletionRequestMessage::Assistant(a) => {
236                if let Some(tool_calls) = &a.tool_calls {
237                    // A new batch while the previous one is still unanswered
238                    // (truncated history): flush deferred messages before it.
239                    if !deferred.is_empty() {
240                        result.append(&mut deferred);
241                    }
242                    pending = tool_calls
243                        .iter()
244                        .filter_map(|tc| {
245                            if let ChatCompletionMessageToolCalls::Function(f) = tc {
246                                Some(f.id.clone())
247                            } else {
248                                None
249                            }
250                        })
251                        .collect();
252                    result.push(msg);
253                } else if pending.is_empty() {
254                    result.push(msg);
255                } else {
256                    deferred.push(msg);
257                }
258            }
259            ChatCompletionRequestMessage::Tool(t) => {
260                let responded = pending.remove(&t.tool_call_id);
261                result.push(msg);
262                if responded && pending.is_empty() && !deferred.is_empty() {
263                    result.append(&mut deferred);
264                }
265            }
266            _ => {
267                if pending.is_empty() {
268                    result.push(msg);
269                } else {
270                    deferred.push(msg);
271                }
272            }
273        }
274    }
275    // A batch truncated mid-way (history cut before all responses): keep any
276    // still-deferred messages at the end; `repair_tool_pairing` will
277    // synthesize placeholders for the unanswered ids right after the
278    // assistant message.
279    result.append(&mut deferred);
280    result
281}
282
283pub struct LlmClient {
284    client: async_openai::Client<OpenAIConfig>,
285    model: String,
286    resolved: ResolvedModel,
287}
288
289impl LlmClient {
290    /// Create a new LlmClient from loaded configuration.
291    ///
292    /// `profile_name`: which profile to use. If `None`, uses the default profile.
293    pub fn from_config(
294        config: &RobitConfig,
295        profile_name: Option<&str>,
296    ) -> Result<Self, LlmError> {
297        let resolved = resolve_profile(config, profile_name)?;
298
299        let oc = OpenAIConfig::new()
300            .with_api_base(&resolved.base_url)
301            .with_api_key(&resolved.api_key);
302
303        let client = async_openai::Client::with_config(oc);
304
305        Ok(Self {
306            client,
307            model: resolved.model_id.clone(),
308            resolved,
309        })
310    }
311
312    /// Streaming chat completion. Returns an async stream of response chunks.
313    pub async fn chat_stream(
314        &self,
315        messages: Vec<ChatCompletionRequestMessage>,
316        tools: Option<Vec<ChatCompletionTools>>,
317    ) -> Result<ChatCompletionResponseStream, LlmError> {
318        // Validate and repair messages before sending to LLM. Interleave
319        // repair must run before tool-pairing repair: it restores the
320        // assistant → tool×N window so the pairing pass can then match, drop,
321        // or synthesize tool responses against a contiguous batch.
322        let messages = validate_and_filter_messages(messages);
323        let messages = repair_interleaved_tool_responses(messages);
324        let messages = repair_tool_pairing(messages);
325        let msg_count = messages.len();
326
327        tracing::trace!("Creating chat stream for model={}, messages={}", self.model, msg_count);
328
329        let request = CreateChatCompletionRequest {
330            model: self.model.clone(),
331            messages,
332            tools,
333            stream: Some(true),
334            // Request usage stats in streaming response.
335            // Supported by OpenAI and DeepSeek (extra chunk before [DONE]).
336            // DeepSeek docs confirm: https://api-docs.deepseek.com/zh-cn/api/create-chat-completion
337            stream_options: Some(async_openai::types::chat::ChatCompletionStreamOptions {
338                include_usage: Some(true),
339                include_obfuscation: None,
340            }),
341            max_completion_tokens: self.resolved.max_tokens,
342            temperature: self.resolved.temperature,
343            ..Default::default()
344        };
345        let stream = self.client.chat().create_stream(request).await;
346        if let Err(e) = &stream {
347            tracing::error!("Chat stream creation failed: {:?}", e);
348        }
349        // Map to a friendly error (e.g. content-moderation rejections carry a
350        // provider error code that deserves a clearer message than the raw
351        // "400 Bad Request ..." display).
352        let stream = stream.map_err(LlmError::from_openai_error)?;
353        Ok(stream)
354    }
355
356    /// Non-streaming chat completion. Returns the full response.
357    pub async fn chat(
358        &self,
359        messages: Vec<ChatCompletionRequestMessage>,
360        tools: Option<Vec<ChatCompletionTools>>,
361    ) -> Result<CreateChatCompletionResponse, LlmError> {
362        // Validate and repair messages before sending to LLM (same pipeline
363        // as `chat_stream`; see the comment there for the repair order).
364        let messages = validate_and_filter_messages(messages);
365        let messages = repair_interleaved_tool_responses(messages);
366        let messages = repair_tool_pairing(messages);
367
368        let request = CreateChatCompletionRequest {
369            model: self.model.clone(),
370            messages,
371            tools,
372            max_completion_tokens: self.resolved.max_tokens,
373            temperature: self.resolved.temperature,
374            ..Default::default()
375        };
376
377        let response = self
378            .client
379            .chat()
380            .create(request)
381            .await
382            .map_err(LlmError::from_openai_error)?;
383        Ok(response)
384    }
385
386    /// Get the current model ID (e.g. "deepseek-chat").
387    pub fn model(&self) -> &str {
388        &self.model
389    }
390
391    /// Get the profile name (e.g. "default").
392    pub fn profile(&self) -> &str {
393        &self.resolved.profile_name
394    }
395
396    /// Get the resolved model info.
397    pub fn resolved(&self) -> &ResolvedModel {
398        &self.resolved
399    }
400
401    /// Whether the current model supports image inputs.
402    pub fn supports_images(&self) -> bool {
403        self.resolved.supports_images
404    }
405
406    /// Whether the current model supports tool calling.
407    pub fn supports_tools(&self) -> bool {
408        self.resolved.supports_tools
409    }
410}
411
412// ============================================================================
413// Tests
414// ============================================================================
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use async_openai::types::chat::{
420        ChatCompletionMessageToolCall, ChatCompletionRequestAssistantMessage,
421        ChatCompletionRequestUserMessage, FunctionCall,
422    };
423
424    fn user_msg(text: &str) -> ChatCompletionRequestMessage {
425        ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
426            content: text.to_string().into(),
427            name: None,
428        })
429    }
430
431    fn assistant_text_msg(text: &str) -> ChatCompletionRequestMessage {
432        ChatCompletionRequestMessage::Assistant(ChatCompletionRequestAssistantMessage {
433            content: Some(text.to_string().into()),
434            name: None,
435            tool_calls: None,
436            refusal: None,
437            audio: None,
438            #[allow(deprecated)]
439            function_call: None,
440        })
441    }
442
443    fn assistant_tool_call_msg(id: &str, name: &str) -> ChatCompletionRequestMessage {
444        ChatCompletionRequestMessage::Assistant(ChatCompletionRequestAssistantMessage {
445            content: None,
446            name: None,
447            tool_calls: Some(vec![ChatCompletionMessageToolCalls::Function(
448                ChatCompletionMessageToolCall {
449                    id: id.to_string(),
450                    function: FunctionCall {
451                        name: name.to_string(),
452                        arguments: "{}".to_string(),
453                    },
454                },
455            )]),
456            refusal: None,
457            audio: None,
458            #[allow(deprecated)]
459            function_call: None,
460        })
461    }
462
463    fn tool_msg(id: &str, text: &str) -> ChatCompletionRequestMessage {
464        ChatCompletionRequestMessage::Tool(ChatCompletionRequestToolMessage {
465            content: text.to_string().into(),
466            tool_call_id: id.to_string(),
467        })
468    }
469
470    #[test]
471    fn repair_drops_orphaned_tool_messages() {
472        // History restored from a DB that saved tool results but not the
473        // assistant message that declared the tool_calls.
474        let messages = vec![
475            user_msg("hello"),
476            assistant_text_msg("hi"),
477            user_msg("do something"),
478            tool_msg("call_1", "orphaned result"),
479            assistant_text_msg("done"),
480        ];
481        let repaired = repair_tool_pairing(messages);
482        assert_eq!(repaired.len(), 4, "orphaned tool message should be dropped");
483        assert!(
484            !repaired
485                .iter()
486                .any(|m| matches!(m, ChatCompletionRequestMessage::Tool(_))),
487            "no tool messages should remain"
488        );
489    }
490
491    #[test]
492    fn repair_synthesizes_missing_tool_responses() {
493        // Assistant declared a tool call but the result was never recorded
494        // (e.g. process killed mid-step).
495        let messages = vec![
496            user_msg("do something"),
497            assistant_tool_call_msg("call_1", "bash"),
498            user_msg("next question"),
499        ];
500        let repaired = repair_tool_pairing(messages);
501        assert_eq!(repaired.len(), 4, "placeholder tool response should be added");
502        // Placeholder must come right after the assistant message.
503        assert!(matches!(repaired[2], ChatCompletionRequestMessage::Tool(_)));
504        if let ChatCompletionRequestMessage::Tool(t) = &repaired[2] {
505            assert_eq!(t.tool_call_id, "call_1");
506        }
507    }
508
509    #[test]
510    fn repair_keeps_valid_pairing_untouched() {
511        let messages = vec![
512            user_msg("do something"),
513            assistant_tool_call_msg("call_1", "bash"),
514            tool_msg("call_1", "ok"),
515            assistant_text_msg("done"),
516        ];
517        let repaired = repair_tool_pairing(messages.clone());
518        assert_eq!(repaired.len(), messages.len(), "valid history must not change");
519    }
520
521    #[test]
522    fn repair_handles_mixed_valid_and_orphaned() {
523        let messages = vec![
524            user_msg("a"),
525            assistant_tool_call_msg("call_1", "read"),
526            tool_msg("call_1", "result 1"), // valid
527            tool_msg("call_ghost", "ghost result"), // orphaned
528            assistant_text_msg("done"),
529        ];
530        let repaired = repair_tool_pairing(messages);
531        assert_eq!(repaired.len(), 4);
532        let tool_ids: Vec<&str> = repaired
533            .iter()
534            .filter_map(|m| {
535                if let ChatCompletionRequestMessage::Tool(t) = m {
536                    Some(t.tool_call_id.as_str())
537                } else {
538                    None
539                }
540            })
541            .collect();
542        assert_eq!(tool_ids, vec!["call_1"]);
543    }
544
545    fn assistant_multi_tool_call_msg(
546        calls: &[(&str, &str)],
547    ) -> ChatCompletionRequestMessage {
548        ChatCompletionRequestMessage::Assistant(ChatCompletionRequestAssistantMessage {
549            content: None,
550            name: None,
551            tool_calls: Some(
552                calls
553                    .iter()
554                    .map(|(id, name)| {
555                        ChatCompletionMessageToolCalls::Function(ChatCompletionMessageToolCall {
556                            id: id.to_string(),
557                            function: FunctionCall {
558                                name: name.to_string(),
559                                arguments: "{}".to_string(),
560                            },
561                        })
562                    })
563                    .collect(),
564            ),
565            refusal: None,
566            audio: None,
567            #[allow(deprecated)]
568            function_call: None,
569        })
570    }
571
572    fn image_user_msg(label: &str) -> ChatCompletionRequestMessage {
573        user_msg(&format!("[工具返回的图片] {}", label))
574    }
575
576    #[test]
577    fn interleave_repair_moves_user_messages_after_tool_batch() {
578        // The exact shape produced by the pre-fix image injection: one
579        // multimodal user message right after EACH tool result of a parallel
580        // tool_calls batch. Providers reject this with 400 "insufficient
581        // tool messages following tool_calls message".
582        let messages = vec![
583            user_msg("generate images"),
584            assistant_multi_tool_call_msg(&[
585                ("call_0", "read"),
586                ("call_1", "read"),
587                ("call_2", "read"),
588            ]),
589            tool_msg("call_0", "Image file: a.png"),
590            image_user_msg("a.png"),
591            tool_msg("call_1", "Image file: b.png"),
592            image_user_msg("b.png"),
593            tool_msg("call_2", "Image file: c.png"),
594            image_user_msg("c.png"),
595        ];
596        let repaired = repair_interleaved_tool_responses(messages);
597        assert_eq!(repaired.len(), 8, "no message may be dropped");
598
599        let role_kinds: Vec<&str> = repaired
600            .iter()
601            .map(|m| match m {
602                ChatCompletionRequestMessage::User(_) => "user",
603                ChatCompletionRequestMessage::Assistant(a) => {
604                    if a.tool_calls.is_some() {
605                        "assistant+tool_calls"
606                    } else {
607                        "assistant"
608                    }
609                }
610                ChatCompletionRequestMessage::Tool(_) => "tool",
611                _ => "other",
612            })
613            .collect();
614        // Tool responses must directly follow the assistant tool_calls
615        // message, with all image user messages moved after the batch.
616        assert_eq!(
617            role_kinds,
618            vec![
619                "user",
620                "assistant+tool_calls",
621                "tool",
622                "tool",
623                "tool",
624                "user",
625                "user",
626                "user",
627            ]
628        );
629        // Relative order of the deferred image messages is preserved.
630        let user_texts: Vec<String> = repaired
631            .iter()
632            .filter_map(|m| {
633                if let ChatCompletionRequestMessage::User(u) = m {
634                    if let async_openai::types::chat::ChatCompletionRequestUserMessageContent::Text(t) = &u.content {
635                        Some(t.clone())
636                    } else {
637                        None
638                    }
639                } else {
640                    None
641                }
642            })
643            .collect();
644        assert_eq!(
645            user_texts.last().map(|t| t.contains("c.png")),
646            Some(true),
647            "deferred messages keep their original order (c.png last)"
648        );
649    }
650
651    #[test]
652    fn interleave_repair_keeps_valid_history_untouched() {
653        let messages = vec![
654            user_msg("look at this"),
655            assistant_tool_call_msg("call_1", "read"),
656            tool_msg("call_1", "Image file: a.png"),
657            image_user_msg("a.png"),
658            assistant_text_msg("looks great"),
659        ];
660        let repaired = repair_interleaved_tool_responses(messages.clone());
661        assert_eq!(
662            repaired.len(),
663            messages.len(),
664            "valid history must not change length"
665        );
666        // And not just length: a valid history must pass through unchanged.
667        let summarize = |m: &ChatCompletionRequestMessage| match m {
668            ChatCompletionRequestMessage::User(u) => format!("user:{:?}", u.content),
669            ChatCompletionRequestMessage::Assistant(a) => format!(
670                "assistant:{:?}:{:?}",
671                a.content, a.tool_calls.as_ref().map(|tcs| tcs.len())
672            ),
673            ChatCompletionRequestMessage::Tool(t) => {
674                format!("tool:{}:{:?}", t.tool_call_id, t.content)
675            }
676            _ => "other".to_string(),
677        };
678        let before: Vec<String> = messages.iter().map(summarize).collect();
679        let after: Vec<String> = repaired.iter().map(summarize).collect();
680        assert_eq!(before, after, "valid history must not be reordered");
681    }
682
683    #[test]
684    fn interleave_repair_truncated_batch_defers_to_end() {
685        // Batch never fully answered (e.g. truncated history): the interleaved
686        // user message still moves after the last tool response of the batch.
687        let messages = vec![
688            user_msg("do something"),
689            assistant_multi_tool_call_msg(&[("call_0", "read"), ("call_1", "read")]),
690            tool_msg("call_0", "result 0"),
691            image_user_msg("a.png"),
692        ];
693        let repaired = repair_interleaved_tool_responses(messages);
694        assert_eq!(repaired.len(), 4);
695        assert!(matches!(repaired[1], ChatCompletionRequestMessage::Assistant(_)));
696        assert!(matches!(repaired[2], ChatCompletionRequestMessage::Tool(_)));
697        assert!(matches!(repaired[3], ChatCompletionRequestMessage::User(_)));
698    }
699
700    #[test]
701    fn interleave_repair_flushes_deferred_before_next_assistant_batch() {
702        // A new assistant tool_calls message arriving while the previous batch
703        // is still unanswered: deferred messages are flushed before it.
704        let messages = vec![
705            user_msg("start"),
706            assistant_multi_tool_call_msg(&[("call_0", "read"), ("call_1", "read")]),
707            tool_msg("call_0", "result 0"),
708            image_user_msg("a.png"),
709            assistant_tool_call_msg("call_2", "bash"),
710            tool_msg("call_2", "ok"),
711        ];
712        let repaired = repair_interleaved_tool_responses(messages);
713        let role_kinds: Vec<&str> = repaired
714            .iter()
715            .map(|m| match m {
716                ChatCompletionRequestMessage::User(_) => "user",
717                ChatCompletionRequestMessage::Assistant(_) => "assistant",
718                ChatCompletionRequestMessage::Tool(_) => "tool",
719                _ => "other",
720            })
721            .collect();
722        assert_eq!(
723            role_kinds,
724            vec!["user", "assistant", "tool", "user", "assistant", "tool"]
725        );
726    }
727}