Skip to main content

aster/conversation/
mod.rs

1use crate::conversation::message::{Message, MessageContent, MessageMetadata};
2use rmcp::model::Role;
3use serde::{Deserialize, Serialize};
4use std::collections::HashSet;
5use thiserror::Error;
6use utoipa::ToSchema;
7
8pub mod message;
9mod tool_result_serde;
10
11#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)]
12pub struct Conversation(Vec<Message>);
13
14#[derive(Error, Debug)]
15#[error("invalid conversation: {reason}")]
16pub struct InvalidConversation {
17    reason: String,
18    conversation: Conversation,
19}
20
21impl Conversation {
22    pub fn new<I>(messages: I) -> Result<Self, InvalidConversation>
23    where
24        I: IntoIterator<Item = Message>,
25    {
26        Self::new_unvalidated(messages).validate()
27    }
28
29    pub fn new_unvalidated<I>(messages: I) -> Self
30    where
31        I: IntoIterator<Item = Message>,
32    {
33        Self(messages.into_iter().collect())
34    }
35
36    pub fn empty() -> Self {
37        Self::new_unvalidated([])
38    }
39
40    pub fn messages(&self) -> &Vec<Message> {
41        &self.0
42    }
43
44    pub fn push(&mut self, message: Message) {
45        if let Some(last) = self
46            .0
47            .last_mut()
48            .filter(|m| m.id.is_some() && m.id == message.id)
49        {
50            match (last.content.last_mut(), message.content.last()) {
51                (Some(MessageContent::Text(ref mut last)), Some(MessageContent::Text(new)))
52                    if message.content.len() == 1 =>
53                {
54                    last.text.push_str(&new.text);
55                }
56                (_, _) => {
57                    last.content.extend(message.content);
58                }
59            }
60        } else {
61            self.0.push(message);
62        }
63    }
64
65    pub fn last(&self) -> Option<&Message> {
66        self.0.last()
67    }
68
69    pub fn first(&self) -> Option<&Message> {
70        self.0.first()
71    }
72
73    pub fn len(&self) -> usize {
74        self.0.len()
75    }
76
77    pub fn is_empty(&self) -> bool {
78        self.0.is_empty()
79    }
80
81    pub fn extend<I>(&mut self, iter: I)
82    where
83        I: IntoIterator<Item = Message>,
84    {
85        for message in iter {
86            self.push(message);
87        }
88    }
89
90    pub fn iter(&self) -> std::slice::Iter<'_, Message> {
91        self.0.iter()
92    }
93
94    pub fn pop(&mut self) -> Option<Message> {
95        self.0.pop()
96    }
97
98    pub fn truncate(&mut self, len: usize) {
99        self.0.truncate(len);
100    }
101
102    pub fn clear(&mut self) {
103        self.0.clear();
104    }
105
106    pub fn filtered_messages<F>(&self, filter: F) -> Vec<Message>
107    where
108        F: Fn(&MessageMetadata) -> bool,
109    {
110        self.0
111            .iter()
112            .filter(|msg| filter(&msg.metadata))
113            .cloned()
114            .collect()
115    }
116
117    pub fn agent_visible_messages(&self) -> Vec<Message> {
118        self.filtered_messages(|meta| meta.agent_visible)
119    }
120
121    pub fn user_visible_messages(&self) -> Vec<Message> {
122        self.filtered_messages(|meta| meta.user_visible)
123    }
124
125    fn validate(self) -> Result<Self, InvalidConversation> {
126        let (_messages, issues) = fix_messages(self.0.clone());
127        if !issues.is_empty() {
128            let reason = issues.join("\n");
129            Err(InvalidConversation {
130                reason,
131                conversation: self,
132            })
133        } else {
134            Ok(self)
135        }
136    }
137}
138
139impl Default for Conversation {
140    fn default() -> Self {
141        Self::empty()
142    }
143}
144
145impl IntoIterator for Conversation {
146    type Item = Message;
147    type IntoIter = std::vec::IntoIter<Message>;
148
149    fn into_iter(self) -> Self::IntoIter {
150        self.0.into_iter()
151    }
152}
153impl<'a> IntoIterator for &'a Conversation {
154    type Item = &'a Message;
155    type IntoIter = std::slice::Iter<'a, Message>;
156
157    fn into_iter(self) -> Self::IntoIter {
158        self.0.iter()
159    }
160}
161
162/// Fix a conversation that we're about to send to an LLM. So the last and first
163/// messages should always be from the user.
164pub fn fix_conversation(conversation: Conversation) -> (Conversation, Vec<String>) {
165    let all_messages = conversation.messages();
166
167    // Create a shadow map: track each message as either Visible or NonVisible with its index
168    enum MessageSlot {
169        Visible(usize),      // Index into agent_visible_messages
170        NonVisible(Message), // Non-visible messages pass through unchanged
171    }
172
173    let mut agent_visible_messages = Vec::new();
174    let shadow_map: Vec<MessageSlot> = all_messages
175        .iter()
176        .map(|msg| {
177            if msg.metadata.agent_visible {
178                let idx = agent_visible_messages.len();
179                agent_visible_messages.push(msg.clone());
180                MessageSlot::Visible(idx)
181            } else {
182                MessageSlot::NonVisible(msg.clone())
183            }
184        })
185        .collect();
186
187    // Fix only the agent-visible messages
188    let (fixed_visible, issues) = fix_messages(agent_visible_messages);
189
190    // Reconstruct using shadow map: replace Visible slots with fixed messages
191    let final_messages: Vec<Message> = shadow_map
192        .into_iter()
193        .filter_map(|slot| match slot {
194            MessageSlot::Visible(idx) => fixed_visible.get(idx).cloned(),
195            MessageSlot::NonVisible(msg) => Some(msg),
196        })
197        .collect();
198
199    (Conversation::new_unvalidated(final_messages), issues)
200}
201
202fn fix_messages(messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
203    [
204        merge_text_content_items,
205        trim_assistant_text_whitespace,
206        remove_empty_messages,
207        fix_tool_calling,
208        merge_consecutive_messages,
209        fix_lead_trail,
210        populate_if_empty,
211    ]
212    .into_iter()
213    .fold(
214        (messages, Vec::new()),
215        |(msgs, mut all_issues), processor| {
216            let (new_msgs, issues) = processor(msgs);
217            all_issues.extend(issues);
218            (new_msgs, all_issues)
219        },
220    )
221}
222
223fn merge_text_content_in_message(mut msg: Message) -> Message {
224    if msg.role != Role::Assistant {
225        return msg;
226    }
227    msg.content = msg
228        .content
229        .into_iter()
230        .fold(Vec::new(), |mut content, item| {
231            match item {
232                MessageContent::Text(text) => {
233                    if let Some(MessageContent::Text(ref mut last)) = content.last_mut() {
234                        last.text.push_str(&text.text);
235                    } else {
236                        content.push(MessageContent::Text(text));
237                    }
238                }
239                other => content.push(other),
240            }
241            content
242        });
243    msg
244}
245
246fn merge_text_content_items(messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
247    messages.into_iter().fold(
248        (Vec::new(), Vec::new()),
249        |(mut messages, mut issues), message| {
250            let content_len = message.content.len();
251            let message = merge_text_content_in_message(message);
252            if content_len != message.content.len() {
253                issues.push(String::from("Merged text content"))
254            }
255            messages.push(message);
256            (messages, issues)
257        },
258    )
259}
260
261fn trim_assistant_text_whitespace(messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
262    let mut issues = Vec::new();
263
264    let fixed_messages = messages
265        .into_iter()
266        .map(|mut message| {
267            if message.role == Role::Assistant {
268                for content in &mut message.content {
269                    if let MessageContent::Text(text) = content {
270                        let trimmed = text.text.trim_end();
271                        if trimmed.len() != text.text.len() {
272                            issues.push(
273                                "Trimmed trailing whitespace from assistant message".to_string(),
274                            );
275                            text.text = trimmed.to_string();
276                        }
277                    }
278                }
279            }
280            message
281        })
282        .collect();
283
284    (fixed_messages, issues)
285}
286
287fn remove_empty_messages(messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
288    let mut issues = Vec::new();
289    let filtered_messages = messages
290        .into_iter()
291        .filter(|msg| {
292            if msg
293                .content
294                .iter()
295                .all(|c| c.as_text().is_some_and(str::is_empty))
296            {
297                issues.push("Removed empty message".to_string());
298                false
299            } else {
300                true
301            }
302        })
303        .collect();
304    (filtered_messages, issues)
305}
306
307fn fix_tool_calling(mut messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
308    let mut issues = Vec::new();
309    let mut pending_tool_requests: HashSet<String> = HashSet::new();
310
311    for message in &mut messages {
312        let mut content_to_remove = Vec::new();
313
314        match message.role {
315            Role::User => {
316                for (idx, content) in message.content.iter().enumerate() {
317                    match content {
318                        MessageContent::ToolRequest(req) => {
319                            content_to_remove.push(idx);
320                            issues.push(format!(
321                                "Removed tool request '{}' from user message",
322                                req.id
323                            ));
324                        }
325                        MessageContent::ToolConfirmationRequest(req) => {
326                            content_to_remove.push(idx);
327                            issues.push(format!(
328                                "Removed tool confirmation request '{}' from user message",
329                                req.id
330                            ));
331                        }
332                        MessageContent::Thinking(_) | MessageContent::RedactedThinking(_) => {
333                            content_to_remove.push(idx);
334                            issues.push("Removed thinking content from user message".to_string());
335                        }
336                        MessageContent::ToolResponse(resp) => {
337                            if pending_tool_requests.contains(&resp.id) {
338                                pending_tool_requests.remove(&resp.id);
339                            } else {
340                                content_to_remove.push(idx);
341                                issues
342                                    .push(format!("Removed orphaned tool response '{}'", resp.id));
343                            }
344                        }
345                        _ => {}
346                    }
347                }
348            }
349            Role::Assistant => {
350                for (idx, content) in message.content.iter().enumerate() {
351                    match content {
352                        MessageContent::ToolResponse(resp) => {
353                            content_to_remove.push(idx);
354                            issues.push(format!(
355                                "Removed tool response '{}' from assistant message",
356                                resp.id
357                            ));
358                        }
359                        MessageContent::FrontendToolRequest(req) => {
360                            content_to_remove.push(idx);
361                            issues.push(format!(
362                                "Removed frontend tool request '{}' from assistant message",
363                                req.id
364                            ));
365                        }
366                        MessageContent::ToolRequest(req) => {
367                            pending_tool_requests.insert(req.id.clone());
368                        }
369                        _ => {}
370                    }
371                }
372            }
373        }
374
375        for &idx in content_to_remove.iter().rev() {
376            message.content.remove(idx);
377        }
378    }
379
380    for message in &mut messages {
381        if message.role == Role::Assistant {
382            let mut content_to_remove = Vec::new();
383            for (idx, content) in message.content.iter().enumerate() {
384                if let MessageContent::ToolRequest(req) = content {
385                    if pending_tool_requests.contains(&req.id) {
386                        content_to_remove.push(idx);
387                        issues.push(format!("Removed orphaned tool request '{}'", req.id));
388                    }
389                }
390            }
391            for &idx in content_to_remove.iter().rev() {
392                message.content.remove(idx);
393            }
394        }
395    }
396    let (messages, empty_removed) = remove_empty_messages(messages);
397    issues.extend(empty_removed);
398    (messages, issues)
399}
400
401pub fn merge_consecutive_messages(messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
402    let mut issues = Vec::new();
403    let mut merged_messages: Vec<Message> = Vec::new();
404
405    for message in messages {
406        if let Some(last) = merged_messages.last_mut() {
407            let effective = effective_role(&message);
408            if effective_role(last) == effective {
409                last.content.extend(message.content);
410                issues.push(format!("Merged consecutive {} messages", effective));
411                continue;
412            }
413        }
414        merged_messages.push(message);
415    }
416
417    (merged_messages, issues)
418}
419
420fn has_tool_response(message: &Message) -> bool {
421    message
422        .content
423        .iter()
424        .any(|content| matches!(content, MessageContent::ToolResponse(_)))
425}
426
427pub fn effective_role(message: &Message) -> String {
428    if message.role == Role::User && has_tool_response(message) {
429        "tool".to_string()
430    } else {
431        match message.role {
432            Role::User => "user".to_string(),
433            Role::Assistant => "assistant".to_string(),
434        }
435    }
436}
437
438fn fix_lead_trail(mut messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
439    let mut issues = Vec::new();
440
441    if let Some(first) = messages.first() {
442        if first.role == Role::Assistant {
443            messages.remove(0);
444            issues.push("Removed leading assistant message".to_string());
445        }
446    }
447
448    if let Some(last) = messages.last() {
449        if last.role == Role::Assistant {
450            messages.pop();
451            issues.push("Removed trailing assistant message".to_string());
452        }
453    }
454
455    (messages, issues)
456}
457
458const PLACEHOLDER_USER_MESSAGE: &str = "Hello";
459
460fn populate_if_empty(mut messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
461    let mut issues = Vec::new();
462
463    if messages.is_empty() {
464        issues.push("Added placeholder user message to empty conversation".to_string());
465        messages.push(Message::user().with_text(PLACEHOLDER_USER_MESSAGE));
466    }
467    (messages, issues)
468}
469
470pub fn debug_conversation_fix(
471    messages: &[Message],
472    fixed: &[Message],
473    issues: &[String],
474) -> String {
475    let mut output = String::new();
476
477    output.push_str("=== CONVERSATION FIX DEBUG ===\n\n");
478
479    output.push_str("BEFORE:\n");
480    for (i, msg) in messages.iter().enumerate() {
481        output.push_str(&format!("  [{}] {}\n", i, msg.debug()));
482    }
483
484    output.push_str("\nISSUES FOUND:\n");
485    if issues.is_empty() {
486        output.push_str("  (none)\n");
487    } else {
488        for issue in issues {
489            output.push_str(&format!("  - {}\n", issue));
490        }
491    }
492
493    output.push_str("\nAFTER:\n");
494    for (i, msg) in fixed.iter().enumerate() {
495        output.push_str(&format!("  [{}] {}\n", i, msg.debug()));
496    }
497
498    output.push_str("\n==============================\n");
499    output
500}
501
502#[cfg(test)]
503mod tests {
504    use crate::conversation::message::Message;
505    use crate::conversation::{debug_conversation_fix, fix_conversation, Conversation};
506    use rmcp::model::{CallToolRequestParam, Role};
507    use rmcp::object;
508
509    macro_rules! assert_has_issues_unordered {
510        ($fixed:expr, $issues:expr, $($expected:expr),+ $(,)?) => {
511            {
512                let mut expected: Vec<&str> = vec![$($expected),+];
513                let mut actual: Vec<&str> = $issues.iter().map(|s| s.as_str()).collect();
514                expected.sort();
515                actual.sort();
516
517                if actual != expected {
518                    panic!(
519                        "assertion failed: issues don't match\nexpected: {:?}\n  actual: {:?}. Fixed conversation is:\n{:#?}",
520                        expected, $issues, $fixed,
521                    );
522                }
523            }
524        };
525    }
526
527    fn run_verify(messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
528        let (fixed, issues) = fix_conversation(Conversation::new_unvalidated(messages.clone()));
529
530        // Uncomment the following line to print the debug report
531        // let report = debug_conversation_fix(&messages, &fixed, &issues);
532        // print!("\n{}", report);
533
534        let (_fixed, issues_with_fixed) = fix_conversation(fixed.clone());
535        assert_eq!(
536            issues_with_fixed.len(),
537            0,
538            "Fixed conversation should have no issues, but found: {:?}\n\n{}",
539            issues_with_fixed,
540            debug_conversation_fix(&messages, fixed.messages(), &issues)
541        );
542        (fixed.messages().clone(), issues)
543    }
544
545    #[test]
546    fn test_valid_conversation() {
547        let all_messages = [
548            Message::user().with_text("Can you help me search for something?"),
549            Message::assistant()
550                .with_text("I'll help you search.")
551                .with_tool_request(
552                    "search_1",
553                    Ok(CallToolRequestParam {
554                        name: "web_search".into(),
555                        arguments: Some(object!({"query": "rust programming"})),
556                    }),
557                ),
558            Message::user().with_tool_response(
559                "search_1",
560                Ok(rmcp::model::CallToolResult {
561                    content: vec![],
562                    structured_content: None,
563                    is_error: Some(false),
564                    meta: None,
565                }),
566            ),
567            Message::assistant().with_text("Based on the search results, here's what I found..."),
568        ];
569
570        for i in 1..=all_messages.len() {
571            let messages = Conversation::new_unvalidated(all_messages[..i].to_vec());
572            if messages.last().unwrap().role == Role::User {
573                let (fixed, issues) = fix_conversation(messages.clone());
574                assert_eq!(
575                    fixed.len(),
576                    messages.len(),
577                    "Step {}: Length should match",
578                    i
579                );
580                assert!(
581                    issues.is_empty(),
582                    "Step {}: Should have no issues, but found: {:?}",
583                    i,
584                    issues
585                );
586                assert_eq!(
587                    fixed.messages(),
588                    messages.messages(),
589                    "Step {}: Messages should be unchanged",
590                    i
591                );
592            }
593        }
594    }
595
596    #[test]
597    fn test_role_alternation_and_content_placement_issues() {
598        let messages = vec![
599            Message::user().with_text("Hello"),
600            Message::user().with_text("Another user message"),
601            Message::assistant()
602                .with_text("Response")
603                .with_tool_response(
604                    "orphan_1",
605                    Ok(rmcp::model::CallToolResult {
606                        content: vec![],
607                        structured_content: None,
608                        is_error: Some(false),
609                        meta: None,
610                    }),
611                ), // Wrong role
612            Message::assistant().with_thinking("Let me think", "sig"),
613            Message::user()
614                .with_tool_request(
615                    "bad_req",
616                    Ok(CallToolRequestParam {
617                        name: "search".into(),
618                        arguments: Some(object!({})),
619                    }),
620                )
621                .with_text("User with bad tool request"),
622        ];
623
624        let (fixed, issues) = run_verify(messages);
625
626        assert_eq!(fixed.len(), 3);
627
628        assert_has_issues_unordered!(
629            fixed,
630            issues,
631            "Merged consecutive assistant messages",
632            "Merged consecutive user messages",
633            "Removed tool response 'orphan_1' from assistant message",
634            "Removed tool request 'bad_req' from user message",
635        );
636
637        assert_eq!(fixed[0].role, Role::User);
638        assert_eq!(fixed[1].role, Role::Assistant);
639        assert_eq!(fixed[2].role, Role::User);
640
641        assert_eq!(fixed[0].content.len(), 2);
642    }
643
644    #[test]
645    fn test_orphaned_tools_and_empty_messages() {
646        // This conversation completely collapses. the first user message is invalid
647        // then we remove the empty user message and the wrong tool response
648        // then we collapse the assistant messages
649        // which we then remove because you can't end a conversation with an assistant message
650        let messages = vec![
651            Message::assistant()
652                .with_text("I'll search for you")
653                .with_tool_request(
654                    "search_1",
655                    Ok(CallToolRequestParam {
656                        name: "search".into(),
657                        arguments: Some(object!({})),
658                    }),
659                ),
660            Message::user(),
661            Message::user().with_tool_response(
662                "wrong_id",
663                Ok(rmcp::model::CallToolResult {
664                    content: vec![],
665                    structured_content: None,
666                    is_error: Some(false),
667                    meta: None,
668                }),
669            ),
670            Message::assistant().with_tool_request(
671                "search_2",
672                Ok(CallToolRequestParam {
673                    name: "search".into(),
674                    arguments: Some(object!({})),
675                }),
676            ),
677        ];
678
679        let (fixed, issues) = run_verify(messages);
680
681        assert_eq!(fixed.len(), 1);
682
683        assert_has_issues_unordered!(
684            fixed,
685            issues,
686            "Removed empty message",
687            "Removed orphaned tool response 'wrong_id'",
688            "Removed orphaned tool request 'search_1'",
689            "Removed orphaned tool request 'search_2'",
690            "Removed empty message",
691            "Removed empty message",
692            "Removed leading assistant message",
693            "Added placeholder user message to empty conversation",
694        );
695
696        assert_eq!(fixed[0].role, Role::User);
697        assert_eq!(fixed[0].as_concat_text(), "Hello");
698    }
699
700    #[test]
701    fn test_real_world_consecutive_assistant_messages() {
702        let conversation = Conversation::new_unvalidated(vec![
703            Message::user().with_text("run ls in the current directory and then run a word count on the smallest file"),
704
705            Message::assistant()
706                .with_text("I'll help you run `ls` in the current directory and then perform a word count on the smallest file. Let me start by listing the directory contents.")
707                .with_tool_request("toolu_bdrk_018adWbP4X26CfoJU5hkhu3i", Ok(CallToolRequestParam { name: "developer__shell".into(), arguments: Some(object!({"command": "ls -la"})) })),
708
709            Message::assistant()
710                .with_text("Now I'll identify the smallest file by size. Looking at the output, I can see that both `slack.yaml` and `subrecipes.yaml` have a size of 0 bytes, making them the smallest files. I'll run a word count on one of them:")
711                .with_tool_request("toolu_bdrk_01KgDYHs4fAodi22NqxRzmwx", Ok(CallToolRequestParam { name: "developer__shell".into(), arguments: Some(object!({"command": "wc slack.yaml"})) })),
712
713            Message::user()
714                .with_tool_response("toolu_bdrk_01KgDYHs4fAodi22NqxRzmwx", Ok(rmcp::model::CallToolResult {
715                    content: vec![],
716                    structured_content: None,
717                    is_error: Some(false),
718                    meta: None,
719                })),
720
721            Message::assistant()
722                .with_text("I ran `ls -la` in the current directory and found several files. Looking at the file sizes, I can see that both `slack.yaml` and `subrecipes.yaml` are 0 bytes (the smallest files). I ran a word count on `slack.yaml` which shows: **0 lines**, **0 words**, **0 characters**"),
723            Message::user().with_text("thanks!"),
724        ]);
725
726        let (fixed, issues) = fix_conversation(conversation);
727
728        assert_eq!(fixed.len(), 5);
729        assert_has_issues_unordered!(
730            fixed,
731            issues,
732            "Removed orphaned tool request 'toolu_bdrk_018adWbP4X26CfoJU5hkhu3i'",
733            "Merged consecutive assistant messages"
734        )
735    }
736
737    #[test]
738    fn test_tool_response_effective_role() {
739        let messages = vec![
740            Message::user().with_text("Search for something"),
741            Message::assistant()
742                .with_text("I'll search for you")
743                .with_tool_request(
744                    "search_1",
745                    Ok(CallToolRequestParam {
746                        name: "search".into(),
747                        arguments: Some(object!({})),
748                    }),
749                ),
750            Message::user().with_tool_response(
751                "search_1",
752                Ok(rmcp::model::CallToolResult {
753                    content: vec![],
754                    structured_content: None,
755                    is_error: Some(false),
756                    meta: None,
757                }),
758            ),
759            Message::user().with_text("Thanks!"),
760        ];
761
762        let (_fixed, issues) = run_verify(messages);
763        assert!(issues.is_empty());
764    }
765
766    #[test]
767    fn test_merge_text_content_items() {
768        use crate::conversation::message::MessageContent;
769        use rmcp::model::{AnnotateAble, RawTextContent};
770
771        let mut message = Message::assistant().with_text("Hello");
772
773        message.content.push(MessageContent::Text(
774            RawTextContent {
775                text: " world".to_string(),
776                meta: None,
777            }
778            .no_annotation(),
779        ));
780        message.content.push(MessageContent::Text(
781            RawTextContent {
782                text: "!".to_string(),
783                meta: None,
784            }
785            .no_annotation(),
786        ));
787
788        let messages = vec![
789            Message::user().with_text("hello"),
790            message,
791            Message::user().with_text("thanks"),
792        ];
793
794        let (fixed, issues) = run_verify(messages);
795
796        assert_eq!(fixed.len(), 3);
797        assert_has_issues_unordered!(fixed, issues, "Merged text content");
798
799        let fixed_msg = &fixed[1];
800        assert_eq!(fixed_msg.content.len(), 1);
801
802        if let MessageContent::Text(text_content) = &fixed_msg.content[0] {
803            assert_eq!(text_content.text, "Hello world!");
804        } else {
805            panic!("Expected text content");
806        }
807    }
808
809    #[test]
810    fn test_merge_text_content_items_with_mixed_content() {
811        use crate::conversation::message::MessageContent;
812        use rmcp::model::{AnnotateAble, RawTextContent};
813
814        let mut image_message = Message::assistant().with_text("Look at");
815
816        image_message.content.push(MessageContent::Text(
817            RawTextContent {
818                text: " this image:".to_string(),
819                meta: None,
820            }
821            .no_annotation(),
822        ));
823
824        image_message = image_message.with_image("", "");
825
826        let messages = vec![
827            Message::user().with_text("hello"),
828            image_message,
829            Message::user().with_text("thanks"),
830        ];
831
832        let (fixed, issues) = run_verify(messages);
833
834        assert_eq!(fixed.len(), 3);
835        assert_has_issues_unordered!(fixed, issues, "Merged text content");
836        let fixed_msg = &fixed[1];
837
838        assert_eq!(fixed_msg.content.len(), 2);
839        if let MessageContent::Text(text_content) = &fixed_msg.content[0] {
840            assert_eq!(text_content.text, "Look at this image:");
841        } else {
842            panic!("Expected first item to be text content");
843        }
844
845        if let MessageContent::Image(_) = &fixed_msg.content[1] {
846            // Good
847        } else {
848            panic!("Expected second item to be an image");
849        }
850    }
851
852    #[test]
853    fn test_agent_visible_non_visible_message_ordering_with_fixes() {
854        // Test that non-visible messages maintain their position relative to visible messages
855        // even when visible messages are fixed (merged, removed, etc.)
856
857        // Create messages with mixed visibility where visible ones need fixing
858        let mut msg1_user = Message::user().with_text("First user message");
859        msg1_user.metadata.agent_visible = true;
860
861        let mut msg2_non_visible = Message::user().with_text("Non-visible note 1");
862        msg2_non_visible.metadata.agent_visible = false;
863
864        // These two consecutive user messages should be merged (triggering a fix)
865        let mut msg3_user = Message::user().with_text("Second user message");
866        msg3_user.metadata.agent_visible = true;
867
868        let mut msg4_user = Message::user().with_text("Third user message");
869        msg4_user.metadata.agent_visible = true;
870
871        let mut msg5_non_visible = Message::user().with_text("Non-visible note 2");
872        msg5_non_visible.metadata.agent_visible = false;
873
874        let mut msg6_assistant = Message::assistant().with_text("Assistant response");
875        msg6_assistant.metadata.agent_visible = true;
876
877        let mut msg7_non_visible = Message::user().with_text("Non-visible note 3");
878        msg7_non_visible.metadata.agent_visible = false;
879
880        let mut msg8_user = Message::user().with_text("Final user message");
881        msg8_user.metadata.agent_visible = true;
882
883        let messages = vec![
884            msg1_user.clone(),
885            msg2_non_visible.clone(),
886            msg3_user.clone(),
887            msg4_user.clone(),
888            msg5_non_visible.clone(),
889            msg6_assistant.clone(),
890            msg7_non_visible.clone(),
891            msg8_user.clone(),
892        ];
893
894        let (fixed, issues) = fix_conversation(Conversation::new_unvalidated(messages.clone()));
895
896        // Should have merged consecutive user messages
897        assert!(!issues.is_empty());
898        assert!(issues.iter().any(|i| i.contains("Merged consecutive")));
899
900        let fixed_messages = fixed.messages();
901
902        // Verify non-visible messages are still present
903        let non_visible_texts: Vec<String> = fixed_messages
904            .iter()
905            .filter(|m| !m.metadata.agent_visible)
906            .map(|m| m.as_concat_text())
907            .collect();
908
909        assert_eq!(non_visible_texts.len(), 3);
910        assert_eq!(non_visible_texts[0], "Non-visible note 1");
911        assert_eq!(non_visible_texts[1], "Non-visible note 2");
912        assert_eq!(non_visible_texts[2], "Non-visible note 3");
913
914        // Verify visible messages were processed
915        let visible_texts: Vec<String> = fixed_messages
916            .iter()
917            .filter(|m| m.metadata.agent_visible)
918            .map(|m| m.as_concat_text())
919            .collect();
920
921        // Should have 3 visible messages: first user, merged user messages, assistant, final user
922        // But after merging consecutive users and fixing lead/trail, we get fewer
923        assert!(!visible_texts.is_empty());
924
925        // The key assertion: non-visible messages should be preserved and not reordered
926        // relative to each other
927        let mut found_note1 = false;
928        let mut found_note2 = false;
929
930        for msg in fixed_messages {
931            let text = msg.as_concat_text();
932            if text == "Non-visible note 1" {
933                assert!(!found_note2 && !found_note1);
934                found_note1 = true;
935            } else if text == "Non-visible note 2" {
936                assert!(found_note1 && !found_note2);
937                found_note2 = true;
938            } else if text == "Non-visible note 3" {
939                assert!(found_note1 && found_note2);
940            }
941        }
942    }
943
944    #[test]
945    fn test_shadow_map_with_multiple_consecutive_merges() {
946        // Test the shadow map handles multiple consecutive visible messages that all merge
947        let mut msg1 = Message::user().with_text("User 1");
948        msg1.metadata.agent_visible = true;
949
950        let mut msg2_non_vis = Message::user().with_text("Non-visible A");
951        msg2_non_vis.metadata.agent_visible = false;
952
953        let mut msg3 = Message::user().with_text("User 2");
954        msg3.metadata.agent_visible = true;
955
956        let mut msg4 = Message::user().with_text("User 3");
957        msg4.metadata.agent_visible = true;
958
959        let mut msg5 = Message::user().with_text("User 4");
960        msg5.metadata.agent_visible = true;
961
962        let mut msg6_non_vis = Message::user().with_text("Non-visible B");
963        msg6_non_vis.metadata.agent_visible = false;
964
965        let messages = vec![
966            msg1,
967            msg2_non_vis.clone(),
968            msg3,
969            msg4,
970            msg5,
971            msg6_non_vis.clone(),
972        ];
973
974        let (fixed, issues) = fix_conversation(Conversation::new_unvalidated(messages));
975
976        // Should have merged the consecutive user messages
977        assert!(issues.iter().any(|i| i.contains("Merged consecutive")));
978
979        let fixed_messages = fixed.messages();
980
981        // Non-visible messages should still be present and in order
982        let non_visible: Vec<String> = fixed_messages
983            .iter()
984            .filter(|m| !m.metadata.agent_visible)
985            .map(|m| m.as_concat_text())
986            .collect();
987
988        assert_eq!(non_visible.len(), 2);
989        assert_eq!(non_visible[0], "Non-visible A");
990        assert_eq!(non_visible[1], "Non-visible B");
991
992        // The merged message should contain all the user texts
993        let visible: Vec<String> = fixed_messages
994            .iter()
995            .filter(|m| m.metadata.agent_visible)
996            .map(|m| m.as_concat_text())
997            .collect();
998
999        assert_eq!(visible.len(), 1);
1000        assert!(visible[0].contains("User 1"));
1001        assert!(visible[0].contains("User 2"));
1002        assert!(visible[0].contains("User 3"));
1003        assert!(visible[0].contains("User 4"));
1004    }
1005
1006    #[test]
1007    fn test_shadow_map_with_leading_trailing_removal() {
1008        // Test that shadow map handles removal of leading/trailing assistant messages
1009        let mut msg1_assistant = Message::assistant().with_text("Leading assistant");
1010        msg1_assistant.metadata.agent_visible = true;
1011
1012        let mut msg2_non_vis = Message::user().with_text("Non-visible note");
1013        msg2_non_vis.metadata.agent_visible = false;
1014
1015        let mut msg3_user = Message::user().with_text("User message");
1016        msg3_user.metadata.agent_visible = true;
1017
1018        let mut msg4_assistant = Message::assistant().with_text("Assistant response");
1019        msg4_assistant.metadata.agent_visible = true;
1020
1021        let mut msg5_assistant = Message::assistant().with_text("Trailing assistant");
1022        msg5_assistant.metadata.agent_visible = true;
1023
1024        let messages = vec![
1025            msg1_assistant,
1026            msg2_non_vis.clone(),
1027            msg3_user,
1028            msg4_assistant,
1029            msg5_assistant,
1030        ];
1031
1032        let (fixed, issues) = fix_conversation(Conversation::new_unvalidated(messages));
1033
1034        // Should have merged consecutive assistants, removed leading, and removed trailing
1035        assert!(issues
1036            .iter()
1037            .any(|i| i.contains("Merged consecutive assistant")));
1038        assert!(issues
1039            .iter()
1040            .any(|i| i.contains("Removed leading assistant")));
1041        assert!(issues
1042            .iter()
1043            .any(|i| i.contains("Removed trailing assistant")));
1044
1045        let fixed_messages = fixed.messages();
1046
1047        // Non-visible message should still be present
1048        let non_visible: Vec<String> = fixed_messages
1049            .iter()
1050            .filter(|m| !m.metadata.agent_visible)
1051            .map(|m| m.as_concat_text())
1052            .collect();
1053
1054        assert_eq!(non_visible.len(), 1);
1055        assert_eq!(non_visible[0], "Non-visible note");
1056
1057        // The two consecutive assistant messages get merged, then the merged message
1058        // is removed as trailing, leaving only the user message
1059        let visible: Vec<String> = fixed_messages
1060            .iter()
1061            .filter(|m| m.metadata.agent_visible)
1062            .map(|m| m.as_concat_text())
1063            .collect();
1064
1065        assert_eq!(visible.len(), 1);
1066        assert_eq!(visible[0], "User message");
1067    }
1068
1069    #[test]
1070    fn test_shadow_map_all_visible_messages_removed() {
1071        // Edge case: all visible messages are removed, only non-visible remain
1072        let mut msg1_assistant = Message::assistant().with_text("Only assistant");
1073        msg1_assistant.metadata.agent_visible = true;
1074
1075        let mut msg2_non_vis = Message::user().with_text("Non-visible note 1");
1076        msg2_non_vis.metadata.agent_visible = false;
1077
1078        let mut msg3_non_vis = Message::user().with_text("Non-visible note 2");
1079        msg3_non_vis.metadata.agent_visible = false;
1080
1081        let messages = vec![msg1_assistant, msg2_non_vis, msg3_non_vis];
1082
1083        let (fixed, issues) = fix_conversation(Conversation::new_unvalidated(messages));
1084
1085        // Should have removed the assistant and added placeholder
1086        assert!(issues
1087            .iter()
1088            .any(|i| i.contains("Removed leading assistant")));
1089        assert!(issues.iter().any(|i| i.contains("Added placeholder")));
1090
1091        let fixed_messages = fixed.messages();
1092
1093        // Non-visible messages should still be present
1094        let non_visible: Vec<String> = fixed_messages
1095            .iter()
1096            .filter(|m| !m.metadata.agent_visible)
1097            .map(|m| m.as_concat_text())
1098            .collect();
1099
1100        assert_eq!(non_visible.len(), 2);
1101        assert_eq!(non_visible[0], "Non-visible note 1");
1102        assert_eq!(non_visible[1], "Non-visible note 2");
1103
1104        // Should have placeholder user message
1105        let visible: Vec<String> = fixed_messages
1106            .iter()
1107            .filter(|m| m.metadata.agent_visible)
1108            .map(|m| m.as_concat_text())
1109            .collect();
1110
1111        assert_eq!(visible.len(), 1);
1112        assert_eq!(visible[0], "Hello");
1113    }
1114
1115    #[test]
1116    fn test_shadow_map_preserves_interleaving_pattern() {
1117        // Test that complex interleaving patterns are preserved
1118        let mut msg1_user = Message::user().with_text("User 1");
1119        msg1_user.metadata.agent_visible = true;
1120
1121        let mut msg2_non_vis = Message::user().with_text("Non-vis A");
1122        msg2_non_vis.metadata.agent_visible = false;
1123
1124        let mut msg3_assistant = Message::assistant().with_text("Assistant 1");
1125        msg3_assistant.metadata.agent_visible = true;
1126
1127        let mut msg4_non_vis = Message::user().with_text("Non-vis B");
1128        msg4_non_vis.metadata.agent_visible = false;
1129
1130        let mut msg5_user = Message::user().with_text("User 2");
1131        msg5_user.metadata.agent_visible = true;
1132
1133        let mut msg6_non_vis = Message::user().with_text("Non-vis C");
1134        msg6_non_vis.metadata.agent_visible = false;
1135
1136        let messages = vec![
1137            msg1_user,
1138            msg2_non_vis,
1139            msg3_assistant,
1140            msg4_non_vis,
1141            msg5_user,
1142            msg6_non_vis,
1143        ];
1144
1145        let (fixed, issues) = fix_conversation(Conversation::new_unvalidated(messages));
1146
1147        // Should have no issues for this valid conversation
1148        assert!(issues.is_empty());
1149
1150        let fixed_messages = fixed.messages();
1151
1152        // Verify the interleaving pattern is preserved
1153        assert_eq!(fixed_messages.len(), 6);
1154
1155        assert_eq!(fixed_messages[0].as_concat_text(), "User 1");
1156        assert!(fixed_messages[0].metadata.agent_visible);
1157
1158        assert_eq!(fixed_messages[1].as_concat_text(), "Non-vis A");
1159        assert!(!fixed_messages[1].metadata.agent_visible);
1160
1161        assert_eq!(fixed_messages[2].as_concat_text(), "Assistant 1");
1162        assert!(fixed_messages[2].metadata.agent_visible);
1163
1164        assert_eq!(fixed_messages[3].as_concat_text(), "Non-vis B");
1165        assert!(!fixed_messages[3].metadata.agent_visible);
1166
1167        assert_eq!(fixed_messages[4].as_concat_text(), "User 2");
1168        assert!(fixed_messages[4].metadata.agent_visible);
1169
1170        assert_eq!(fixed_messages[5].as_concat_text(), "Non-vis C");
1171        assert!(!fixed_messages[5].metadata.agent_visible);
1172    }
1173}