Skip to main content

everruns_core/
message.rs

1// Message types
2//
3// Message is a DB-agnostic message type that represents
4// a single message in the conversation history.
5//
6// Content is stored as Vec<ContentPart> for unified representation
7// across storage and runtime layers.
8
9use 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/// Message role in the conversation
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[cfg_attr(feature = "openapi", derive(ToSchema))]
22// Published as `RuntimeMessageRole`. The REST API exposes only `user` and
23// `agent` (`api::messages::MessageRole`); publishing this four-variant runtime
24// enum under the plain name made clients model roles the API never returns.
25#[cfg_attr(feature = "openapi", schema(as = RuntimeMessageRole))]
26#[serde(rename_all = "snake_case")]
27pub enum MessageRole {
28    /// System message (instructions)
29    System,
30    /// User message
31    User,
32    /// Agent response (may contain tool calls in content)
33    Agent,
34    /// Tool execution result
35    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            // Accept both "agent" and legacy "assistant"
55            "agent" | "assistant" => MessageRole::Agent,
56            "tool_result" => MessageRole::ToolResult,
57            _ => MessageRole::User,
58        }
59    }
60}
61
62// ============================================
63// External Actor (channel-agnostic user identity)
64// ============================================
65
66/// External actor identity for messages originating from external channels
67/// (Slack, Discord, Teams, etc.).
68///
69/// Channel adapters populate this to identify the sender without coupling
70/// core logic to any specific channel. The ReasonAtom uses this to prefix
71/// user messages so the LLM knows who is speaking.
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73#[cfg_attr(feature = "openapi", derive(ToSchema))]
74pub struct ExternalActor {
75    /// Opaque actor identifier from the source channel (e.g. Slack user ID "U0123456789")
76    pub actor_id: String,
77    /// Resolved display name (e.g. "Alice"). Falls back to actor_id if absent.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub actor_name: Option<String>,
80    /// Source channel identifier (e.g. "slack", "discord")
81    pub source: String,
82    /// Channel-specific metadata (e.g. team_id, channel_id)
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub metadata: Option<std::collections::HashMap<String, String>>,
85}
86
87impl ExternalActor {
88    /// Human-readable label: display name if available, otherwise actor_id.
89    pub fn display_label(&self) -> &str {
90        self.actor_name.as_deref().unwrap_or(&self.actor_id)
91    }
92}
93
94// ============================================
95// Controls (runtime options for message processing)
96// ============================================
97
98/// Reasoning configuration for the model
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100#[cfg_attr(feature = "openapi", derive(ToSchema))]
101pub struct ReasoningConfig {
102    /// Effort level for reasoning.
103    ///
104    /// Typed rather than free-form: the effort taxonomy is closed, and each
105    /// driver previously re-parsed the string with its own case handling, which
106    /// let `minimal` silently mean "no reasoning" on budget-based models.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub effort: Option<everruns_provider::model::ReasoningEffort>,
109}
110
111/// Runtime controls for message processing
112#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
113#[cfg_attr(feature = "openapi", derive(ToSchema))]
114pub struct Controls {
115    /// Model ID to use for this message (format: model_{32-hex}).
116    /// Overrides session and agent model settings.
117    #[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    /// Locale override for this message turn (BCP 47, e.g. `uk-UA`).
122    /// Overrides the session locale for backend-authored strings and prompts.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub locale: Option<String>,
125
126    /// Reasoning configuration
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub reasoning: Option<ReasoningConfig>,
129
130    /// Speed (service tier) for this message turn: "flex", "default", or
131    /// "priority". Only sent to providers whose model profile advertises a
132    /// speed config (OpenAI `service_tier`).
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub speed: Option<String>,
135
136    /// Verbosity for this message turn: "low", "medium", or "high". Only sent
137    /// to providers whose model profile advertises a verbosity config (OpenAI
138    /// `verbosity`).
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub verbosity: Option<String>,
141
142    /// Error disclosure override for this turn: "generic", "standard", or
143    /// "detailed". Clamped to at most the mode allowed by the agent's
144    /// `error_disclosure` capability (capability absent => "standard"), so a
145    /// client can narrow but never widen disclosure.
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub error_disclosure: Option<String>,
148
149    /// Generic client hints — arbitrary key-value pairs declared by the client.
150    /// Session-level defaults are set at session creation; per-message values
151    /// override session hints key-by-key (shallow merge).
152    ///
153    /// Examples: `{"setup_connection": true, "rich_media": true}`
154    #[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    /// Resolve effective hints by shallow-merging session-level defaults with
161    /// per-message overrides. Per-message hints take precedence key-by-key.
162    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/// A message in the conversation
180#[derive(Debug, Clone, Serialize, Deserialize)]
181#[cfg_attr(feature = "openapi", derive(ToSchema))]
182// Published as `RuntimeMessage`: this is the canonical runtime/event message,
183// distinct from the REST resource in `api::messages::Message` (which adds
184// `session_id` and `sequence`). Both previously claimed the name `Message` in
185// one OpenAPI document, so generated clients saw whichever won.
186#[cfg_attr(feature = "openapi", schema(as = RuntimeMessage))]
187pub struct Message {
188    /// Unique message ID (format: message_{32-hex})
189    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "message_01933b5a00007000800000000000001"))]
190    pub id: MessageId,
191
192    /// Message role
193    pub role: MessageRole,
194
195    /// Message content as array of content parts (text, images, tool calls, tool results)
196    pub content: Vec<ContentPart>,
197
198    /// Execution phase for this message.
199    ///
200    /// Helps LLMs distinguish between intermediate working commentary and completed
201    /// answers in multi-step tool-calling flows. Only set on agent (assistant) messages.
202    /// Providers with native phase support (OpenAI GPT-5.x) send this value in the API
203    /// request; others derive it from state but don't send it to the provider.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub phase: Option<ExecutionPhase>,
206
207    /// Whether [`Self::phase`] was reported by the provider or inferred from
208    /// tool-call presence. A derived phase carries no information beyond
209    /// "this message called tools", so consumers that need a real
210    /// classification must be able to tell the two apart.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub phase_source: Option<PhaseSource>,
213
214    /// Runtime controls (model, reasoning, etc.)
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub controls: Option<Controls>,
217
218    /// Message-level metadata
219    #[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    /// External actor identity (for messages from external channels like Slack)
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub external_actor: Option<ExternalActor>,
226
227    /// Timestamp when the message was created
228    pub created_at: DateTime<Utc>,
229}
230
231// ============================================
232// Content Type Enum
233// ============================================
234
235/// Content type discriminator
236#[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// ============================================
275// Content Part Structs
276// ============================================
277
278/// Text content part
279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
280#[cfg_attr(feature = "openapi", derive(ToSchema))]
281pub struct TextContentPart {
282    pub text: String,
283    /// Claim-level citations attached to spans of `text`.
284    ///
285    /// The narrow render contract shared by all citation capabilities (see
286    /// `knowledge/runtime-resources/citations.md`). Empty for non-cited text, so the wire shape of
287    /// existing messages is unchanged.
288    #[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    /// Attach citation annotations, replacing any existing ones.
301    pub fn with_annotations(mut self, annotations: Vec<TextAnnotation>) -> Self {
302        self.annotations = annotations;
303        self
304    }
305}
306
307/// A claim-level citation attached to a span of generated text.
308///
309/// The single shared type across every citation capability: a text span linked
310/// to a source. Producers agree only on this render contract — each capability
311/// keeps its own richer representation (e.g. `KnowledgeIndexCitation`) and maps
312/// into this envelope at emit time. See `knowledge/runtime-resources/citations.md`.
313#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
314#[cfg_attr(feature = "openapi", derive(ToSchema))]
315pub struct TextAnnotation {
316    /// 0-indexed start char offset into the enclosing `TextContentPart.text`.
317    #[cfg_attr(feature = "openapi", schema(example = 0))]
318    pub start: usize,
319    /// Exclusive end char offset.
320    #[cfg_attr(feature = "openapi", schema(example = 19))]
321    pub end: usize,
322    /// Capability id that produced this annotation (e.g. `citation_retrieval`).
323    /// Lets the UI and evals attribute and filter each citation by feed.
324    #[cfg_attr(feature = "openapi", schema(example = "citation_retrieval"))]
325    pub origin: String,
326    /// The cited source.
327    pub source: AnnotationSource,
328    /// Opaque producer id (e.g. `kchk_…`, `kbe_…`, a URL hash). Not interpreted
329    /// by the render contract.
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    #[cfg_attr(feature = "openapi", schema(example = "kchk_01j9y3q8w2"))]
332    pub external_id: Option<String>,
333    /// Verification verdict, filled by the `citation_verification` capability.
334    /// Absent means unverified (not "unsupported").
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub verified: Option<VerificationVerdict>,
337}
338
339/// The source a [`TextAnnotation`] points to.
340#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
341#[cfg_attr(feature = "openapi", derive(ToSchema))]
342pub struct AnnotationSource {
343    /// Stable, linkable locator (e.g. `github://owner/repo@main/docs/x.md` or an
344    /// `https://` URL).
345    #[cfg_attr(
346        feature = "openapi",
347        schema(example = "github://owner/repo@main/docs/x.md")
348    )]
349    pub uri: String,
350    /// Human-readable source title, when known.
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    #[cfg_attr(feature = "openapi", schema(example = "Architecture Overview"))]
353    pub title: Option<String>,
354    /// Trimmed passage that backs the claim. Display-only; never relied on for
355    /// prompt reconstruction.
356    #[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    /// Provenance within the document (line / char / page / block ranges),
363    /// reusing the retrieval `location` JSONB shape.
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub location: Option<serde_json::Value>,
366}
367
368/// Outcome of citation verification (see the `citation_verification` capability).
369#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
370#[cfg_attr(feature = "openapi", derive(ToSchema))]
371pub struct VerificationVerdict {
372    /// Whether the cited source supports the claim.
373    pub status: VerificationStatus,
374    /// Entailment confidence in `[0, 1]`, when the verifier produced one.
375    #[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/// Whether a cited source entails the claim it is attached to.
381#[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    /// The source supports the claim.
387    Entailed,
388    /// The source does not support the claim.
389    Unsupported,
390    /// The verifier could not decide.
391    Uncertain,
392}
393
394/// Image content part (base64 or URL)
395#[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/// Image file content part (reference to uploaded image)
425///
426/// This is used for images uploaded via the /images API.
427/// The image data is stored separately and referenced by ID.
428/// Note: Currently filtered out before sending to LLM.
429#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
430#[cfg_attr(feature = "openapi", derive(ToSchema))]
431pub struct ImageFileContentPart {
432    /// ID of the uploaded image (format: img_{32-hex})
433    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "img_01933b5a00007000800000000000001"))]
434    pub image_id: ImageId,
435    /// Original filename (for display)
436    #[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/// Tool call content part (assistant requesting tool execution)
457#[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/// Tool result content part (result of tool execution)
480#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
481#[cfg_attr(feature = "openapi", derive(ToSchema))]
482pub struct ToolResultContentPart {
483    /// ID of the tool call this result corresponds to
484    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// ============================================
522// Content Part Enums
523// ============================================
524
525/// A part of message content - can be text, image, image_file, tool_call, or tool_result
526///
527/// This is the canonical content part type used across the system.
528/// API layer enables the "openapi" feature to add ToSchema derive.
529#[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 content
534    Text(TextContentPart),
535    /// Image content (base64 or URL)
536    Image(ImageContentPart),
537    /// Image file content (reference to uploaded image by ID)
538    ImageFile(ImageFileContentPart),
539    /// Tool call content (assistant requesting tool execution)
540    ToolCall(ToolCallContentPart),
541    /// Tool result content (result of tool execution)
542    ToolResult(ToolResultContentPart),
543    /// Provider reasoning artifact, ordered against the text and tool calls it
544    /// was interleaved with.
545    Reasoning(ReasoningContentPart),
546}
547
548impl ContentPart {
549    /// Create a text content part
550    pub fn text(text: impl Into<String>) -> Self {
551        ContentPart::Text(TextContentPart::new(text))
552    }
553
554    /// Convert a JSON tool result into text without JSON-quoting string values.
555    /// Structured values retain their JSON representation for transport and details views.
556    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    /// Create an image content part from URL
564    pub fn image_url(url: impl Into<String>) -> Self {
565        ContentPart::Image(ImageContentPart::from_url(url))
566    }
567
568    /// Create an image file content part (reference to uploaded image)
569    pub fn image_file(image_id: ImageId) -> Self {
570        ContentPart::ImageFile(ImageFileContentPart::new(image_id))
571    }
572
573    /// Create a tool call content part
574    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    /// Create a tool result content part
583    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    /// Create a reasoning content part
592    pub fn reasoning(part: ReasoningContentPart) -> Self {
593        ContentPart::Reasoning(part)
594    }
595
596    /// Get the reasoning artifact if this is a reasoning part
597    pub fn as_reasoning(&self) -> Option<&ReasoningContentPart> {
598        match self {
599            ContentPart::Reasoning(r) => Some(r),
600            _ => None,
601        }
602    }
603
604    /// Whether this part is a reasoning artifact.
605    pub fn is_reasoning(&self) -> bool {
606        matches!(self, ContentPart::Reasoning(_))
607    }
608
609    /// Get text if this is a text part
610    pub fn as_text(&self) -> Option<&str> {
611        match self {
612            ContentPart::Text(t) => Some(&t.text),
613            _ => None,
614        }
615    }
616
617    /// Check if this is an ImageFile part
618    pub fn is_image_file(&self) -> bool {
619        matches!(self, ContentPart::ImageFile(_))
620    }
621
622    /// Get the content type
623    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    /// Convert content part to OpenAI-compatible format
635    ///
636    /// Returns `None` for content types that aren't valid in user/system messages
637    /// (ImageFile, ToolCall, ToolResult are handled at message level).
638    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            // ImageFile, ToolCall, ToolResult handled at message level
661            _ => None,
662        }
663    }
664}
665
666/// Input content part - text, image, and image_file (for user input)
667///
668/// This is a subset of ContentPart that users can send.
669/// Tool calls and results are system-generated.
670#[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 content
675    Text(TextContentPart),
676    /// Image content (base64 or URL)
677    Image(ImageContentPart),
678    /// Image file content (reference to uploaded image by ID)
679    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    /// Create a text content part
694    pub fn text(text: impl Into<String>) -> Self {
695        InputContentPart::Text(TextContentPart::new(text))
696    }
697
698    /// Create an image content part from URL
699    pub fn image_url(url: impl Into<String>) -> Self {
700        InputContentPart::Image(ImageContentPart::from_url(url))
701    }
702
703    /// Create an image file content part (reference to uploaded image)
704    pub fn image_file(image_id: ImageId) -> Self {
705        InputContentPart::ImageFile(ImageFileContentPart::new(image_id))
706    }
707
708    /// Get text content if this is a Text part
709    pub fn as_text(&self) -> Option<&str> {
710        match self {
711            InputContentPart::Text(t) => Some(&t.text),
712            _ => None,
713        }
714    }
715
716    /// Get the content type
717    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    /// Reasoning artifacts carried by this message, in emission order.
728    pub fn reasoning_parts(&self) -> impl Iterator<Item = &ReasoningContentPart> {
729        self.content.iter().filter_map(ContentPart::as_reasoning)
730    }
731
732    /// Whether this message carries any provider reasoning artifact.
733    pub fn has_reasoning(&self) -> bool {
734        self.content.iter().any(ContentPart::is_reasoning)
735    }
736
737    /// Readable reasoning across every artifact, joined for display.
738    ///
739    /// Display only. Replay must walk [`Message::reasoning_parts`] so each
740    /// artifact keeps its own signature and position.
741    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    /// Replace every reasoning part with its publishable projection, dropping
751    /// opaque provider replay state. Used at API boundaries.
752    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    /// Override the generated message id.
762    ///
763    /// Streaming producers use this to allocate a public id before emitting
764    /// `output.message.started`, then reuse it on the completed message.
765    pub fn with_id(mut self, id: MessageId) -> Self {
766        self.id = id;
767        self
768    }
769
770    /// Create a new user message
771    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    /// Create a new assistant message
786    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    /// Create a new assistant message with tool calls
801    ///
802    /// Tool calls are stored as ContentPart::ToolCall in the content array
803    /// alongside the text content. Empty text content is omitted to avoid
804    /// LLM API errors (e.g., Anthropic requires non-empty text blocks).
805    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        // Only include text part if non-empty
812        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    /// Create a new system message
836    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    /// Create a tool result message
851    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    /// Create a tool result message with images.
875    ///
876    /// Images are included as `ContentPart::Image` alongside the `ToolResult` part.
877    /// When converted to `LlmMessage`, images become native image content blocks
878    /// that the LLM can see visually (not just stringified base64).
879    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    /// Set the execution phase on this message and return self.
910    pub fn with_phase(mut self, phase: ExecutionPhase) -> Self {
911        self.phase = Some(phase);
912        self
913    }
914
915    /// Set the phase together with where it came from.
916    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    /// Get the tool_call_id from a tool result message
923    ///
924    /// Returns the tool_call_id from the first ToolResult content part, if any.
925    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    /// Get first text content from the message
933    pub fn text(&self) -> Option<&str> {
934        self.content.iter().find_map(|p| p.as_text())
935    }
936
937    /// Get all tool calls from the message content
938    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    /// Check if this message has tool calls
949    pub fn has_tool_calls(&self) -> bool {
950        self.content
951            .iter()
952            .any(|p| matches!(p, ContentPart::ToolCall(_)))
953    }
954
955    /// Get the first tool result from the message content
956    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    /// Convert content to LLM-compatible string representation
964    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                // Reasoning is replayed as provider-native artifacts on
970                // `LlmMessage::reasoning`; it must never be flattened into
971                // prompt text. Filtered out below.
972                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    /// Convert message to OpenAI-compatible format
998    ///
999    /// Transforms internal message format to OpenAI API format:
1000    /// - `agent` role → `assistant`
1001    /// - `tool_result` role → `tool` (with tool_call_id at message level)
1002    /// - Tool calls formatted as `{id, type: "function", function: {name, arguments}}`
1003    ///
1004    /// Used by observability backends (e.g., Braintrust) that expect OpenAI format.
1005    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        // Handle tool result messages (need tool_call_id at message level)
1014        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        // Handle assistant messages with tool calls
1041        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        // For system/user messages, convert content parts
1086        let content = self.content_to_openai_format();
1087        serde_json::json!({
1088            "role": role,
1089            "content": content
1090        })
1091    }
1092
1093    /// Convert content parts to OpenAI-compatible format
1094    fn content_to_openai_format(&self) -> serde_json::Value {
1095        // Single text content → string
1096        if self.content.len() == 1
1097            && let ContentPart::Text(t) = &self.content[0]
1098        {
1099            return serde_json::json!(t.text);
1100        }
1101
1102        // Convert each content part
1103        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        // Single text part after filtering → string
1114        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
1124/// Patch dangling tool calls by adding synthetic "cancelled" results.
1125///
1126/// This ensures every tool call has a corresponding tool result,
1127/// preventing LLM API errors (e.g., OpenAI requires every tool_call to have a result).
1128///
1129/// This is the simple, store-free patcher used by out-of-band completions
1130/// (see `crate::command_host`). The main reason path uses the durable-store-aware
1131/// the execution kernel's transcript-repair path instead (EVE-533),
1132/// which can replay settled results rather than synthesizing cancellations.
1133pub 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        // After an assistant message with tool calls, add cancelled results for any missing ones
1140        if msg.role == MessageRole::Agent && msg.has_tool_calls() {
1141            for tc in msg.tool_calls() {
1142                // Look for a matching tool result in ALL subsequent messages
1143                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}