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::{FileId, 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    /// Generic file attachment (for example a PDF).
244    File,
245    ToolCall,
246    ToolResult,
247    Reasoning,
248}
249
250impl std::fmt::Display for ContentType {
251    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        match self {
253            ContentType::Text => write!(f, "text"),
254            ContentType::Image => write!(f, "image"),
255            ContentType::ImageFile => write!(f, "image_file"),
256            ContentType::File => write!(f, "file"),
257            ContentType::ToolCall => write!(f, "tool_call"),
258            ContentType::ToolResult => write!(f, "tool_result"),
259            ContentType::Reasoning => write!(f, "reasoning"),
260        }
261    }
262}
263
264impl From<&str> for ContentType {
265    fn from(s: &str) -> Self {
266        match s {
267            "image" => ContentType::Image,
268            "image_file" => ContentType::ImageFile,
269            "tool_call" => ContentType::ToolCall,
270            "tool_result" => ContentType::ToolResult,
271            "reasoning" => ContentType::Reasoning,
272            _ => ContentType::Text,
273        }
274    }
275}
276
277// ============================================
278// Content Part Structs
279// ============================================
280
281/// Text content part
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
283#[cfg_attr(feature = "openapi", derive(ToSchema))]
284pub struct TextContentPart {
285    pub text: String,
286    /// Claim-level citations attached to spans of `text`.
287    ///
288    /// The narrow render contract shared by all citation capabilities (see
289    /// `knowledge/runtime-resources/citations.md`). Empty for non-cited text, so the wire shape of
290    /// existing messages is unchanged.
291    #[serde(default, skip_serializing_if = "Vec::is_empty")]
292    pub annotations: Vec<TextAnnotation>,
293}
294
295impl TextContentPart {
296    pub fn new(text: impl Into<String>) -> Self {
297        Self {
298            text: text.into(),
299            annotations: Vec::new(),
300        }
301    }
302
303    /// Attach citation annotations, replacing any existing ones.
304    pub fn with_annotations(mut self, annotations: Vec<TextAnnotation>) -> Self {
305        self.annotations = annotations;
306        self
307    }
308}
309
310/// A claim-level citation attached to a span of generated text.
311///
312/// The single shared type across every citation capability: a text span linked
313/// to a source. Producers agree only on this render contract — each capability
314/// keeps its own richer representation (e.g. `KnowledgeIndexCitation`) and maps
315/// into this envelope at emit time. See `knowledge/runtime-resources/citations.md`.
316#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
317#[cfg_attr(feature = "openapi", derive(ToSchema))]
318pub struct TextAnnotation {
319    /// 0-indexed start char offset into the enclosing `TextContentPart.text`.
320    #[cfg_attr(feature = "openapi", schema(example = 0))]
321    pub start: usize,
322    /// Exclusive end char offset.
323    #[cfg_attr(feature = "openapi", schema(example = 19))]
324    pub end: usize,
325    /// Capability id that produced this annotation (e.g. `citation_retrieval`).
326    /// Lets the UI and evals attribute and filter each citation by feed.
327    #[cfg_attr(feature = "openapi", schema(example = "citation_retrieval"))]
328    pub origin: String,
329    /// The cited source.
330    pub source: AnnotationSource,
331    /// Opaque producer id (e.g. `kchk_…`, `kbe_…`, a URL hash). Not interpreted
332    /// by the render contract.
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    #[cfg_attr(feature = "openapi", schema(example = "kchk_01j9y3q8w2"))]
335    pub external_id: Option<String>,
336    /// Verification verdict, filled by the `citation_verification` capability.
337    /// Absent means unverified (not "unsupported").
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub verified: Option<VerificationVerdict>,
340}
341
342/// The source a [`TextAnnotation`] points to.
343#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
344#[cfg_attr(feature = "openapi", derive(ToSchema))]
345pub struct AnnotationSource {
346    /// Stable, linkable locator (e.g. `github://owner/repo@main/docs/x.md` or an
347    /// `https://` URL).
348    #[cfg_attr(
349        feature = "openapi",
350        schema(example = "github://owner/repo@main/docs/x.md")
351    )]
352    pub uri: String,
353    /// Human-readable source title, when known.
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    #[cfg_attr(feature = "openapi", schema(example = "Architecture Overview"))]
356    pub title: Option<String>,
357    /// Trimmed passage that backs the claim. Display-only; never relied on for
358    /// prompt reconstruction.
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    #[cfg_attr(
361        feature = "openapi",
362        schema(example = "The control plane owns durable state.")
363    )]
364    pub snippet: Option<String>,
365    /// Provenance within the document (line / char / page / block ranges),
366    /// reusing the retrieval `location` JSONB shape.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub location: Option<serde_json::Value>,
369}
370
371/// Outcome of citation verification (see the `citation_verification` capability).
372#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
373#[cfg_attr(feature = "openapi", derive(ToSchema))]
374pub struct VerificationVerdict {
375    /// Whether the cited source supports the claim.
376    pub status: VerificationStatus,
377    /// Entailment confidence in `[0, 1]`, when the verifier produced one.
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    #[cfg_attr(feature = "openapi", schema(example = 0.92))]
380    pub score: Option<f32>,
381}
382
383/// Whether a cited source entails the claim it is attached to.
384#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
385#[cfg_attr(feature = "openapi", derive(ToSchema))]
386#[cfg_attr(feature = "openapi", schema(example = "entailed"))]
387#[serde(rename_all = "snake_case")]
388pub enum VerificationStatus {
389    /// The source supports the claim.
390    Entailed,
391    /// The source does not support the claim.
392    Unsupported,
393    /// The verifier could not decide.
394    Uncertain,
395}
396
397/// Image content part (base64 or URL)
398#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
399#[cfg_attr(feature = "openapi", derive(ToSchema))]
400pub struct ImageContentPart {
401    #[serde(skip_serializing_if = "Option::is_none")]
402    pub url: Option<String>,
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub base64: Option<String>,
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub media_type: Option<String>,
407}
408
409impl ImageContentPart {
410    pub fn from_url(url: impl Into<String>) -> Self {
411        Self {
412            url: Some(url.into()),
413            base64: None,
414            media_type: None,
415        }
416    }
417
418    pub fn from_base64(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
419        Self {
420            url: None,
421            base64: Some(base64.into()),
422            media_type: Some(media_type.into()),
423        }
424    }
425}
426
427/// Image file content part (reference to uploaded image)
428///
429/// This is used for images uploaded via the /images API.
430/// The image data is stored separately and referenced by ID.
431/// Note: Currently filtered out before sending to LLM.
432#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
433#[cfg_attr(feature = "openapi", derive(ToSchema))]
434pub struct ImageFileContentPart {
435    /// ID of the uploaded image (format: img_{32-hex})
436    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "img_01933b5a00007000800000000000001"))]
437    pub image_id: ImageId,
438    /// Original filename (for display)
439    #[serde(skip_serializing_if = "Option::is_none")]
440    pub filename: Option<String>,
441}
442
443impl ImageFileContentPart {
444    pub fn new(image_id: ImageId) -> Self {
445        Self {
446            image_id,
447            filename: None,
448        }
449    }
450
451    pub fn with_filename(image_id: ImageId, filename: impl Into<String>) -> Self {
452        Self {
453            image_id,
454            filename: Some(filename.into()),
455        }
456    }
457}
458
459/// File content part (reference to an uploaded file, e.g. a PDF)
460///
461/// This is used for files uploaded via the /files API.
462/// The file data is stored separately and referenced by ID.
463#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
464#[cfg_attr(feature = "openapi", derive(ToSchema))]
465pub struct FileContentPart {
466    /// ID of the uploaded file (format: file_{32-hex})
467    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "file_01933b5a00007000800000000000001"))]
468    pub file_id: FileId,
469    /// Original filename (for display and provider file parts)
470    #[serde(skip_serializing_if = "Option::is_none")]
471    pub filename: Option<String>,
472}
473
474impl FileContentPart {
475    /// Create a file content part that references an already-stored file.
476    pub fn new(file_id: FileId) -> Self {
477        Self {
478            file_id,
479            filename: None,
480        }
481    }
482
483    /// Create a file content part with its original filename attached.
484    pub fn with_filename(file_id: FileId, filename: impl Into<String>) -> Self {
485        Self {
486            file_id,
487            filename: Some(filename.into()),
488        }
489    }
490}
491
492/// Tool call content part (assistant requesting tool execution)
493#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
494#[cfg_attr(feature = "openapi", derive(ToSchema))]
495pub struct ToolCallContentPart {
496    /// Original native call, including raw custom input and async metadata.
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub native: Option<everruns_provider::native_async::NativeToolCall>,
499    pub id: String,
500    pub name: String,
501    pub arguments: serde_json::Value,
502}
503
504impl ToolCallContentPart {
505    /// Validate and retain a native call alongside its portable tool arguments.
506    pub fn from_native(
507        call: everruns_provider::native_async::NativeToolCall,
508    ) -> crate::error::Result<Self> {
509        use everruns_provider::native_async::NativeToolCall;
510        call.validate()?;
511        let arguments = match &call {
512            NativeToolCall::Function { arguments, .. } => serde_json::from_str(arguments)
513                .map_err(|error| crate::error::AgentLoopError::llm(error.to_string()))?,
514            NativeToolCall::Custom { input, .. } => serde_json::Value::String(input.clone()),
515        };
516        Ok(Self {
517            id: call.id().into(),
518            name: call.name().into(),
519            arguments,
520            native: Some(call),
521        })
522    }
523
524    pub fn new(
525        id: impl Into<String>,
526        name: impl Into<String>,
527        arguments: serde_json::Value,
528    ) -> Self {
529        Self {
530            native: None,
531            id: id.into(),
532            name: name.into(),
533            arguments,
534        }
535    }
536}
537
538/// Tool result content part (result of tool execution)
539#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
540#[cfg_attr(feature = "openapi", derive(ToSchema))]
541pub struct ToolResultContentPart {
542    /// ID of the tool call this result corresponds to
543    pub tool_call_id: String,
544    #[serde(skip_serializing_if = "Option::is_none")]
545    pub result: Option<serde_json::Value>,
546    #[serde(skip_serializing_if = "Option::is_none")]
547    pub error: Option<String>,
548}
549
550impl ToolResultContentPart {
551    pub fn new(
552        tool_call_id: impl Into<String>,
553        result: Option<serde_json::Value>,
554        error: Option<String>,
555    ) -> Self {
556        Self {
557            tool_call_id: tool_call_id.into(),
558            result,
559            error,
560        }
561    }
562
563    pub fn success(tool_call_id: impl Into<String>, result: serde_json::Value) -> Self {
564        Self {
565            tool_call_id: tool_call_id.into(),
566            result: Some(result),
567            error: None,
568        }
569    }
570
571    pub fn error(tool_call_id: impl Into<String>, error: impl Into<String>) -> Self {
572        Self {
573            tool_call_id: tool_call_id.into(),
574            result: None,
575            error: Some(error.into()),
576        }
577    }
578}
579
580// ============================================
581// Content Part Enums
582// ============================================
583
584/// A part of message content - can be text, image, image_file, tool_call, or tool_result
585///
586/// This is the canonical content part type used across the system.
587/// API layer enables the "openapi" feature to add ToSchema derive.
588#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
589#[cfg_attr(feature = "openapi", derive(ToSchema))]
590#[serde(tag = "type", rename_all = "snake_case")]
591pub enum ContentPart {
592    /// Text content
593    Text(TextContentPart),
594    /// Image content (base64 or URL)
595    Image(ImageContentPart),
596    /// Image file content (reference to uploaded image by ID)
597    ImageFile(ImageFileContentPart),
598    /// File content (reference to uploaded file, e.g. PDF, by ID)
599    File(FileContentPart),
600    /// Tool call content (assistant requesting tool execution)
601    ToolCall(ToolCallContentPart),
602    /// Tool result content (result of tool execution)
603    ToolResult(ToolResultContentPart),
604    /// Provider reasoning artifact, ordered against the text and tool calls it
605    /// was interleaved with.
606    Reasoning(ReasoningContentPart),
607}
608
609impl ContentPart {
610    /// Create a text content part
611    pub fn text(text: impl Into<String>) -> Self {
612        ContentPart::Text(TextContentPart::new(text))
613    }
614
615    /// Convert a JSON tool result into text without JSON-quoting string values.
616    /// Structured values retain their JSON representation for transport and details views.
617    pub fn tool_result_text(value: &serde_json::Value) -> Self {
618        match value {
619            serde_json::Value::String(text) => Self::text(text.clone()),
620            other => Self::text(other.to_string()),
621        }
622    }
623
624    /// Create an image content part from URL
625    pub fn image_url(url: impl Into<String>) -> Self {
626        ContentPart::Image(ImageContentPart::from_url(url))
627    }
628
629    /// Create an image file content part (reference to uploaded image)
630    pub fn image_file(image_id: ImageId) -> Self {
631        ContentPart::ImageFile(ImageFileContentPart::new(image_id))
632    }
633
634    /// Create a generic file content part (reference to an uploaded file).
635    pub fn file(file_id: FileId) -> Self {
636        ContentPart::File(FileContentPart::new(file_id))
637    }
638
639    /// Create a tool call content part
640    pub fn tool_call(
641        id: impl Into<String>,
642        name: impl Into<String>,
643        arguments: serde_json::Value,
644    ) -> Self {
645        ContentPart::ToolCall(ToolCallContentPart::new(id, name, arguments))
646    }
647
648    /// Create a tool result content part
649    pub fn tool_result(
650        tool_call_id: impl Into<String>,
651        result: Option<serde_json::Value>,
652        error: Option<String>,
653    ) -> Self {
654        ContentPart::ToolResult(ToolResultContentPart::new(tool_call_id, result, error))
655    }
656
657    /// Create a reasoning content part
658    pub fn reasoning(part: ReasoningContentPart) -> Self {
659        ContentPart::Reasoning(part)
660    }
661
662    /// Get the reasoning artifact if this is a reasoning part
663    pub fn as_reasoning(&self) -> Option<&ReasoningContentPart> {
664        match self {
665            ContentPart::Reasoning(r) => Some(r),
666            _ => None,
667        }
668    }
669
670    /// Whether this part is a reasoning artifact.
671    pub fn is_reasoning(&self) -> bool {
672        matches!(self, ContentPart::Reasoning(_))
673    }
674
675    /// Get text if this is a text part
676    pub fn as_text(&self) -> Option<&str> {
677        match self {
678            ContentPart::Text(t) => Some(&t.text),
679            _ => None,
680        }
681    }
682
683    /// Check if this is an ImageFile part
684    pub fn is_image_file(&self) -> bool {
685        matches!(self, ContentPart::ImageFile(_))
686    }
687
688    /// Returns true when this is a generic file part.
689    pub fn is_file(&self) -> bool {
690        matches!(self, ContentPart::File(_))
691    }
692
693    /// Get the content type
694    pub fn content_type(&self) -> ContentType {
695        match self {
696            ContentPart::Text(_) => ContentType::Text,
697            ContentPart::Image(_) => ContentType::Image,
698            ContentPart::ImageFile(_) => ContentType::ImageFile,
699            ContentPart::File(_) => ContentType::File,
700            ContentPart::ToolCall(_) => ContentType::ToolCall,
701            ContentPart::ToolResult(_) => ContentType::ToolResult,
702            ContentPart::Reasoning(_) => ContentType::Reasoning,
703        }
704    }
705
706    /// Convert content part to OpenAI-compatible format
707    ///
708    /// Returns `None` for content types that aren't valid in user/system messages
709    /// (ImageFile, ToolCall, ToolResult are handled at message level).
710    pub fn to_openai_format(&self) -> Option<serde_json::Value> {
711        match self {
712            ContentPart::Text(t) => Some(serde_json::json!({
713                "type": "text",
714                "text": t.text
715            })),
716            ContentPart::Image(img) => {
717                if let Some(url) = &img.url {
718                    Some(serde_json::json!({
719                        "type": "image_url",
720                        "image_url": { "url": url }
721                    }))
722                } else if let Some(b64) = &img.base64 {
723                    let media_type = img.media_type.as_deref().unwrap_or("image/png");
724                    Some(serde_json::json!({
725                        "type": "image_url",
726                        "image_url": { "url": format!("data:{};base64,{}", media_type, b64) }
727                    }))
728                } else {
729                    None
730                }
731            }
732            // ImageFile, ToolCall, ToolResult handled at message level
733            _ => None,
734        }
735    }
736}
737
738/// Input content part - text, image, and image_file (for user input)
739///
740/// This is a subset of ContentPart that users can send.
741/// Tool calls and results are system-generated.
742#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
743#[cfg_attr(feature = "openapi", derive(ToSchema))]
744#[serde(tag = "type", rename_all = "snake_case")]
745pub enum InputContentPart {
746    /// Text content
747    Text(TextContentPart),
748    /// Image content (base64 or URL)
749    Image(ImageContentPart),
750    /// Image file content (reference to uploaded image by ID)
751    ImageFile(ImageFileContentPart),
752    /// File content (reference to uploaded file, e.g. PDF, by ID)
753    File(FileContentPart),
754}
755
756impl From<InputContentPart> for ContentPart {
757    fn from(input: InputContentPart) -> Self {
758        match input {
759            InputContentPart::Text(t) => ContentPart::Text(t),
760            InputContentPart::Image(i) => ContentPart::Image(i),
761            InputContentPart::ImageFile(f) => ContentPart::ImageFile(f),
762            InputContentPart::File(f) => ContentPart::File(f),
763        }
764    }
765}
766
767impl InputContentPart {
768    /// Create a text content part
769    pub fn text(text: impl Into<String>) -> Self {
770        InputContentPart::Text(TextContentPart::new(text))
771    }
772
773    /// Create an image content part from URL
774    pub fn image_url(url: impl Into<String>) -> Self {
775        InputContentPart::Image(ImageContentPart::from_url(url))
776    }
777
778    /// Create an image file content part (reference to uploaded image)
779    pub fn image_file(image_id: ImageId) -> Self {
780        InputContentPart::ImageFile(ImageFileContentPart::new(image_id))
781    }
782
783    /// Create a generic file input part (reference to an uploaded file).
784    pub fn file(file_id: FileId) -> Self {
785        InputContentPart::File(FileContentPart::new(file_id))
786    }
787
788    /// Get text content if this is a Text part
789    pub fn as_text(&self) -> Option<&str> {
790        match self {
791            InputContentPart::Text(t) => Some(&t.text),
792            _ => None,
793        }
794    }
795
796    /// Get the content type
797    pub fn content_type(&self) -> ContentType {
798        match self {
799            InputContentPart::Text(_) => ContentType::Text,
800            InputContentPart::Image(_) => ContentType::Image,
801            InputContentPart::ImageFile(_) => ContentType::ImageFile,
802            InputContentPart::File(_) => ContentType::File,
803        }
804    }
805}
806
807impl Message {
808    /// Reasoning artifacts carried by this message, in emission order.
809    pub fn reasoning_parts(&self) -> impl Iterator<Item = &ReasoningContentPart> {
810        self.content.iter().filter_map(ContentPart::as_reasoning)
811    }
812
813    /// Whether this message carries any provider reasoning artifact.
814    pub fn has_reasoning(&self) -> bool {
815        self.content.iter().any(ContentPart::is_reasoning)
816    }
817
818    /// Readable reasoning across every artifact, joined for display.
819    ///
820    /// Display only. Replay must walk [`Message::reasoning_parts`] so each
821    /// artifact keeps its own signature and position.
822    pub fn reasoning_display_text(&self) -> Option<String> {
823        let joined = self
824            .reasoning_parts()
825            .filter_map(ReasoningContentPart::display_text)
826            .collect::<Vec<_>>()
827            .join("\n\n");
828        (!joined.is_empty()).then_some(joined)
829    }
830
831    /// Replace every reasoning part with its publishable projection, dropping
832    /// opaque provider replay state. Used at API boundaries.
833    pub fn into_public(mut self) -> Self {
834        for part in &mut self.content {
835            if let ContentPart::Reasoning(r) = part {
836                *r = r.to_public();
837            }
838        }
839        self
840    }
841
842    /// Override the generated message id.
843    ///
844    /// Streaming producers use this to allocate a public id before emitting
845    /// `output.message.started`, then reuse it on the completed message.
846    pub fn with_id(mut self, id: MessageId) -> Self {
847        self.id = id;
848        self
849    }
850
851    /// Create a new user message
852    pub fn user(content: impl Into<String>) -> Self {
853        Self {
854            id: MessageId::new(),
855            role: MessageRole::User,
856            content: vec![ContentPart::text(content)],
857            phase: None,
858            phase_source: None,
859            controls: None,
860            metadata: None,
861            external_actor: None,
862            created_at: Utc::now(),
863        }
864    }
865
866    /// Create a new assistant message
867    pub fn assistant(content: impl Into<String>) -> Self {
868        Self {
869            id: MessageId::new(),
870            role: MessageRole::Agent,
871            content: vec![ContentPart::text(content)],
872            phase: None,
873            phase_source: None,
874            controls: None,
875            metadata: None,
876            external_actor: None,
877            created_at: Utc::now(),
878        }
879    }
880
881    /// Create a new assistant message with tool calls
882    ///
883    /// Tool calls are stored as ContentPart::ToolCall in the content array
884    /// alongside the text content. Empty text content is omitted to avoid
885    /// LLM API errors (e.g., Anthropic requires non-empty text blocks).
886    pub fn assistant_with_tools(
887        content: impl Into<String>,
888        tool_calls: Vec<crate::tool_types::ToolCall>,
889    ) -> Self {
890        let text_content = content.into();
891        let mut parts = Vec::new();
892        // Only include text part if non-empty
893        if !text_content.is_empty() {
894            parts.push(ContentPart::text(text_content));
895        }
896        for tc in tool_calls {
897            parts.push(ContentPart::ToolCall(ToolCallContentPart {
898                native: None,
899                id: tc.id,
900                name: tc.name,
901                arguments: tc.arguments,
902            }));
903        }
904        Self {
905            id: MessageId::new(),
906            role: MessageRole::Agent,
907            content: parts,
908            phase: None,
909            phase_source: None,
910            controls: None,
911            metadata: None,
912            external_actor: None,
913            created_at: Utc::now(),
914        }
915    }
916
917    /// Create a new system message
918    pub fn system(content: impl Into<String>) -> Self {
919        Self {
920            id: MessageId::new(),
921            role: MessageRole::System,
922            content: vec![ContentPart::text(content)],
923            phase: None,
924            phase_source: None,
925            controls: None,
926            metadata: None,
927            external_actor: None,
928            created_at: Utc::now(),
929        }
930    }
931
932    /// Create a tool result message
933    pub fn tool_result(
934        tool_call_id: impl Into<String>,
935        result: Option<serde_json::Value>,
936        error: Option<String>,
937    ) -> Self {
938        let tool_call_id = tool_call_id.into();
939        Self {
940            id: MessageId::new(),
941            role: MessageRole::ToolResult,
942            content: vec![ContentPart::ToolResult(ToolResultContentPart::new(
943                tool_call_id,
944                result,
945                error,
946            ))],
947            phase: None,
948            phase_source: None,
949            controls: None,
950            metadata: None,
951            external_actor: None,
952            created_at: Utc::now(),
953        }
954    }
955
956    /// Create a tool result message with images.
957    ///
958    /// Images are included as `ContentPart::Image` alongside the `ToolResult` part.
959    /// When converted to `LlmMessage`, images become native image content blocks
960    /// that the LLM can see visually (not just stringified base64).
961    pub fn tool_result_with_images(
962        tool_call_id: impl Into<String>,
963        result: Option<serde_json::Value>,
964        images: Vec<everruns_provider::tool_types::ToolResultImage>,
965    ) -> Self {
966        let tool_call_id = tool_call_id.into();
967        let mut content = vec![ContentPart::ToolResult(ToolResultContentPart::new(
968            tool_call_id,
969            result,
970            None,
971        ))];
972        for img in images {
973            content.push(ContentPart::Image(ImageContentPart::from_base64(
974                img.base64,
975                img.media_type,
976            )));
977        }
978        Self {
979            id: MessageId::new(),
980            role: MessageRole::ToolResult,
981            content,
982            phase: None,
983            phase_source: None,
984            controls: None,
985            metadata: None,
986            external_actor: None,
987            created_at: Utc::now(),
988        }
989    }
990
991    /// Set the execution phase on this message and return self.
992    pub fn with_phase(mut self, phase: ExecutionPhase) -> Self {
993        self.phase = Some(phase);
994        self
995    }
996
997    /// Set the phase together with where it came from.
998    pub fn with_phase_from(mut self, phase: ExecutionPhase, source: PhaseSource) -> Self {
999        self.phase = Some(phase);
1000        self.phase_source = Some(source);
1001        self
1002    }
1003
1004    /// Get the tool_call_id from a tool result message
1005    ///
1006    /// Returns the tool_call_id from the first ToolResult content part, if any.
1007    pub fn tool_call_id(&self) -> Option<&str> {
1008        self.content.iter().find_map(|p| match p {
1009            ContentPart::ToolResult(tr) => Some(tr.tool_call_id.as_str()),
1010            _ => None,
1011        })
1012    }
1013
1014    /// Get first text content from the message
1015    pub fn text(&self) -> Option<&str> {
1016        self.content.iter().find_map(|p| p.as_text())
1017    }
1018
1019    /// Get all tool calls from the message content
1020    pub fn tool_calls(&self) -> Vec<&ToolCallContentPart> {
1021        self.content
1022            .iter()
1023            .filter_map(|p| match p {
1024                ContentPart::ToolCall(tc) => Some(tc),
1025                _ => None,
1026            })
1027            .collect()
1028    }
1029
1030    /// Check if this message has tool calls
1031    pub fn has_tool_calls(&self) -> bool {
1032        self.content
1033            .iter()
1034            .any(|p| matches!(p, ContentPart::ToolCall(_)))
1035    }
1036
1037    /// Get the first tool result from the message content
1038    pub fn tool_result_content(&self) -> Option<&ToolResultContentPart> {
1039        self.content.iter().find_map(|p| match p {
1040            ContentPart::ToolResult(tr) => Some(tr),
1041            _ => None,
1042        })
1043    }
1044
1045    /// Convert content to LLM-compatible string representation
1046    pub fn content_to_llm_string(&self) -> String {
1047        self.content
1048            .iter()
1049            .map(|part| match part {
1050                ContentPart::Text(t) => t.text.clone(),
1051                // Reasoning is replayed as provider-native artifacts on
1052                // `LlmMessage::reasoning`; it must never be flattened into
1053                // prompt text. Filtered out below.
1054                ContentPart::Reasoning(_) => String::new(),
1055                ContentPart::Image(_) => "[Image]".to_string(),
1056                ContentPart::ImageFile(_) => "[Image File]".to_string(),
1057                ContentPart::File(part) => part
1058                    .filename
1059                    .clone()
1060                    .map(|n| format!("[PDF File: {}]", n))
1061                    .unwrap_or_else(|| "[PDF File]".to_string()),
1062                ContentPart::ToolCall(tc) => {
1063                    format!(
1064                        "Tool call: {} with arguments: {}",
1065                        tc.name,
1066                        serde_json::to_string(&tc.arguments).unwrap_or_default()
1067                    )
1068                }
1069                ContentPart::ToolResult(tr) => {
1070                    if let Some(err) = &tr.error {
1071                        format!("Tool error: {}", err)
1072                    } else if let Some(res) = &tr.result {
1073                        serde_json::to_string(res).unwrap_or_else(|_| "{}".to_string())
1074                    } else {
1075                        "{}".to_string()
1076                    }
1077                }
1078            })
1079            .filter(|rendered| !rendered.is_empty())
1080            .collect::<Vec<_>>()
1081            .join("\n")
1082    }
1083
1084    /// Convert message to OpenAI-compatible format
1085    ///
1086    /// Transforms internal message format to OpenAI API format:
1087    /// - `agent` role → `assistant`
1088    /// - `tool_result` role → `tool` (with tool_call_id at message level)
1089    /// - Tool calls formatted as `{id, type: "function", function: {name, arguments}}`
1090    ///
1091    /// Used by observability backends (e.g., Braintrust) that expect OpenAI format.
1092    pub fn to_openai_format(&self) -> serde_json::Value {
1093        let role = match self.role {
1094            MessageRole::System => "system",
1095            MessageRole::User => "user",
1096            MessageRole::Agent => "assistant",
1097            MessageRole::ToolResult => "tool",
1098        };
1099
1100        // Handle tool result messages (need tool_call_id at message level)
1101        if self.role == MessageRole::ToolResult {
1102            let tool_call_id = self.tool_call_id().unwrap_or("");
1103            let content = self
1104                .content
1105                .iter()
1106                .find_map(|p| match p {
1107                    ContentPart::ToolResult(tr) => {
1108                        if let Some(error) = &tr.error {
1109                            Some(format!("Error: {}", error))
1110                        } else if let Some(result) = &tr.result {
1111                            Some(serde_json::to_string(result).unwrap_or_else(|_| "{}".to_string()))
1112                        } else {
1113                            Some("{}".to_string())
1114                        }
1115                    }
1116                    _ => None,
1117                })
1118                .unwrap_or_else(|| "{}".to_string());
1119
1120            return serde_json::json!({
1121                "role": role,
1122                "content": content,
1123                "tool_call_id": tool_call_id
1124            });
1125        }
1126
1127        // Handle assistant messages with tool calls
1128        if self.role == MessageRole::Agent {
1129            let tool_calls: Vec<serde_json::Value> = self
1130                .content
1131                .iter()
1132                .filter_map(|p| match p {
1133                    ContentPart::ToolCall(tc) => Some(serde_json::json!({
1134                        "id": tc.id,
1135                        "type": "function",
1136                        "function": {
1137                            "name": tc.name,
1138                            "arguments": serde_json::to_string(&tc.arguments).unwrap_or_else(|_| "{}".to_string())
1139                        }
1140                    })),
1141                    _ => None,
1142                })
1143                .collect();
1144
1145            let text_content: String = self
1146                .content
1147                .iter()
1148                .filter_map(|p| match p {
1149                    ContentPart::Text(t) => Some(t.text.clone()),
1150                    _ => None,
1151                })
1152                .collect::<Vec<_>>()
1153                .join("\n");
1154
1155            if tool_calls.is_empty() {
1156                return serde_json::json!({
1157                    "role": role,
1158                    "content": text_content
1159                });
1160            } else {
1161                let mut result = serde_json::json!({
1162                    "role": role,
1163                    "tool_calls": tool_calls
1164                });
1165                if !text_content.is_empty() {
1166                    result["content"] = serde_json::json!(text_content);
1167                }
1168                return result;
1169            }
1170        }
1171
1172        // For system/user messages, convert content parts
1173        let content = self.content_to_openai_format();
1174        serde_json::json!({
1175            "role": role,
1176            "content": content
1177        })
1178    }
1179
1180    /// Convert content parts to OpenAI-compatible format
1181    fn content_to_openai_format(&self) -> serde_json::Value {
1182        // Single text content → string
1183        if self.content.len() == 1
1184            && let ContentPart::Text(t) = &self.content[0]
1185        {
1186            return serde_json::json!(t.text);
1187        }
1188
1189        // Convert each content part
1190        let parts: Vec<serde_json::Value> = self
1191            .content
1192            .iter()
1193            .filter_map(|part| part.to_openai_format())
1194            .collect();
1195
1196        if parts.is_empty() {
1197            return serde_json::json!("");
1198        }
1199
1200        // Single text part after filtering → string
1201        if parts.len() == 1
1202            && let Some(text) = parts[0].get("text")
1203        {
1204            return text.clone();
1205        }
1206
1207        serde_json::json!(parts)
1208    }
1209}
1210
1211/// Patch dangling tool calls by adding synthetic "cancelled" results.
1212///
1213/// This ensures every tool call has a corresponding tool result,
1214/// preventing LLM API errors (e.g., OpenAI requires every tool_call to have a result).
1215///
1216/// This is the simple, store-free patcher used by out-of-band completions
1217/// (see `crate::command_host`). The main reason path uses the durable-store-aware
1218/// the execution kernel's transcript-repair path instead (EVE-533),
1219/// which can replay settled results rather than synthesizing cancellations.
1220pub fn patch_dangling_tool_calls(messages: &[Message]) -> Vec<Message> {
1221    let mut result = Vec::new();
1222
1223    for (i, msg) in messages.iter().enumerate() {
1224        result.push(msg.clone());
1225
1226        // After an assistant message with tool calls, add cancelled results for any missing ones
1227        if msg.role == MessageRole::Agent && msg.has_tool_calls() {
1228            for tc in msg.tool_calls() {
1229                // Look for a matching tool result in ALL subsequent messages
1230                let has_result = messages[(i + 1)..]
1231                    .iter()
1232                    .any(|m| m.role == MessageRole::ToolResult && m.tool_call_id() == Some(&tc.id));
1233
1234                if !has_result {
1235                    result.push(Message::tool_result(
1236                        &tc.id,
1237                        None,
1238                        Some(
1239                            "cancelled - another message came in before it could be completed"
1240                                .to_string(),
1241                        ),
1242                    ));
1243                }
1244            }
1245        }
1246    }
1247
1248    result
1249}
1250
1251#[cfg(test)]
1252mod tests {
1253    use super::*;
1254    use crate::tool_types::ToolCall;
1255    use serde_json::json;
1256
1257    fn calls() -> Vec<ToolCall> {
1258        vec![
1259            ToolCall {
1260                id: "call_search".into(),
1261                name: "search".into(),
1262                arguments: json!({"q": "rust"}),
1263            },
1264            ToolCall {
1265                id: "call_fetch".into(),
1266                name: "fetch".into(),
1267                arguments: json!({"url": "https://example.com"}),
1268            },
1269        ]
1270    }
1271
1272    fn assert_messages(actual: &[Message], expected: &[Message]) {
1273        assert_eq!(
1274            serde_json::to_value(actual).unwrap(),
1275            serde_json::to_value(expected).unwrap()
1276        );
1277    }
1278
1279    #[test]
1280    fn native_custom_call_survives_transcript_serialization_and_conversion() {
1281        let native = everruns_provider::native_async::NativeToolCall::Custom {
1282            call_id: "original-call".into(),
1283            name: "lookup".into(),
1284            input: "raw\nquery: \"value\"".into(),
1285            asynchronous: true,
1286        };
1287        let mut message = Message::assistant("");
1288        message.content.push(ContentPart::ToolCall(
1289            ToolCallContentPart::from_native(native.clone()).unwrap(),
1290        ));
1291        let restored: Message =
1292            serde_json::from_slice(&serde_json::to_vec(&message).unwrap()).unwrap();
1293        assert_eq!(restored.tool_calls()[0].native.as_ref(), Some(&native));
1294        let llm = crate::llm_conversions::llm_message_from_message(&restored);
1295        assert_eq!(llm.native_tool_calls, vec![native]);
1296        assert_eq!(llm.tool_calls.unwrap()[0].id, "original-call");
1297    }
1298
1299    #[test]
1300    fn settled_transcripts_are_preserved_without_synthetic_results() {
1301        for messages in [
1302            vec![],
1303            vec![Message::user("Hello"), Message::assistant("Hi")],
1304            vec![
1305                Message::assistant_with_tools("Searching", vec![calls()[0].clone()]),
1306                Message::tool_result("call_search", Some(json!({"found": 2})), None),
1307            ],
1308        ] {
1309            assert_messages(&patch_dangling_tool_calls(&messages), &messages);
1310        }
1311    }
1312
1313    #[test]
1314    fn dangling_calls_get_only_missing_cancellations_and_patching_is_idempotent() {
1315        let messages = vec![
1316            Message::user("Search then fetch"),
1317            Message::assistant_with_tools("Working", calls()),
1318            Message::user("Never mind"),
1319            Message::tool_result("call_search", Some(json!({"found": 2})), None),
1320        ];
1321        let patched = patch_dangling_tool_calls(&messages);
1322        assert_eq!(patched.len(), 5);
1323        assert_messages(&patched[..2], &messages[..2]);
1324        assert_messages(&patched[3..], &messages[2..]);
1325        assert_eq!(patched[2].role, MessageRole::ToolResult);
1326        assert_eq!(
1327            serde_json::to_value(&patched[2].content).unwrap(),
1328            json!([{
1329                "type": "tool_result", "tool_call_id": "call_fetch",
1330                "error": "cancelled - another message came in before it could be completed"
1331            }])
1332        );
1333        assert_messages(&patch_dangling_tool_calls(&patched), &patched);
1334    }
1335
1336    #[test]
1337    fn plain_message_constructors_preserve_role_and_text() {
1338        for (message, role, text) in [
1339            (Message::user("question"), MessageRole::User, "question"),
1340            (Message::assistant("answer"), MessageRole::Agent, "answer"),
1341            (
1342                Message::system("instruction"),
1343                MessageRole::System,
1344                "instruction",
1345            ),
1346        ] {
1347            assert_eq!(message.role, role);
1348            assert_eq!(message.text(), Some(text));
1349            assert_eq!(message.content, vec![ContentPart::text(text)]);
1350            assert!(!message.has_tool_calls());
1351        }
1352    }
1353
1354    #[test]
1355    fn tool_result_constructor_preserves_result_and_error_fields() {
1356        for (result, error) in [
1357            (Some(json!({"count": 2})), None),
1358            (None, Some("timeout".to_owned())),
1359            (Some(json!(false)), Some("partial".to_owned())),
1360        ] {
1361            let message = Message::tool_result("call_result", result.clone(), error.clone());
1362            assert_eq!(message.role, MessageRole::ToolResult);
1363            assert_eq!(message.tool_call_id(), Some("call_result"));
1364            assert_eq!(
1365                message.content,
1366                vec![ContentPart::tool_result("call_result", result, error)]
1367            );
1368        }
1369    }
1370
1371    #[test]
1372    fn assistant_tool_messages_preserve_calls_and_distinguish_empty_from_whitespace_text() {
1373        for text in ["", "   ", "Working"] {
1374            let message = Message::assistant_with_tools(text, calls());
1375            let tool_parts: Vec<_> = calls()
1376                .into_iter()
1377                .map(|c| ContentPart::tool_call(c.id, c.name, c.arguments))
1378                .collect();
1379            let mut expected = vec![];
1380            if !text.is_empty() {
1381                expected.push(ContentPart::text(text));
1382            }
1383            expected.extend(tool_parts);
1384            assert_eq!(message.role, MessageRole::Agent);
1385            assert_eq!(message.text(), (!text.is_empty()).then_some(text));
1386            assert_eq!(message.content, expected);
1387            assert!(message.has_tool_calls());
1388            assert_eq!(
1389                serde_json::to_value(message.tool_calls()).unwrap(),
1390                serde_json::to_value(calls()).unwrap()
1391            );
1392        }
1393    }
1394
1395    #[test]
1396    fn openai_plain_messages_map_internal_roles_and_preserve_text() {
1397        for (message, expected) in [
1398            (
1399                Message::user("question"),
1400                json!({"role": "user", "content": "question"}),
1401            ),
1402            (
1403                Message::system("instruction"),
1404                json!({"role": "system", "content": "instruction"}),
1405            ),
1406            (
1407                Message::assistant("answer"),
1408                json!({"role": "assistant", "content": "answer"}),
1409            ),
1410        ] {
1411            assert_eq!(message.to_openai_format(), expected);
1412        }
1413    }
1414
1415    #[test]
1416    fn openai_tool_calls_preserve_ids_arguments_and_optional_text() {
1417        for text in ["", "Working"] {
1418            let message = Message::assistant_with_tools(text, calls());
1419            let mut expected = json!({"role": "assistant", "tool_calls": [
1420                {"id": "call_search", "type": "function", "function": {"name": "search", "arguments": "{\"q\":\"rust\"}"}},
1421                {"id": "call_fetch", "type": "function", "function": {"name": "fetch", "arguments": "{\"url\":\"https://example.com\"}"}}
1422            ]});
1423            if !text.is_empty() {
1424                expected["content"] = text.into();
1425            }
1426            assert_eq!(message.to_openai_format(), expected);
1427        }
1428    }
1429
1430    #[test]
1431    fn openai_tool_results_prefer_errors_and_preserve_call_identity() {
1432        for (result, error, content) in [
1433            (
1434                Some(json!({"temperature":72})),
1435                None,
1436                "{\"temperature\":72}",
1437            ),
1438            (None, Some("timeout"), "Error: timeout"),
1439            (
1440                Some(json!({"partial":true})),
1441                Some("partial failure"),
1442                "Error: partial failure",
1443            ),
1444            (None, None, "{}"),
1445        ] {
1446            let message = Message::tool_result("call_result", result, error.map(str::to_owned));
1447            assert_eq!(
1448                message.to_openai_format(),
1449                json!({"role":"tool", "tool_call_id":"call_result", "content":content})
1450            );
1451        }
1452    }
1453
1454    #[test]
1455    fn openai_content_parts_preserve_text_and_image_sources() {
1456        for (part, expected) in [
1457            (
1458                ContentPart::text("Hello"),
1459                json!({"type":"text", "text":"Hello"}),
1460            ),
1461            (
1462                ContentPart::image_url("https://example.com/img.png"),
1463                json!({"type":"image_url", "image_url":{"url":"https://example.com/img.png"}}),
1464            ),
1465            (
1466                ContentPart::Image(ImageContentPart::from_base64("YWJj", "image/jpeg")),
1467                json!({"type":"image_url", "image_url":{"url":"data:image/jpeg;base64,YWJj"}}),
1468            ),
1469            (
1470                ContentPart::Image(ImageContentPart {
1471                    url: None,
1472                    base64: Some("YWJj".into()),
1473                    media_type: None,
1474                }),
1475                json!({"type":"image_url", "image_url":{"url":"data:image/png;base64,YWJj"}}),
1476            ),
1477            (
1478                ContentPart::Image(ImageContentPart {
1479                    url: Some("https://example.com/preferred".into()),
1480                    base64: Some("YWJj".into()),
1481                    media_type: Some("image/jpeg".into()),
1482                }),
1483                json!({"type":"image_url", "image_url":{"url":"https://example.com/preferred"}}),
1484            ),
1485        ] {
1486            assert_eq!(part.to_openai_format(), Some(expected));
1487        }
1488        assert!(
1489            ContentPart::Image(ImageContentPart {
1490                url: None,
1491                base64: None,
1492                media_type: None
1493            })
1494            .to_openai_format()
1495            .is_none()
1496        );
1497    }
1498
1499    #[test]
1500    fn openai_content_parts_exclude_tool_file_and_reasoning_artifacts() {
1501        for part in [
1502            ContentPart::tool_call("call_1", "lookup", json!({})),
1503            ContentPart::tool_result("call_1", Some(json!(42)), None),
1504            ContentPart::image_file(ImageId::new()),
1505            ContentPart::reasoning(
1506                ReasoningContentPart::opaque("test").with_signature("private-signature"),
1507            ),
1508        ] {
1509            assert!(part.to_openai_format().is_none());
1510        }
1511    }
1512
1513    #[test]
1514    fn openai_message_content_preserves_multimodal_order_and_filters_unsupported_parts() {
1515        let mut message = Message::user("before");
1516        message
1517            .content
1518            .push(ContentPart::image_url("https://example.com/image"));
1519        message.content.push(ContentPart::text("after"));
1520        assert_eq!(
1521            message.to_openai_format(),
1522            json!({"role":"user", "content":[
1523                {"type":"text", "text":"before"}, {"type":"image_url", "image_url":{"url":"https://example.com/image"}},
1524                {"type":"text", "text":"after"}
1525            ]})
1526        );
1527        message.content = vec![
1528            ContentPart::tool_call("ignored", "tool", json!({})),
1529            ContentPart::text("kept"),
1530        ];
1531        assert_eq!(
1532            message.to_openai_format(),
1533            json!({"role":"user", "content":"kept"})
1534        );
1535        message.content.remove(1);
1536        assert_eq!(
1537            message.to_openai_format(),
1538            json!({"role":"user", "content":""})
1539        );
1540        let mut assistant = Message::assistant("first");
1541        assistant.content.push(ContentPart::text("second"));
1542        assert_eq!(
1543            assistant.to_openai_format(),
1544            json!({"role":"assistant", "content":"first\nsecond"})
1545        );
1546    }
1547
1548    #[test]
1549    fn message_phase_wire_contract_preserves_optional_source() {
1550        for (phase, wire) in [
1551            (None, None),
1552            (Some(ExecutionPhase::Commentary), Some("commentary")),
1553            (Some(ExecutionPhase::FinalAnswer), Some("final_answer")),
1554        ] {
1555            for source in [
1556                None,
1557                Some(PhaseSource::Provider),
1558                Some(PhaseSource::Derived),
1559            ] {
1560                if phase.is_none() && source.is_some() {
1561                    continue;
1562                }
1563                let message = match (phase, source) {
1564                    (Some(phase), Some(source)) => {
1565                        Message::assistant("answer").with_phase_from(phase, source)
1566                    }
1567                    (Some(phase), None) => Message::assistant("answer").with_phase(phase),
1568                    _ => Message::assistant("answer"),
1569                };
1570                let json = serde_json::to_value(&message).unwrap();
1571                assert_eq!(
1572                    json.get("phase"),
1573                    wire.map(serde_json::Value::from).as_ref()
1574                );
1575                let source_wire = match source {
1576                    Some(PhaseSource::Provider) => Some("provider"),
1577                    Some(PhaseSource::Derived) => Some("derived"),
1578                    None => None,
1579                };
1580                assert_eq!(
1581                    json.get("phase_source"),
1582                    source_wire.map(serde_json::Value::from).as_ref()
1583                );
1584                let decoded: Message = serde_json::from_value(json.clone()).unwrap();
1585                assert_eq!(decoded.phase, phase);
1586                assert_eq!(decoded.phase_source, source);
1587                assert_eq!(decoded.text(), Some("answer"));
1588                assert_eq!(serde_json::to_value(decoded).unwrap(), json);
1589            }
1590        }
1591    }
1592
1593    #[test]
1594    fn hints_merge_shallowly_with_message_precedence() {
1595        let session = std::collections::HashMap::from([
1596            ("shared".into(), json!({"old":1})),
1597            ("session_only".into(), json!(42)),
1598        ]);
1599        let message = std::collections::HashMap::from([
1600            ("shared".into(), json!({"new":2})),
1601            ("message_only".into(), json!(null)),
1602        ]);
1603        for (left, right, expected) in [
1604            (None, None, json!({})),
1605            (
1606                Some(&session),
1607                None,
1608                json!({"shared":{"old":1},"session_only":42}),
1609            ),
1610            (
1611                None,
1612                Some(&message),
1613                json!({"shared":{"new":2},"message_only":null}),
1614            ),
1615            (
1616                Some(&session),
1617                Some(&message),
1618                json!({"shared":{"new":2},"session_only":42,"message_only":null}),
1619            ),
1620        ] {
1621            assert_eq!(
1622                serde_json::to_value(Controls::resolve_hints(left, right)).unwrap(),
1623                expected
1624            );
1625        }
1626    }
1627
1628    #[test]
1629    fn controls_wire_contract_preserves_all_overrides_and_legacy_defaults() {
1630        let expected = json!({"model_id":"model_00000000000000000000000000000006", "locale":"uk-UA",
1631            "reasoning":{"effort":"high"}, "speed":"priority", "verbosity":"low", "error_disclosure":"generic",
1632            "hints":{"setup_connection":true,"theme":"dark"}});
1633        let controls = Controls {
1634            model_id: Some(ModelId::from_uuid(uuid::Uuid::from_u128(6))),
1635            locale: Some("uk-UA".into()),
1636            reasoning: Some(ReasoningConfig {
1637                effort: Some(everruns_provider::model::ReasoningEffort::High),
1638            }),
1639            speed: Some("priority".into()),
1640            verbosity: Some("low".into()),
1641            error_disclosure: Some("generic".into()),
1642            hints: Some(std::collections::HashMap::from([
1643                ("setup_connection".into(), json!(true)),
1644                ("theme".into(), json!("dark")),
1645            ])),
1646        };
1647        assert_eq!(serde_json::to_value(&controls).unwrap(), expected);
1648        assert_eq!(
1649            serde_json::from_value::<Controls>(expected).unwrap(),
1650            controls
1651        );
1652        let legacy: Controls = serde_json::from_value(json!({})).unwrap();
1653        assert_eq!(serde_json::to_value(legacy).unwrap(), json!({}));
1654    }
1655
1656    #[test]
1657    fn tool_result_text_preserves_strings_without_json_escaping() {
1658        let value = serde_json::json!("{\n  \"count\": 1\n}");
1659        assert_eq!(
1660            ContentPart::tool_result_text(&value).as_text(),
1661            Some("{\n  \"count\": 1\n}")
1662        );
1663    }
1664
1665    #[test]
1666    fn tool_result_text_serializes_structured_values() {
1667        for (value, expected) in [
1668            (json!({"count":1}), "{\"count\":1}"),
1669            (json!([true, 2]), "[true,2]"),
1670            (json!(null), "null"),
1671        ] {
1672            assert_eq!(
1673                ContentPart::tool_result_text(&value).as_text(),
1674                Some(expected)
1675            );
1676        }
1677    }
1678    #[test]
1679    fn file_content_part_serde_roundtrip() {
1680        let part = ContentPart::File(FileContentPart::with_filename(FileId::new(), "report.pdf"));
1681        let v = serde_json::to_value(&part).unwrap();
1682        assert_eq!(v["type"], serde_json::json!("file"));
1683        assert_eq!(v["filename"], serde_json::json!("report.pdf"));
1684        let back: ContentPart = serde_json::from_value(v).unwrap();
1685        assert_eq!(back, part);
1686        assert!(back.is_file());
1687        assert_eq!(back.content_type(), ContentType::File);
1688        assert_eq!(ContentType::File.to_string(), "file");
1689    }
1690}