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
162pub fn fix_conversation(conversation: Conversation) -> (Conversation, Vec<String>) {
165 let all_messages = conversation.messages();
166
167 enum MessageSlot {
169 Visible(usize), NonVisible(Message), }
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 let (fixed_visible, issues) = fix_messages(agent_visible_messages);
189
190 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 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 ), 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 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 } 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 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 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 assert!(!issues.is_empty());
898 assert!(issues.iter().any(|i| i.contains("Merged consecutive")));
899
900 let fixed_messages = fixed.messages();
901
902 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 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 assert!(!visible_texts.is_empty());
924
925 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 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 assert!(issues.iter().any(|i| i.contains("Merged consecutive")));
978
979 let fixed_messages = fixed.messages();
980
981 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 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 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 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 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 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 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 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 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 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 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 assert!(issues.is_empty());
1149
1150 let fixed_messages = fixed.messages();
1151
1152 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}