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
1169 #[test]
1170 fn test_patch_dangling_tool_calls_no_tool_calls() {
1171 let messages = vec![Message::user("Hello"), Message::assistant("Hi there!")];
1172 let patched = patch_dangling_tool_calls(&messages);
1173 assert_eq!(patched.len(), 2);
1174 }
1175
1176 #[test]
1177 fn test_patch_dangling_tool_calls_with_result() {
1178 let tool_call = ToolCall {
1179 id: "call_123".to_string(),
1180 name: "get_weather".to_string(),
1181 arguments: serde_json::json!({"city": "NYC"}),
1182 };
1183
1184 let messages = vec![
1185 Message::user("What's the weather?"),
1186 Message::assistant_with_tools("Let me check", vec![tool_call]),
1187 Message::tool_result("call_123", Some(serde_json::json!({"temp": 72})), None),
1188 ];
1189
1190 let patched = patch_dangling_tool_calls(&messages);
1191 assert_eq!(patched.len(), 3);
1192 }
1193
1194 #[test]
1195 fn test_patch_dangling_tool_calls_missing_result() {
1196 let tool_call = ToolCall {
1197 id: "call_456".to_string(),
1198 name: "search_web".to_string(),
1199 arguments: serde_json::json!({"query": "rust"}),
1200 };
1201
1202 let messages = vec![
1203 Message::user("Search for rust"),
1204 Message::assistant_with_tools("Searching...", vec![tool_call]),
1205 Message::user("Actually, never mind"),
1206 ];
1207
1208 let patched = patch_dangling_tool_calls(&messages);
1209 assert_eq!(patched.len(), 4);
1211 assert_eq!(patched[2].role, MessageRole::ToolResult);
1212 assert_eq!(patched[2].tool_call_id(), Some("call_456"));
1213 }
1214
1215 #[test]
1216 fn test_user_message() {
1217 let msg = Message::user("Hello");
1218 assert_eq!(msg.role, MessageRole::User);
1219 assert_eq!(msg.text(), Some("Hello"));
1220 }
1221
1222 #[test]
1223 fn test_assistant_message() {
1224 let msg = Message::assistant("Hi there!");
1225 assert_eq!(msg.role, MessageRole::Agent);
1226 assert_eq!(msg.text(), Some("Hi there!"));
1227 }
1228
1229 #[test]
1230 fn test_tool_result_message() {
1231 let msg = Message::tool_result(
1232 "call_123",
1233 Some(serde_json::json!({"result": "success"})),
1234 None,
1235 );
1236 assert_eq!(msg.role, MessageRole::ToolResult);
1237 assert_eq!(msg.tool_call_id(), Some("call_123"));
1238 }
1239
1240 #[test]
1241 fn test_assistant_with_tools_and_text() {
1242 let tool_call = ToolCall {
1243 id: "call_123".to_string(),
1244 name: "get_weather".to_string(),
1245 arguments: serde_json::json!({"location": "Tokyo"}),
1246 };
1247 let msg = Message::assistant_with_tools("Let me check the weather.", vec![tool_call]);
1248
1249 assert_eq!(msg.role, MessageRole::Agent);
1250 assert_eq!(msg.text(), Some("Let me check the weather."));
1251 assert_eq!(msg.tool_calls().len(), 1);
1252 assert_eq!(msg.tool_calls()[0].name, "get_weather");
1253 }
1254
1255 #[test]
1256 fn test_assistant_with_tools_empty_text() {
1257 let tool_call = ToolCall {
1260 id: "call_123".to_string(),
1261 name: "search".to_string(),
1262 arguments: serde_json::json!({"query": "rust"}),
1263 };
1264 let msg = Message::assistant_with_tools("", vec![tool_call]);
1265
1266 assert_eq!(msg.role, MessageRole::Agent);
1267 assert_eq!(msg.text(), None);
1269 assert_eq!(msg.tool_calls().len(), 1);
1271 assert_eq!(msg.tool_calls()[0].name, "search");
1272 assert_eq!(msg.content.len(), 1);
1274 assert!(matches!(msg.content[0], ContentPart::ToolCall(_)));
1275 }
1276
1277 #[test]
1278 fn test_assistant_with_tools_whitespace_text() {
1279 let tool_call = ToolCall {
1281 id: "call_456".to_string(),
1282 name: "fetch".to_string(),
1283 arguments: serde_json::json!({}),
1284 };
1285 let msg = Message::assistant_with_tools(" ", vec![tool_call]);
1286
1287 assert_eq!(msg.text(), Some(" "));
1289 assert_eq!(msg.content.len(), 2); }
1291
1292 #[test]
1293 fn test_assistant_with_multiple_tool_calls() {
1294 let tool_calls = vec![
1295 ToolCall {
1296 id: "call_1".to_string(),
1297 name: "search".to_string(),
1298 arguments: serde_json::json!({"q": "a"}),
1299 },
1300 ToolCall {
1301 id: "call_2".to_string(),
1302 name: "fetch".to_string(),
1303 arguments: serde_json::json!({"url": "http://example.com"}),
1304 },
1305 ];
1306 let msg = Message::assistant_with_tools("", tool_calls);
1307
1308 assert_eq!(msg.tool_calls().len(), 2);
1309 assert_eq!(msg.content.len(), 2);
1311 }
1312
1313 #[test]
1318 fn test_to_openai_format_user_message() {
1319 let msg = Message::user("Hello, world!");
1320 let converted = msg.to_openai_format();
1321
1322 assert_eq!(converted["role"], "user");
1323 assert_eq!(converted["content"], "Hello, world!");
1324 }
1325
1326 #[test]
1327 fn test_to_openai_format_system_message() {
1328 let msg = Message::system("You are a helpful assistant.");
1329 let converted = msg.to_openai_format();
1330
1331 assert_eq!(converted["role"], "system");
1332 assert_eq!(converted["content"], "You are a helpful assistant.");
1333 }
1334
1335 #[test]
1336 fn test_to_openai_format_assistant_role_mapping() {
1337 let msg = Message::assistant("Hi there!");
1339 let converted = msg.to_openai_format();
1340
1341 assert_eq!(converted["role"], "assistant");
1342 assert_eq!(converted["content"], "Hi there!");
1343 }
1344
1345 #[test]
1346 fn test_to_openai_format_assistant_with_tool_calls() {
1347 let tool_call = ToolCall {
1348 id: "call_123".to_string(),
1349 name: "get_weather".to_string(),
1350 arguments: serde_json::json!({"location": "Tokyo"}),
1351 };
1352 let msg = Message::assistant_with_tools("Let me check.", vec![tool_call]);
1353 let converted = msg.to_openai_format();
1354
1355 assert_eq!(converted["role"], "assistant");
1356 assert_eq!(converted["content"], "Let me check.");
1357
1358 let tool_calls = converted["tool_calls"].as_array().unwrap();
1359 assert_eq!(tool_calls.len(), 1);
1360 assert_eq!(tool_calls[0]["id"], "call_123");
1361 assert_eq!(tool_calls[0]["type"], "function");
1362 assert_eq!(tool_calls[0]["function"]["name"], "get_weather");
1363 assert_eq!(
1364 tool_calls[0]["function"]["arguments"],
1365 r#"{"location":"Tokyo"}"#
1366 );
1367 }
1368
1369 #[test]
1370 fn test_to_openai_format_assistant_tool_calls_only() {
1371 let tool_call = ToolCall {
1373 id: "call_abc".to_string(),
1374 name: "search".to_string(),
1375 arguments: serde_json::json!({"query": "rust"}),
1376 };
1377 let msg = Message::assistant_with_tools("", vec![tool_call]);
1378 let converted = msg.to_openai_format();
1379
1380 assert_eq!(converted["role"], "assistant");
1381 assert!(converted.get("content").is_none());
1383 assert!(converted["tool_calls"].is_array());
1384 }
1385
1386 #[test]
1387 fn test_to_openai_format_tool_result_role_mapping() {
1388 let msg = Message::tool_result(
1390 "call_123",
1391 Some(serde_json::json!({"temperature": 72})),
1392 None,
1393 );
1394 let converted = msg.to_openai_format();
1395
1396 assert_eq!(converted["role"], "tool");
1397 assert_eq!(converted["tool_call_id"], "call_123");
1398 assert_eq!(converted["content"], r#"{"temperature":72}"#);
1399 }
1400
1401 #[test]
1402 fn test_to_openai_format_tool_result_error() {
1403 let msg = Message::tool_result("call_456", None, Some("API timeout".to_string()));
1404 let converted = msg.to_openai_format();
1405
1406 assert_eq!(converted["role"], "tool");
1407 assert_eq!(converted["tool_call_id"], "call_456");
1408 assert_eq!(converted["content"], "Error: API timeout");
1409 }
1410
1411 #[test]
1412 fn test_to_openai_format_full_conversation() {
1413 let tool_call = ToolCall {
1415 id: "call_abc".to_string(),
1416 name: "search".to_string(),
1417 arguments: serde_json::json!({"query": "rust"}),
1418 };
1419
1420 let messages = [
1421 Message::user("Search for rust"),
1422 Message::assistant_with_tools("", vec![tool_call]),
1423 Message::tool_result(
1424 "call_abc",
1425 Some(serde_json::json!({"results": ["rust-lang.org"]})),
1426 None,
1427 ),
1428 Message::assistant("Here are the search results."),
1429 ];
1430 let converted: Vec<_> = messages.iter().map(|m| m.to_openai_format()).collect();
1431
1432 assert_eq!(converted.len(), 4);
1433 assert_eq!(converted[0]["role"], "user");
1434 assert_eq!(converted[1]["role"], "assistant");
1435 assert!(converted[1]["tool_calls"].is_array());
1436 assert_eq!(converted[2]["role"], "tool");
1437 assert_eq!(converted[2]["tool_call_id"], "call_abc");
1438 assert_eq!(converted[3]["role"], "assistant");
1439 }
1440
1441 #[test]
1446 fn test_content_part_to_openai_format_text() {
1447 let part = ContentPart::text("Hello");
1448 let converted = part.to_openai_format().unwrap();
1449
1450 assert_eq!(converted["type"], "text");
1451 assert_eq!(converted["text"], "Hello");
1452 }
1453
1454 #[test]
1455 fn test_content_part_to_openai_format_image_url() {
1456 let part = ContentPart::image_url("https://example.com/img.png");
1457 let converted = part.to_openai_format().unwrap();
1458
1459 assert_eq!(converted["type"], "image_url");
1460 assert_eq!(converted["image_url"]["url"], "https://example.com/img.png");
1461 }
1462
1463 #[test]
1464 fn test_content_part_to_openai_format_image_base64() {
1465 let part = ContentPart::Image(ImageContentPart::from_base64("abc123", "image/jpeg"));
1466 let converted = part.to_openai_format().unwrap();
1467
1468 assert_eq!(converted["type"], "image_url");
1469 assert_eq!(
1470 converted["image_url"]["url"],
1471 "data:image/jpeg;base64,abc123"
1472 );
1473 }
1474
1475 #[test]
1476 fn test_content_part_to_openai_format_tool_call_returns_none() {
1477 let part = ContentPart::tool_call("call_1", "search", serde_json::json!({}));
1479 assert!(part.to_openai_format().is_none());
1480 }
1481
1482 #[test]
1483 fn test_content_part_to_openai_format_tool_result_returns_none() {
1484 let part = ContentPart::tool_result("call_1", Some(serde_json::json!({})), None);
1486 assert!(part.to_openai_format().is_none());
1487 }
1488
1489 #[test]
1490 fn test_execution_phase_from_has_tool_calls() {
1491 assert_eq!(
1492 ExecutionPhase::from_has_tool_calls(true),
1493 ExecutionPhase::Commentary
1494 );
1495 assert_eq!(
1496 ExecutionPhase::from_has_tool_calls(false),
1497 ExecutionPhase::FinalAnswer
1498 );
1499 }
1500
1501 #[test]
1502 fn test_execution_phase_refine_streamed_hint_monotonic() {
1503 use ExecutionPhase::{Commentary, FinalAnswer};
1504 assert_eq!(
1506 ExecutionPhase::refine_streamed_hint(None, Commentary),
1507 Some(Commentary)
1508 );
1509 assert_eq!(
1510 ExecutionPhase::refine_streamed_hint(None, FinalAnswer),
1511 Some(FinalAnswer)
1512 );
1513 assert_eq!(
1515 ExecutionPhase::refine_streamed_hint(Some(Commentary), FinalAnswer),
1516 Some(Commentary)
1517 );
1518 assert_eq!(
1519 ExecutionPhase::refine_streamed_hint(Some(FinalAnswer), Commentary),
1520 Some(FinalAnswer)
1521 );
1522 assert_eq!(
1525 ExecutionPhase::refine_streamed_hint(Some(Commentary), Commentary),
1526 Some(Commentary)
1527 );
1528 }
1529
1530 #[test]
1531 fn test_execution_phase_serde_roundtrip() {
1532 let commentary = ExecutionPhase::Commentary;
1533 let json = serde_json::to_string(&commentary).unwrap();
1534 assert_eq!(json, "\"commentary\"");
1535 let deserialized: ExecutionPhase = serde_json::from_str(&json).unwrap();
1536 assert_eq!(deserialized, ExecutionPhase::Commentary);
1537
1538 let final_answer = ExecutionPhase::FinalAnswer;
1539 let json = serde_json::to_string(&final_answer).unwrap();
1540 assert_eq!(json, "\"final_answer\"");
1541 let deserialized: ExecutionPhase = serde_json::from_str(&json).unwrap();
1542 assert_eq!(deserialized, ExecutionPhase::FinalAnswer);
1543 }
1544
1545 #[test]
1546 fn test_execution_phase_deserialize_legacy() {
1547 let legacy_in_progress: ExecutionPhase = serde_json::from_str("\"in_progress\"").unwrap();
1548 assert_eq!(legacy_in_progress, ExecutionPhase::Commentary);
1549
1550 let legacy_completed: ExecutionPhase = serde_json::from_str("\"completed\"").unwrap();
1551 assert_eq!(legacy_completed, ExecutionPhase::FinalAnswer);
1552 }
1553
1554 #[test]
1555 fn test_execution_phase_deserialize_unknown_fails() {
1556 let result = serde_json::from_str::<ExecutionPhase>("\"bogus\"");
1557 assert!(result.is_err());
1558 }
1559
1560 #[test]
1561 fn test_message_with_phase() {
1562 let msg = Message::assistant("Hello").with_phase(ExecutionPhase::Commentary);
1563 assert_eq!(msg.phase, Some(ExecutionPhase::Commentary));
1564 }
1565
1566 #[test]
1567 fn test_message_phase_skipped_when_none() {
1568 let msg = Message::assistant("Hello");
1569 let json = serde_json::to_value(&msg).unwrap();
1570 assert!(json.get("phase").is_none());
1571 }
1572
1573 #[test]
1574 fn test_message_phase_included_when_set() {
1575 let msg = Message::assistant("Hello").with_phase(ExecutionPhase::FinalAnswer);
1576 let json = serde_json::to_value(&msg).unwrap();
1577 assert_eq!(json.get("phase").unwrap(), "final_answer");
1578 }
1579
1580 #[test]
1581 fn test_resolve_hints_both_none() {
1582 let result = Controls::resolve_hints(None, None);
1583 assert!(result.is_empty());
1584 }
1585
1586 #[test]
1587 fn test_resolve_hints_session_only() {
1588 let mut session = std::collections::HashMap::new();
1589 session.insert("key1".into(), serde_json::json!("val1"));
1590 session.insert("key2".into(), serde_json::json!(42));
1591
1592 let result = Controls::resolve_hints(Some(&session), None);
1593 assert_eq!(result.len(), 2);
1594 assert_eq!(result["key1"], serde_json::json!("val1"));
1595 assert_eq!(result["key2"], serde_json::json!(42));
1596 }
1597
1598 #[test]
1599 fn test_resolve_hints_message_only() {
1600 let mut message = std::collections::HashMap::new();
1601 message.insert("key1".into(), serde_json::json!(true));
1602
1603 let result = Controls::resolve_hints(None, Some(&message));
1604 assert_eq!(result.len(), 1);
1605 assert_eq!(result["key1"], serde_json::json!(true));
1606 }
1607
1608 #[test]
1609 fn test_resolve_hints_message_overrides_session() {
1610 let mut session = std::collections::HashMap::new();
1611 session.insert("shared".into(), serde_json::json!("session_val"));
1612 session.insert("session_only".into(), serde_json::json!(1));
1613
1614 let mut message = std::collections::HashMap::new();
1615 message.insert("shared".into(), serde_json::json!("message_val"));
1616 message.insert("message_only".into(), serde_json::json!(2));
1617
1618 let result = Controls::resolve_hints(Some(&session), Some(&message));
1619 assert_eq!(result.len(), 3);
1620 assert_eq!(result["shared"], serde_json::json!("message_val"));
1621 assert_eq!(result["session_only"], serde_json::json!(1));
1622 assert_eq!(result["message_only"], serde_json::json!(2));
1623 }
1624
1625 #[test]
1626 fn test_controls_hints_serde_roundtrip() {
1627 let mut hints = std::collections::HashMap::new();
1628 hints.insert("setup_connection".into(), serde_json::json!(true));
1629 hints.insert("theme".into(), serde_json::json!("dark"));
1630
1631 let controls = Controls {
1632 hints: Some(hints),
1633 ..Default::default()
1634 };
1635
1636 let json = serde_json::to_value(&controls).unwrap();
1637 let deserialized: Controls = serde_json::from_value(json).unwrap();
1638 let h = deserialized.hints.unwrap();
1639 assert_eq!(h["setup_connection"], serde_json::json!(true));
1640 assert_eq!(h["theme"], serde_json::json!("dark"));
1641 }
1642
1643 #[test]
1644 fn tool_result_text_preserves_strings_without_json_escaping() {
1645 let value = serde_json::json!("{\n \"count\": 1\n}");
1646 assert_eq!(
1647 ContentPart::tool_result_text(&value).as_text(),
1648 Some("{\n \"count\": 1\n}")
1649 );
1650 }
1651
1652 #[test]
1653 fn tool_result_text_serializes_structured_values() {
1654 let value = serde_json::json!({"count": 1});
1655 assert_eq!(
1656 ContentPart::tool_result_text(&value).as_text(),
1657 Some("{\"count\":1}")
1658 );
1659 }
1660}