1use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12use crate::typed_id::{ImageId, MessageId, ModelId};
13
14#[cfg(feature = "openapi")]
15use utoipa::ToSchema;
16
17use everruns_provider::execution_phase::{ExecutionPhase, PhaseSource};
18use everruns_provider::reasoning::ReasoningContentPart;
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[cfg_attr(feature = "openapi", derive(ToSchema))]
22#[cfg_attr(feature = "openapi", schema(as = RuntimeMessageRole))]
26#[serde(rename_all = "snake_case")]
27pub enum MessageRole {
28 System,
30 User,
32 Agent,
34 ToolResult,
36}
37
38impl std::fmt::Display for MessageRole {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 MessageRole::System => write!(f, "system"),
42 MessageRole::User => write!(f, "user"),
43 MessageRole::Agent => write!(f, "agent"),
44 MessageRole::ToolResult => write!(f, "tool_result"),
45 }
46 }
47}
48
49impl From<&str> for MessageRole {
50 fn from(s: &str) -> Self {
51 match s.to_lowercase().as_str() {
52 "system" => MessageRole::System,
53 "user" => MessageRole::User,
54 "agent" | "assistant" => MessageRole::Agent,
56 "tool_result" => MessageRole::ToolResult,
57 _ => MessageRole::User,
58 }
59 }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73#[cfg_attr(feature = "openapi", derive(ToSchema))]
74pub struct ExternalActor {
75 pub actor_id: String,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub actor_name: Option<String>,
80 pub source: String,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub metadata: Option<std::collections::HashMap<String, String>>,
85}
86
87impl ExternalActor {
88 pub fn display_label(&self) -> &str {
90 self.actor_name.as_deref().unwrap_or(&self.actor_id)
91 }
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100#[cfg_attr(feature = "openapi", derive(ToSchema))]
101pub struct ReasoningConfig {
102 #[serde(skip_serializing_if = "Option::is_none")]
108 pub effort: Option<everruns_provider::model::ReasoningEffort>,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
113#[cfg_attr(feature = "openapi", derive(ToSchema))]
114pub struct Controls {
115 #[serde(skip_serializing_if = "Option::is_none")]
118 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "model_01933b5a00007000800000000000001"))]
119 pub model_id: Option<ModelId>,
120
121 #[serde(skip_serializing_if = "Option::is_none")]
124 pub locale: Option<String>,
125
126 #[serde(skip_serializing_if = "Option::is_none")]
128 pub reasoning: Option<ReasoningConfig>,
129
130 #[serde(skip_serializing_if = "Option::is_none")]
134 pub speed: Option<String>,
135
136 #[serde(skip_serializing_if = "Option::is_none")]
140 pub verbosity: Option<String>,
141
142 #[serde(skip_serializing_if = "Option::is_none")]
147 pub error_disclosure: Option<String>,
148
149 #[serde(default, skip_serializing_if = "Option::is_none")]
155 #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
156 pub hints: Option<std::collections::HashMap<String, serde_json::Value>>,
157}
158
159impl Controls {
160 pub fn resolve_hints(
163 session_hints: Option<&std::collections::HashMap<String, serde_json::Value>>,
164 message_hints: Option<&std::collections::HashMap<String, serde_json::Value>>,
165 ) -> std::collections::HashMap<String, serde_json::Value> {
166 match (session_hints, message_hints) {
167 (None, None) => std::collections::HashMap::new(),
168 (Some(s), None) => s.clone(),
169 (None, Some(m)) => m.clone(),
170 (Some(s), Some(m)) => {
171 let mut merged = s.clone();
172 merged.extend(m.iter().map(|(k, v)| (k.clone(), v.clone())));
173 merged
174 }
175 }
176 }
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
181#[cfg_attr(feature = "openapi", derive(ToSchema))]
182#[cfg_attr(feature = "openapi", schema(as = RuntimeMessage))]
187pub struct Message {
188 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "message_01933b5a00007000800000000000001"))]
190 pub id: MessageId,
191
192 pub role: MessageRole,
194
195 pub content: Vec<ContentPart>,
197
198 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub phase: Option<ExecutionPhase>,
206
207 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub phase_source: Option<PhaseSource>,
213
214 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub controls: Option<Controls>,
217
218 #[serde(default, skip_serializing_if = "Option::is_none")]
220 #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
221 pub metadata: Option<std::collections::HashMap<String, serde_json::Value>>,
222
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub external_actor: Option<ExternalActor>,
226
227 pub created_at: DateTime<Utc>,
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
237#[cfg_attr(feature = "openapi", derive(ToSchema))]
238#[serde(rename_all = "snake_case")]
239pub enum ContentType {
240 Text,
241 Image,
242 ImageFile,
243 ToolCall,
244 ToolResult,
245 Reasoning,
246}
247
248impl std::fmt::Display for ContentType {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 match self {
251 ContentType::Text => write!(f, "text"),
252 ContentType::Image => write!(f, "image"),
253 ContentType::ImageFile => write!(f, "image_file"),
254 ContentType::ToolCall => write!(f, "tool_call"),
255 ContentType::ToolResult => write!(f, "tool_result"),
256 ContentType::Reasoning => write!(f, "reasoning"),
257 }
258 }
259}
260
261impl From<&str> for ContentType {
262 fn from(s: &str) -> Self {
263 match s {
264 "image" => ContentType::Image,
265 "image_file" => ContentType::ImageFile,
266 "tool_call" => ContentType::ToolCall,
267 "tool_result" => ContentType::ToolResult,
268 "reasoning" => ContentType::Reasoning,
269 _ => ContentType::Text,
270 }
271 }
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
280#[cfg_attr(feature = "openapi", derive(ToSchema))]
281pub struct TextContentPart {
282 pub text: String,
283 #[serde(default, skip_serializing_if = "Vec::is_empty")]
289 pub annotations: Vec<TextAnnotation>,
290}
291
292impl TextContentPart {
293 pub fn new(text: impl Into<String>) -> Self {
294 Self {
295 text: text.into(),
296 annotations: Vec::new(),
297 }
298 }
299
300 pub fn with_annotations(mut self, annotations: Vec<TextAnnotation>) -> Self {
302 self.annotations = annotations;
303 self
304 }
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
314#[cfg_attr(feature = "openapi", derive(ToSchema))]
315pub struct TextAnnotation {
316 #[cfg_attr(feature = "openapi", schema(example = 0))]
318 pub start: usize,
319 #[cfg_attr(feature = "openapi", schema(example = 19))]
321 pub end: usize,
322 #[cfg_attr(feature = "openapi", schema(example = "citation_retrieval"))]
325 pub origin: String,
326 pub source: AnnotationSource,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
331 #[cfg_attr(feature = "openapi", schema(example = "kchk_01j9y3q8w2"))]
332 pub external_id: Option<String>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub verified: Option<VerificationVerdict>,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
341#[cfg_attr(feature = "openapi", derive(ToSchema))]
342pub struct AnnotationSource {
343 #[cfg_attr(
346 feature = "openapi",
347 schema(example = "github://owner/repo@main/docs/x.md")
348 )]
349 pub uri: String,
350 #[serde(default, skip_serializing_if = "Option::is_none")]
352 #[cfg_attr(feature = "openapi", schema(example = "Architecture Overview"))]
353 pub title: Option<String>,
354 #[serde(default, skip_serializing_if = "Option::is_none")]
357 #[cfg_attr(
358 feature = "openapi",
359 schema(example = "The control plane owns durable state.")
360 )]
361 pub snippet: Option<String>,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub location: Option<serde_json::Value>,
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
370#[cfg_attr(feature = "openapi", derive(ToSchema))]
371pub struct VerificationVerdict {
372 pub status: VerificationStatus,
374 #[serde(default, skip_serializing_if = "Option::is_none")]
376 #[cfg_attr(feature = "openapi", schema(example = 0.92))]
377 pub score: Option<f32>,
378}
379
380#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
382#[cfg_attr(feature = "openapi", derive(ToSchema))]
383#[cfg_attr(feature = "openapi", schema(example = "entailed"))]
384#[serde(rename_all = "snake_case")]
385pub enum VerificationStatus {
386 Entailed,
388 Unsupported,
390 Uncertain,
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
396#[cfg_attr(feature = "openapi", derive(ToSchema))]
397pub struct ImageContentPart {
398 #[serde(skip_serializing_if = "Option::is_none")]
399 pub url: Option<String>,
400 #[serde(skip_serializing_if = "Option::is_none")]
401 pub base64: Option<String>,
402 #[serde(skip_serializing_if = "Option::is_none")]
403 pub media_type: Option<String>,
404}
405
406impl ImageContentPart {
407 pub fn from_url(url: impl Into<String>) -> Self {
408 Self {
409 url: Some(url.into()),
410 base64: None,
411 media_type: None,
412 }
413 }
414
415 pub fn from_base64(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
416 Self {
417 url: None,
418 base64: Some(base64.into()),
419 media_type: Some(media_type.into()),
420 }
421 }
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
430#[cfg_attr(feature = "openapi", derive(ToSchema))]
431pub struct ImageFileContentPart {
432 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "img_01933b5a00007000800000000000001"))]
434 pub image_id: ImageId,
435 #[serde(skip_serializing_if = "Option::is_none")]
437 pub filename: Option<String>,
438}
439
440impl ImageFileContentPart {
441 pub fn new(image_id: ImageId) -> Self {
442 Self {
443 image_id,
444 filename: None,
445 }
446 }
447
448 pub fn with_filename(image_id: ImageId, filename: impl Into<String>) -> Self {
449 Self {
450 image_id,
451 filename: Some(filename.into()),
452 }
453 }
454}
455
456#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
458#[cfg_attr(feature = "openapi", derive(ToSchema))]
459pub struct ToolCallContentPart {
460 pub id: String,
461 pub name: String,
462 pub arguments: serde_json::Value,
463}
464
465impl ToolCallContentPart {
466 pub fn new(
467 id: impl Into<String>,
468 name: impl Into<String>,
469 arguments: serde_json::Value,
470 ) -> Self {
471 Self {
472 id: id.into(),
473 name: name.into(),
474 arguments,
475 }
476 }
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
481#[cfg_attr(feature = "openapi", derive(ToSchema))]
482pub struct ToolResultContentPart {
483 pub tool_call_id: String,
485 #[serde(skip_serializing_if = "Option::is_none")]
486 pub result: Option<serde_json::Value>,
487 #[serde(skip_serializing_if = "Option::is_none")]
488 pub error: Option<String>,
489}
490
491impl ToolResultContentPart {
492 pub fn new(
493 tool_call_id: impl Into<String>,
494 result: Option<serde_json::Value>,
495 error: Option<String>,
496 ) -> Self {
497 Self {
498 tool_call_id: tool_call_id.into(),
499 result,
500 error,
501 }
502 }
503
504 pub fn success(tool_call_id: impl Into<String>, result: serde_json::Value) -> Self {
505 Self {
506 tool_call_id: tool_call_id.into(),
507 result: Some(result),
508 error: None,
509 }
510 }
511
512 pub fn error(tool_call_id: impl Into<String>, error: impl Into<String>) -> Self {
513 Self {
514 tool_call_id: tool_call_id.into(),
515 result: None,
516 error: Some(error.into()),
517 }
518 }
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
530#[cfg_attr(feature = "openapi", derive(ToSchema))]
531#[serde(tag = "type", rename_all = "snake_case")]
532pub enum ContentPart {
533 Text(TextContentPart),
535 Image(ImageContentPart),
537 ImageFile(ImageFileContentPart),
539 ToolCall(ToolCallContentPart),
541 ToolResult(ToolResultContentPart),
543 Reasoning(ReasoningContentPart),
546}
547
548impl ContentPart {
549 pub fn text(text: impl Into<String>) -> Self {
551 ContentPart::Text(TextContentPart::new(text))
552 }
553
554 pub fn tool_result_text(value: &serde_json::Value) -> Self {
557 match value {
558 serde_json::Value::String(text) => Self::text(text.clone()),
559 other => Self::text(other.to_string()),
560 }
561 }
562
563 pub fn image_url(url: impl Into<String>) -> Self {
565 ContentPart::Image(ImageContentPart::from_url(url))
566 }
567
568 pub fn image_file(image_id: ImageId) -> Self {
570 ContentPart::ImageFile(ImageFileContentPart::new(image_id))
571 }
572
573 pub fn tool_call(
575 id: impl Into<String>,
576 name: impl Into<String>,
577 arguments: serde_json::Value,
578 ) -> Self {
579 ContentPart::ToolCall(ToolCallContentPart::new(id, name, arguments))
580 }
581
582 pub fn tool_result(
584 tool_call_id: impl Into<String>,
585 result: Option<serde_json::Value>,
586 error: Option<String>,
587 ) -> Self {
588 ContentPart::ToolResult(ToolResultContentPart::new(tool_call_id, result, error))
589 }
590
591 pub fn reasoning(part: ReasoningContentPart) -> Self {
593 ContentPart::Reasoning(part)
594 }
595
596 pub fn as_reasoning(&self) -> Option<&ReasoningContentPart> {
598 match self {
599 ContentPart::Reasoning(r) => Some(r),
600 _ => None,
601 }
602 }
603
604 pub fn is_reasoning(&self) -> bool {
606 matches!(self, ContentPart::Reasoning(_))
607 }
608
609 pub fn as_text(&self) -> Option<&str> {
611 match self {
612 ContentPart::Text(t) => Some(&t.text),
613 _ => None,
614 }
615 }
616
617 pub fn is_image_file(&self) -> bool {
619 matches!(self, ContentPart::ImageFile(_))
620 }
621
622 pub fn content_type(&self) -> ContentType {
624 match self {
625 ContentPart::Text(_) => ContentType::Text,
626 ContentPart::Image(_) => ContentType::Image,
627 ContentPart::ImageFile(_) => ContentType::ImageFile,
628 ContentPart::ToolCall(_) => ContentType::ToolCall,
629 ContentPart::ToolResult(_) => ContentType::ToolResult,
630 ContentPart::Reasoning(_) => ContentType::Reasoning,
631 }
632 }
633
634 pub fn to_openai_format(&self) -> Option<serde_json::Value> {
639 match self {
640 ContentPart::Text(t) => Some(serde_json::json!({
641 "type": "text",
642 "text": t.text
643 })),
644 ContentPart::Image(img) => {
645 if let Some(url) = &img.url {
646 Some(serde_json::json!({
647 "type": "image_url",
648 "image_url": { "url": url }
649 }))
650 } else if let Some(b64) = &img.base64 {
651 let media_type = img.media_type.as_deref().unwrap_or("image/png");
652 Some(serde_json::json!({
653 "type": "image_url",
654 "image_url": { "url": format!("data:{};base64,{}", media_type, b64) }
655 }))
656 } else {
657 None
658 }
659 }
660 _ => None,
662 }
663 }
664}
665
666#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
671#[cfg_attr(feature = "openapi", derive(ToSchema))]
672#[serde(tag = "type", rename_all = "snake_case")]
673pub enum InputContentPart {
674 Text(TextContentPart),
676 Image(ImageContentPart),
678 ImageFile(ImageFileContentPart),
680}
681
682impl From<InputContentPart> for ContentPart {
683 fn from(input: InputContentPart) -> Self {
684 match input {
685 InputContentPart::Text(t) => ContentPart::Text(t),
686 InputContentPart::Image(i) => ContentPart::Image(i),
687 InputContentPart::ImageFile(f) => ContentPart::ImageFile(f),
688 }
689 }
690}
691
692impl InputContentPart {
693 pub fn text(text: impl Into<String>) -> Self {
695 InputContentPart::Text(TextContentPart::new(text))
696 }
697
698 pub fn image_url(url: impl Into<String>) -> Self {
700 InputContentPart::Image(ImageContentPart::from_url(url))
701 }
702
703 pub fn image_file(image_id: ImageId) -> Self {
705 InputContentPart::ImageFile(ImageFileContentPart::new(image_id))
706 }
707
708 pub fn as_text(&self) -> Option<&str> {
710 match self {
711 InputContentPart::Text(t) => Some(&t.text),
712 _ => None,
713 }
714 }
715
716 pub fn content_type(&self) -> ContentType {
718 match self {
719 InputContentPart::Text(_) => ContentType::Text,
720 InputContentPart::Image(_) => ContentType::Image,
721 InputContentPart::ImageFile(_) => ContentType::ImageFile,
722 }
723 }
724}
725
726impl Message {
727 pub fn reasoning_parts(&self) -> impl Iterator<Item = &ReasoningContentPart> {
729 self.content.iter().filter_map(ContentPart::as_reasoning)
730 }
731
732 pub fn has_reasoning(&self) -> bool {
734 self.content.iter().any(ContentPart::is_reasoning)
735 }
736
737 pub fn reasoning_display_text(&self) -> Option<String> {
742 let joined = self
743 .reasoning_parts()
744 .filter_map(ReasoningContentPart::display_text)
745 .collect::<Vec<_>>()
746 .join("\n\n");
747 (!joined.is_empty()).then_some(joined)
748 }
749
750 pub fn into_public(mut self) -> Self {
753 for part in &mut self.content {
754 if let ContentPart::Reasoning(r) = part {
755 *r = r.to_public();
756 }
757 }
758 self
759 }
760
761 pub fn with_id(mut self, id: MessageId) -> Self {
766 self.id = id;
767 self
768 }
769
770 pub fn user(content: impl Into<String>) -> Self {
772 Self {
773 id: MessageId::new(),
774 role: MessageRole::User,
775 content: vec![ContentPart::text(content)],
776 phase: None,
777 phase_source: None,
778 controls: None,
779 metadata: None,
780 external_actor: None,
781 created_at: Utc::now(),
782 }
783 }
784
785 pub fn assistant(content: impl Into<String>) -> Self {
787 Self {
788 id: MessageId::new(),
789 role: MessageRole::Agent,
790 content: vec![ContentPart::text(content)],
791 phase: None,
792 phase_source: None,
793 controls: None,
794 metadata: None,
795 external_actor: None,
796 created_at: Utc::now(),
797 }
798 }
799
800 pub fn assistant_with_tools(
806 content: impl Into<String>,
807 tool_calls: Vec<crate::tool_types::ToolCall>,
808 ) -> Self {
809 let text_content = content.into();
810 let mut parts = Vec::new();
811 if !text_content.is_empty() {
813 parts.push(ContentPart::text(text_content));
814 }
815 for tc in tool_calls {
816 parts.push(ContentPart::ToolCall(ToolCallContentPart {
817 id: tc.id,
818 name: tc.name,
819 arguments: tc.arguments,
820 }));
821 }
822 Self {
823 id: MessageId::new(),
824 role: MessageRole::Agent,
825 content: parts,
826 phase: None,
827 phase_source: None,
828 controls: None,
829 metadata: None,
830 external_actor: None,
831 created_at: Utc::now(),
832 }
833 }
834
835 pub fn system(content: impl Into<String>) -> Self {
837 Self {
838 id: MessageId::new(),
839 role: MessageRole::System,
840 content: vec![ContentPart::text(content)],
841 phase: None,
842 phase_source: None,
843 controls: None,
844 metadata: None,
845 external_actor: None,
846 created_at: Utc::now(),
847 }
848 }
849
850 pub fn tool_result(
852 tool_call_id: impl Into<String>,
853 result: Option<serde_json::Value>,
854 error: Option<String>,
855 ) -> Self {
856 let tool_call_id = tool_call_id.into();
857 Self {
858 id: MessageId::new(),
859 role: MessageRole::ToolResult,
860 content: vec![ContentPart::ToolResult(ToolResultContentPart::new(
861 tool_call_id,
862 result,
863 error,
864 ))],
865 phase: None,
866 phase_source: None,
867 controls: None,
868 metadata: None,
869 external_actor: None,
870 created_at: Utc::now(),
871 }
872 }
873
874 pub fn tool_result_with_images(
880 tool_call_id: impl Into<String>,
881 result: Option<serde_json::Value>,
882 images: Vec<everruns_provider::tool_types::ToolResultImage>,
883 ) -> Self {
884 let tool_call_id = tool_call_id.into();
885 let mut content = vec![ContentPart::ToolResult(ToolResultContentPart::new(
886 tool_call_id,
887 result,
888 None,
889 ))];
890 for img in images {
891 content.push(ContentPart::Image(ImageContentPart::from_base64(
892 img.base64,
893 img.media_type,
894 )));
895 }
896 Self {
897 id: MessageId::new(),
898 role: MessageRole::ToolResult,
899 content,
900 phase: None,
901 phase_source: None,
902 controls: None,
903 metadata: None,
904 external_actor: None,
905 created_at: Utc::now(),
906 }
907 }
908
909 pub fn with_phase(mut self, phase: ExecutionPhase) -> Self {
911 self.phase = Some(phase);
912 self
913 }
914
915 pub fn with_phase_from(mut self, phase: ExecutionPhase, source: PhaseSource) -> Self {
917 self.phase = Some(phase);
918 self.phase_source = Some(source);
919 self
920 }
921
922 pub fn tool_call_id(&self) -> Option<&str> {
926 self.content.iter().find_map(|p| match p {
927 ContentPart::ToolResult(tr) => Some(tr.tool_call_id.as_str()),
928 _ => None,
929 })
930 }
931
932 pub fn text(&self) -> Option<&str> {
934 self.content.iter().find_map(|p| p.as_text())
935 }
936
937 pub fn tool_calls(&self) -> Vec<&ToolCallContentPart> {
939 self.content
940 .iter()
941 .filter_map(|p| match p {
942 ContentPart::ToolCall(tc) => Some(tc),
943 _ => None,
944 })
945 .collect()
946 }
947
948 pub fn has_tool_calls(&self) -> bool {
950 self.content
951 .iter()
952 .any(|p| matches!(p, ContentPart::ToolCall(_)))
953 }
954
955 pub fn tool_result_content(&self) -> Option<&ToolResultContentPart> {
957 self.content.iter().find_map(|p| match p {
958 ContentPart::ToolResult(tr) => Some(tr),
959 _ => None,
960 })
961 }
962
963 pub fn content_to_llm_string(&self) -> String {
965 self.content
966 .iter()
967 .map(|part| match part {
968 ContentPart::Text(t) => t.text.clone(),
969 ContentPart::Reasoning(_) => String::new(),
973 ContentPart::Image(_) => "[Image]".to_string(),
974 ContentPart::ImageFile(_) => "[Image File]".to_string(),
975 ContentPart::ToolCall(tc) => {
976 format!(
977 "Tool call: {} with arguments: {}",
978 tc.name,
979 serde_json::to_string(&tc.arguments).unwrap_or_default()
980 )
981 }
982 ContentPart::ToolResult(tr) => {
983 if let Some(err) = &tr.error {
984 format!("Tool error: {}", err)
985 } else if let Some(res) = &tr.result {
986 serde_json::to_string(res).unwrap_or_else(|_| "{}".to_string())
987 } else {
988 "{}".to_string()
989 }
990 }
991 })
992 .filter(|rendered| !rendered.is_empty())
993 .collect::<Vec<_>>()
994 .join("\n")
995 }
996
997 pub fn to_openai_format(&self) -> serde_json::Value {
1006 let role = match self.role {
1007 MessageRole::System => "system",
1008 MessageRole::User => "user",
1009 MessageRole::Agent => "assistant",
1010 MessageRole::ToolResult => "tool",
1011 };
1012
1013 if self.role == MessageRole::ToolResult {
1015 let tool_call_id = self.tool_call_id().unwrap_or("");
1016 let content = self
1017 .content
1018 .iter()
1019 .find_map(|p| match p {
1020 ContentPart::ToolResult(tr) => {
1021 if let Some(error) = &tr.error {
1022 Some(format!("Error: {}", error))
1023 } else if let Some(result) = &tr.result {
1024 Some(serde_json::to_string(result).unwrap_or_else(|_| "{}".to_string()))
1025 } else {
1026 Some("{}".to_string())
1027 }
1028 }
1029 _ => None,
1030 })
1031 .unwrap_or_else(|| "{}".to_string());
1032
1033 return serde_json::json!({
1034 "role": role,
1035 "content": content,
1036 "tool_call_id": tool_call_id
1037 });
1038 }
1039
1040 if self.role == MessageRole::Agent {
1042 let tool_calls: Vec<serde_json::Value> = self
1043 .content
1044 .iter()
1045 .filter_map(|p| match p {
1046 ContentPart::ToolCall(tc) => Some(serde_json::json!({
1047 "id": tc.id,
1048 "type": "function",
1049 "function": {
1050 "name": tc.name,
1051 "arguments": serde_json::to_string(&tc.arguments).unwrap_or_else(|_| "{}".to_string())
1052 }
1053 })),
1054 _ => None,
1055 })
1056 .collect();
1057
1058 let text_content: String = self
1059 .content
1060 .iter()
1061 .filter_map(|p| match p {
1062 ContentPart::Text(t) => Some(t.text.clone()),
1063 _ => None,
1064 })
1065 .collect::<Vec<_>>()
1066 .join("\n");
1067
1068 if tool_calls.is_empty() {
1069 return serde_json::json!({
1070 "role": role,
1071 "content": text_content
1072 });
1073 } else {
1074 let mut result = serde_json::json!({
1075 "role": role,
1076 "tool_calls": tool_calls
1077 });
1078 if !text_content.is_empty() {
1079 result["content"] = serde_json::json!(text_content);
1080 }
1081 return result;
1082 }
1083 }
1084
1085 let content = self.content_to_openai_format();
1087 serde_json::json!({
1088 "role": role,
1089 "content": content
1090 })
1091 }
1092
1093 fn content_to_openai_format(&self) -> serde_json::Value {
1095 if self.content.len() == 1
1097 && let ContentPart::Text(t) = &self.content[0]
1098 {
1099 return serde_json::json!(t.text);
1100 }
1101
1102 let parts: Vec<serde_json::Value> = self
1104 .content
1105 .iter()
1106 .filter_map(|part| part.to_openai_format())
1107 .collect();
1108
1109 if parts.is_empty() {
1110 return serde_json::json!("");
1111 }
1112
1113 if parts.len() == 1
1115 && let Some(text) = parts[0].get("text")
1116 {
1117 return text.clone();
1118 }
1119
1120 serde_json::json!(parts)
1121 }
1122}
1123
1124pub fn patch_dangling_tool_calls(messages: &[Message]) -> Vec<Message> {
1134 let mut result = Vec::new();
1135
1136 for (i, msg) in messages.iter().enumerate() {
1137 result.push(msg.clone());
1138
1139 if msg.role == MessageRole::Agent && msg.has_tool_calls() {
1141 for tc in msg.tool_calls() {
1142 let has_result = messages[(i + 1)..]
1144 .iter()
1145 .any(|m| m.role == MessageRole::ToolResult && m.tool_call_id() == Some(&tc.id));
1146
1147 if !has_result {
1148 result.push(Message::tool_result(
1149 &tc.id,
1150 None,
1151 Some(
1152 "cancelled - another message came in before it could be completed"
1153 .to_string(),
1154 ),
1155 ));
1156 }
1157 }
1158 }
1159 }
1160
1161 result
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166 use super::*;
1167 use crate::tool_types::ToolCall;
1168 use serde_json::json;
1169
1170 fn calls() -> Vec<ToolCall> {
1171 vec![
1172 ToolCall {
1173 id: "call_search".into(),
1174 name: "search".into(),
1175 arguments: json!({"q": "rust"}),
1176 },
1177 ToolCall {
1178 id: "call_fetch".into(),
1179 name: "fetch".into(),
1180 arguments: json!({"url": "https://example.com"}),
1181 },
1182 ]
1183 }
1184
1185 fn assert_messages(actual: &[Message], expected: &[Message]) {
1186 assert_eq!(
1187 serde_json::to_value(actual).unwrap(),
1188 serde_json::to_value(expected).unwrap()
1189 );
1190 }
1191
1192 #[test]
1193 fn settled_transcripts_are_preserved_without_synthetic_results() {
1194 for messages in [
1195 vec![],
1196 vec![Message::user("Hello"), Message::assistant("Hi")],
1197 vec![
1198 Message::assistant_with_tools("Searching", vec![calls()[0].clone()]),
1199 Message::tool_result("call_search", Some(json!({"found": 2})), None),
1200 ],
1201 ] {
1202 assert_messages(&patch_dangling_tool_calls(&messages), &messages);
1203 }
1204 }
1205
1206 #[test]
1207 fn dangling_calls_get_only_missing_cancellations_and_patching_is_idempotent() {
1208 let messages = vec![
1209 Message::user("Search then fetch"),
1210 Message::assistant_with_tools("Working", calls()),
1211 Message::user("Never mind"),
1212 Message::tool_result("call_search", Some(json!({"found": 2})), None),
1213 ];
1214 let patched = patch_dangling_tool_calls(&messages);
1215 assert_eq!(patched.len(), 5);
1216 assert_messages(&patched[..2], &messages[..2]);
1217 assert_messages(&patched[3..], &messages[2..]);
1218 assert_eq!(patched[2].role, MessageRole::ToolResult);
1219 assert_eq!(
1220 serde_json::to_value(&patched[2].content).unwrap(),
1221 json!([{
1222 "type": "tool_result", "tool_call_id": "call_fetch",
1223 "error": "cancelled - another message came in before it could be completed"
1224 }])
1225 );
1226 assert_messages(&patch_dangling_tool_calls(&patched), &patched);
1227 }
1228
1229 #[test]
1230 fn plain_message_constructors_preserve_role_and_text() {
1231 for (message, role, text) in [
1232 (Message::user("question"), MessageRole::User, "question"),
1233 (Message::assistant("answer"), MessageRole::Agent, "answer"),
1234 (
1235 Message::system("instruction"),
1236 MessageRole::System,
1237 "instruction",
1238 ),
1239 ] {
1240 assert_eq!(message.role, role);
1241 assert_eq!(message.text(), Some(text));
1242 assert_eq!(message.content, vec![ContentPart::text(text)]);
1243 assert!(!message.has_tool_calls());
1244 }
1245 }
1246
1247 #[test]
1248 fn tool_result_constructor_preserves_result_and_error_fields() {
1249 for (result, error) in [
1250 (Some(json!({"count": 2})), None),
1251 (None, Some("timeout".to_owned())),
1252 (Some(json!(false)), Some("partial".to_owned())),
1253 ] {
1254 let message = Message::tool_result("call_result", result.clone(), error.clone());
1255 assert_eq!(message.role, MessageRole::ToolResult);
1256 assert_eq!(message.tool_call_id(), Some("call_result"));
1257 assert_eq!(
1258 message.content,
1259 vec![ContentPart::tool_result("call_result", result, error)]
1260 );
1261 }
1262 }
1263
1264 #[test]
1265 fn assistant_tool_messages_preserve_calls_and_distinguish_empty_from_whitespace_text() {
1266 for text in ["", " ", "Working"] {
1267 let message = Message::assistant_with_tools(text, calls());
1268 let tool_parts: Vec<_> = calls()
1269 .into_iter()
1270 .map(|c| ContentPart::tool_call(c.id, c.name, c.arguments))
1271 .collect();
1272 let mut expected = vec![];
1273 if !text.is_empty() {
1274 expected.push(ContentPart::text(text));
1275 }
1276 expected.extend(tool_parts);
1277 assert_eq!(message.role, MessageRole::Agent);
1278 assert_eq!(message.text(), (!text.is_empty()).then_some(text));
1279 assert_eq!(message.content, expected);
1280 assert!(message.has_tool_calls());
1281 assert_eq!(
1282 serde_json::to_value(message.tool_calls()).unwrap(),
1283 serde_json::to_value(calls()).unwrap()
1284 );
1285 }
1286 }
1287
1288 #[test]
1289 fn openai_plain_messages_map_internal_roles_and_preserve_text() {
1290 for (message, expected) in [
1291 (
1292 Message::user("question"),
1293 json!({"role": "user", "content": "question"}),
1294 ),
1295 (
1296 Message::system("instruction"),
1297 json!({"role": "system", "content": "instruction"}),
1298 ),
1299 (
1300 Message::assistant("answer"),
1301 json!({"role": "assistant", "content": "answer"}),
1302 ),
1303 ] {
1304 assert_eq!(message.to_openai_format(), expected);
1305 }
1306 }
1307
1308 #[test]
1309 fn openai_tool_calls_preserve_ids_arguments_and_optional_text() {
1310 for text in ["", "Working"] {
1311 let message = Message::assistant_with_tools(text, calls());
1312 let mut expected = json!({"role": "assistant", "tool_calls": [
1313 {"id": "call_search", "type": "function", "function": {"name": "search", "arguments": "{\"q\":\"rust\"}"}},
1314 {"id": "call_fetch", "type": "function", "function": {"name": "fetch", "arguments": "{\"url\":\"https://example.com\"}"}}
1315 ]});
1316 if !text.is_empty() {
1317 expected["content"] = text.into();
1318 }
1319 assert_eq!(message.to_openai_format(), expected);
1320 }
1321 }
1322
1323 #[test]
1324 fn openai_tool_results_prefer_errors_and_preserve_call_identity() {
1325 for (result, error, content) in [
1326 (
1327 Some(json!({"temperature":72})),
1328 None,
1329 "{\"temperature\":72}",
1330 ),
1331 (None, Some("timeout"), "Error: timeout"),
1332 (
1333 Some(json!({"partial":true})),
1334 Some("partial failure"),
1335 "Error: partial failure",
1336 ),
1337 (None, None, "{}"),
1338 ] {
1339 let message = Message::tool_result("call_result", result, error.map(str::to_owned));
1340 assert_eq!(
1341 message.to_openai_format(),
1342 json!({"role":"tool", "tool_call_id":"call_result", "content":content})
1343 );
1344 }
1345 }
1346
1347 #[test]
1348 fn openai_content_parts_preserve_text_and_image_sources() {
1349 for (part, expected) in [
1350 (
1351 ContentPart::text("Hello"),
1352 json!({"type":"text", "text":"Hello"}),
1353 ),
1354 (
1355 ContentPart::image_url("https://example.com/img.png"),
1356 json!({"type":"image_url", "image_url":{"url":"https://example.com/img.png"}}),
1357 ),
1358 (
1359 ContentPart::Image(ImageContentPart::from_base64("YWJj", "image/jpeg")),
1360 json!({"type":"image_url", "image_url":{"url":"data:image/jpeg;base64,YWJj"}}),
1361 ),
1362 (
1363 ContentPart::Image(ImageContentPart {
1364 url: None,
1365 base64: Some("YWJj".into()),
1366 media_type: None,
1367 }),
1368 json!({"type":"image_url", "image_url":{"url":"data:image/png;base64,YWJj"}}),
1369 ),
1370 (
1371 ContentPart::Image(ImageContentPart {
1372 url: Some("https://example.com/preferred".into()),
1373 base64: Some("YWJj".into()),
1374 media_type: Some("image/jpeg".into()),
1375 }),
1376 json!({"type":"image_url", "image_url":{"url":"https://example.com/preferred"}}),
1377 ),
1378 ] {
1379 assert_eq!(part.to_openai_format(), Some(expected));
1380 }
1381 assert!(
1382 ContentPart::Image(ImageContentPart {
1383 url: None,
1384 base64: None,
1385 media_type: None
1386 })
1387 .to_openai_format()
1388 .is_none()
1389 );
1390 }
1391
1392 #[test]
1393 fn openai_content_parts_exclude_tool_file_and_reasoning_artifacts() {
1394 for part in [
1395 ContentPart::tool_call("call_1", "lookup", json!({})),
1396 ContentPart::tool_result("call_1", Some(json!(42)), None),
1397 ContentPart::image_file(ImageId::new()),
1398 ContentPart::reasoning(
1399 ReasoningContentPart::opaque("test").with_signature("private-signature"),
1400 ),
1401 ] {
1402 assert!(part.to_openai_format().is_none());
1403 }
1404 }
1405
1406 #[test]
1407 fn openai_message_content_preserves_multimodal_order_and_filters_unsupported_parts() {
1408 let mut message = Message::user("before");
1409 message
1410 .content
1411 .push(ContentPart::image_url("https://example.com/image"));
1412 message.content.push(ContentPart::text("after"));
1413 assert_eq!(
1414 message.to_openai_format(),
1415 json!({"role":"user", "content":[
1416 {"type":"text", "text":"before"}, {"type":"image_url", "image_url":{"url":"https://example.com/image"}},
1417 {"type":"text", "text":"after"}
1418 ]})
1419 );
1420 message.content = vec![
1421 ContentPart::tool_call("ignored", "tool", json!({})),
1422 ContentPart::text("kept"),
1423 ];
1424 assert_eq!(
1425 message.to_openai_format(),
1426 json!({"role":"user", "content":"kept"})
1427 );
1428 message.content.remove(1);
1429 assert_eq!(
1430 message.to_openai_format(),
1431 json!({"role":"user", "content":""})
1432 );
1433 let mut assistant = Message::assistant("first");
1434 assistant.content.push(ContentPart::text("second"));
1435 assert_eq!(
1436 assistant.to_openai_format(),
1437 json!({"role":"assistant", "content":"first\nsecond"})
1438 );
1439 }
1440
1441 #[test]
1442 fn message_phase_wire_contract_preserves_optional_source() {
1443 for (phase, wire) in [
1444 (None, None),
1445 (Some(ExecutionPhase::Commentary), Some("commentary")),
1446 (Some(ExecutionPhase::FinalAnswer), Some("final_answer")),
1447 ] {
1448 for source in [
1449 None,
1450 Some(PhaseSource::Provider),
1451 Some(PhaseSource::Derived),
1452 ] {
1453 if phase.is_none() && source.is_some() {
1454 continue;
1455 }
1456 let message = match (phase, source) {
1457 (Some(phase), Some(source)) => {
1458 Message::assistant("answer").with_phase_from(phase, source)
1459 }
1460 (Some(phase), None) => Message::assistant("answer").with_phase(phase),
1461 _ => Message::assistant("answer"),
1462 };
1463 let json = serde_json::to_value(&message).unwrap();
1464 assert_eq!(
1465 json.get("phase"),
1466 wire.map(serde_json::Value::from).as_ref()
1467 );
1468 let source_wire = match source {
1469 Some(PhaseSource::Provider) => Some("provider"),
1470 Some(PhaseSource::Derived) => Some("derived"),
1471 None => None,
1472 };
1473 assert_eq!(
1474 json.get("phase_source"),
1475 source_wire.map(serde_json::Value::from).as_ref()
1476 );
1477 let decoded: Message = serde_json::from_value(json.clone()).unwrap();
1478 assert_eq!(decoded.phase, phase);
1479 assert_eq!(decoded.phase_source, source);
1480 assert_eq!(decoded.text(), Some("answer"));
1481 assert_eq!(serde_json::to_value(decoded).unwrap(), json);
1482 }
1483 }
1484 }
1485
1486 #[test]
1487 fn hints_merge_shallowly_with_message_precedence() {
1488 let session = std::collections::HashMap::from([
1489 ("shared".into(), json!({"old":1})),
1490 ("session_only".into(), json!(42)),
1491 ]);
1492 let message = std::collections::HashMap::from([
1493 ("shared".into(), json!({"new":2})),
1494 ("message_only".into(), json!(null)),
1495 ]);
1496 for (left, right, expected) in [
1497 (None, None, json!({})),
1498 (
1499 Some(&session),
1500 None,
1501 json!({"shared":{"old":1},"session_only":42}),
1502 ),
1503 (
1504 None,
1505 Some(&message),
1506 json!({"shared":{"new":2},"message_only":null}),
1507 ),
1508 (
1509 Some(&session),
1510 Some(&message),
1511 json!({"shared":{"new":2},"session_only":42,"message_only":null}),
1512 ),
1513 ] {
1514 assert_eq!(
1515 serde_json::to_value(Controls::resolve_hints(left, right)).unwrap(),
1516 expected
1517 );
1518 }
1519 }
1520
1521 #[test]
1522 fn controls_wire_contract_preserves_all_overrides_and_legacy_defaults() {
1523 let expected = json!({"model_id":"model_00000000000000000000000000000006", "locale":"uk-UA",
1524 "reasoning":{"effort":"high"}, "speed":"priority", "verbosity":"low", "error_disclosure":"generic",
1525 "hints":{"setup_connection":true,"theme":"dark"}});
1526 let controls = Controls {
1527 model_id: Some(ModelId::from_uuid(uuid::Uuid::from_u128(6))),
1528 locale: Some("uk-UA".into()),
1529 reasoning: Some(ReasoningConfig {
1530 effort: Some(everruns_provider::model::ReasoningEffort::High),
1531 }),
1532 speed: Some("priority".into()),
1533 verbosity: Some("low".into()),
1534 error_disclosure: Some("generic".into()),
1535 hints: Some(std::collections::HashMap::from([
1536 ("setup_connection".into(), json!(true)),
1537 ("theme".into(), json!("dark")),
1538 ])),
1539 };
1540 assert_eq!(serde_json::to_value(&controls).unwrap(), expected);
1541 assert_eq!(
1542 serde_json::from_value::<Controls>(expected).unwrap(),
1543 controls
1544 );
1545 let legacy: Controls = serde_json::from_value(json!({})).unwrap();
1546 assert_eq!(serde_json::to_value(legacy).unwrap(), json!({}));
1547 }
1548
1549 #[test]
1550 fn tool_result_text_preserves_strings_without_json_escaping() {
1551 let value = serde_json::json!("{\n \"count\": 1\n}");
1552 assert_eq!(
1553 ContentPart::tool_result_text(&value).as_text(),
1554 Some("{\n \"count\": 1\n}")
1555 );
1556 }
1557
1558 #[test]
1559 fn tool_result_text_serializes_structured_values() {
1560 for (value, expected) in [
1561 (json!({"count":1}), "{\"count\":1}"),
1562 (json!([true, 2]), "[true,2]"),
1563 (json!(null), "null"),
1564 ] {
1565 assert_eq!(
1566 ContentPart::tool_result_text(&value).as_text(),
1567 Some(expected)
1568 );
1569 }
1570 }
1571}