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")]
591#[non_exhaustive]
592pub enum ContentPart {
593    /// Text content
594    Text(TextContentPart),
595    /// Image content (base64 or URL)
596    Image(ImageContentPart),
597    /// Image file content (reference to uploaded image by ID)
598    ImageFile(ImageFileContentPart),
599    /// File content (reference to uploaded file, e.g. PDF, by ID)
600    File(FileContentPart),
601    /// Tool call content (assistant requesting tool execution)
602    ToolCall(ToolCallContentPart),
603    /// Tool result content (result of tool execution)
604    ToolResult(ToolResultContentPart),
605    /// Provider reasoning artifact, ordered against the text and tool calls it
606    /// was interleaved with.
607    Reasoning(ReasoningContentPart),
608}
609
610impl ContentPart {
611    /// Create a text content part
612    pub fn text(text: impl Into<String>) -> Self {
613        ContentPart::Text(TextContentPart::new(text))
614    }
615
616    /// Convert a JSON tool result into text without JSON-quoting string values.
617    /// Structured values retain their JSON representation for transport and details views.
618    pub fn tool_result_text(value: &serde_json::Value) -> Self {
619        match value {
620            serde_json::Value::String(text) => Self::text(text.clone()),
621            other => Self::text(other.to_string()),
622        }
623    }
624
625    /// Create an image content part from URL
626    pub fn image_url(url: impl Into<String>) -> Self {
627        ContentPart::Image(ImageContentPart::from_url(url))
628    }
629
630    /// Create an image file content part (reference to uploaded image)
631    pub fn image_file(image_id: ImageId) -> Self {
632        ContentPart::ImageFile(ImageFileContentPart::new(image_id))
633    }
634
635    /// Create a generic file content part (reference to an uploaded file).
636    pub fn file(file_id: FileId) -> Self {
637        ContentPart::File(FileContentPart::new(file_id))
638    }
639
640    /// Create a tool call content part
641    pub fn tool_call(
642        id: impl Into<String>,
643        name: impl Into<String>,
644        arguments: serde_json::Value,
645    ) -> Self {
646        ContentPart::ToolCall(ToolCallContentPart::new(id, name, arguments))
647    }
648
649    /// Create a tool result content part
650    pub fn tool_result(
651        tool_call_id: impl Into<String>,
652        result: Option<serde_json::Value>,
653        error: Option<String>,
654    ) -> Self {
655        ContentPart::ToolResult(ToolResultContentPart::new(tool_call_id, result, error))
656    }
657
658    /// Create a reasoning content part
659    pub fn reasoning(part: ReasoningContentPart) -> Self {
660        ContentPart::Reasoning(part)
661    }
662
663    /// Get the reasoning artifact if this is a reasoning part
664    pub fn as_reasoning(&self) -> Option<&ReasoningContentPart> {
665        match self {
666            ContentPart::Reasoning(r) => Some(r),
667            _ => None,
668        }
669    }
670
671    /// Whether this part is a reasoning artifact.
672    pub fn is_reasoning(&self) -> bool {
673        matches!(self, ContentPart::Reasoning(_))
674    }
675
676    /// Get text if this is a text part
677    pub fn as_text(&self) -> Option<&str> {
678        match self {
679            ContentPart::Text(t) => Some(&t.text),
680            _ => None,
681        }
682    }
683
684    /// Check if this is an ImageFile part
685    pub fn is_image_file(&self) -> bool {
686        matches!(self, ContentPart::ImageFile(_))
687    }
688
689    /// Returns true when this is a generic file part.
690    pub fn is_file(&self) -> bool {
691        matches!(self, ContentPart::File(_))
692    }
693
694    /// Get the content type
695    pub fn content_type(&self) -> ContentType {
696        match self {
697            ContentPart::Text(_) => ContentType::Text,
698            ContentPart::Image(_) => ContentType::Image,
699            ContentPart::ImageFile(_) => ContentType::ImageFile,
700            ContentPart::File(_) => ContentType::File,
701            ContentPart::ToolCall(_) => ContentType::ToolCall,
702            ContentPart::ToolResult(_) => ContentType::ToolResult,
703            ContentPart::Reasoning(_) => ContentType::Reasoning,
704        }
705    }
706
707    /// Convert content part to OpenAI-compatible format
708    ///
709    /// Returns `None` for content types that aren't valid in user/system messages
710    /// (ImageFile, ToolCall, ToolResult are handled at message level).
711    pub fn to_openai_format(&self) -> Option<serde_json::Value> {
712        match self {
713            ContentPart::Text(t) => Some(serde_json::json!({
714                "type": "text",
715                "text": t.text
716            })),
717            ContentPart::Image(img) => {
718                if let Some(url) = &img.url {
719                    Some(serde_json::json!({
720                        "type": "image_url",
721                        "image_url": { "url": url }
722                    }))
723                } else if let Some(b64) = &img.base64 {
724                    let media_type = img.media_type.as_deref().unwrap_or("image/png");
725                    Some(serde_json::json!({
726                        "type": "image_url",
727                        "image_url": { "url": format!("data:{};base64,{}", media_type, b64) }
728                    }))
729                } else {
730                    None
731                }
732            }
733            // ImageFile, ToolCall, ToolResult handled at message level
734            _ => None,
735        }
736    }
737}
738
739/// Input content part - text, image, and image_file (for user input)
740///
741/// This is a subset of ContentPart that users can send.
742/// Tool calls and results are system-generated.
743#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
744#[cfg_attr(feature = "openapi", derive(ToSchema))]
745#[serde(tag = "type", rename_all = "snake_case")]
746pub enum InputContentPart {
747    /// Text content
748    Text(TextContentPart),
749    /// Image content (base64 or URL)
750    Image(ImageContentPart),
751    /// Image file content (reference to uploaded image by ID)
752    ImageFile(ImageFileContentPart),
753    /// File content (reference to uploaded file, e.g. PDF, by ID)
754    File(FileContentPart),
755}
756
757impl From<InputContentPart> for ContentPart {
758    fn from(input: InputContentPart) -> Self {
759        match input {
760            InputContentPart::Text(t) => ContentPart::Text(t),
761            InputContentPart::Image(i) => ContentPart::Image(i),
762            InputContentPart::ImageFile(f) => ContentPart::ImageFile(f),
763            InputContentPart::File(f) => ContentPart::File(f),
764        }
765    }
766}
767
768impl InputContentPart {
769    /// Create a text content part
770    pub fn text(text: impl Into<String>) -> Self {
771        InputContentPart::Text(TextContentPart::new(text))
772    }
773
774    /// Create an image content part from URL
775    pub fn image_url(url: impl Into<String>) -> Self {
776        InputContentPart::Image(ImageContentPart::from_url(url))
777    }
778
779    /// Create an image file content part (reference to uploaded image)
780    pub fn image_file(image_id: ImageId) -> Self {
781        InputContentPart::ImageFile(ImageFileContentPart::new(image_id))
782    }
783
784    /// Create a generic file input part (reference to an uploaded file).
785    pub fn file(file_id: FileId) -> Self {
786        InputContentPart::File(FileContentPart::new(file_id))
787    }
788
789    /// Get text content if this is a Text part
790    pub fn as_text(&self) -> Option<&str> {
791        match self {
792            InputContentPart::Text(t) => Some(&t.text),
793            _ => None,
794        }
795    }
796
797    /// Get the content type
798    pub fn content_type(&self) -> ContentType {
799        match self {
800            InputContentPart::Text(_) => ContentType::Text,
801            InputContentPart::Image(_) => ContentType::Image,
802            InputContentPart::ImageFile(_) => ContentType::ImageFile,
803            InputContentPart::File(_) => ContentType::File,
804        }
805    }
806}
807
808impl Message {
809    /// Reasoning artifacts carried by this message, in emission order.
810    pub fn reasoning_parts(&self) -> impl Iterator<Item = &ReasoningContentPart> {
811        self.content.iter().filter_map(ContentPart::as_reasoning)
812    }
813
814    /// Whether this message carries any provider reasoning artifact.
815    pub fn has_reasoning(&self) -> bool {
816        self.content.iter().any(ContentPart::is_reasoning)
817    }
818
819    /// Readable reasoning across every artifact, joined for display.
820    ///
821    /// Display only. Replay must walk [`Message::reasoning_parts`] so each
822    /// artifact keeps its own signature and position.
823    pub fn reasoning_display_text(&self) -> Option<String> {
824        let joined = self
825            .reasoning_parts()
826            .filter_map(ReasoningContentPart::display_text)
827            .collect::<Vec<_>>()
828            .join("\n\n");
829        (!joined.is_empty()).then_some(joined)
830    }
831
832    /// Replace every reasoning part with its publishable projection, dropping
833    /// opaque provider replay state. Used at API boundaries.
834    pub fn into_public(mut self) -> Self {
835        for part in &mut self.content {
836            if let ContentPart::Reasoning(r) = part {
837                *r = r.to_public();
838            }
839        }
840        self
841    }
842
843    /// Override the generated message id.
844    ///
845    /// Streaming producers use this to allocate a public id before emitting
846    /// `output.message.started`, then reuse it on the completed message.
847    pub fn with_id(mut self, id: MessageId) -> Self {
848        self.id = id;
849        self
850    }
851
852    /// Create a new user message
853    pub fn user(content: impl Into<String>) -> Self {
854        Self {
855            id: MessageId::new(),
856            role: MessageRole::User,
857            content: vec![ContentPart::text(content)],
858            phase: None,
859            phase_source: None,
860            controls: None,
861            metadata: None,
862            external_actor: None,
863            created_at: Utc::now(),
864        }
865    }
866
867    /// Create a new assistant message
868    pub fn assistant(content: impl Into<String>) -> Self {
869        Self {
870            id: MessageId::new(),
871            role: MessageRole::Agent,
872            content: vec![ContentPart::text(content)],
873            phase: None,
874            phase_source: None,
875            controls: None,
876            metadata: None,
877            external_actor: None,
878            created_at: Utc::now(),
879        }
880    }
881
882    /// Create a new assistant message with tool calls
883    ///
884    /// Tool calls are stored as ContentPart::ToolCall in the content array
885    /// alongside the text content. Empty text content is omitted to avoid
886    /// LLM API errors (e.g., Anthropic requires non-empty text blocks).
887    pub fn assistant_with_tools(
888        content: impl Into<String>,
889        tool_calls: Vec<crate::tool_types::ToolCall>,
890    ) -> Self {
891        let text_content = content.into();
892        let mut parts = Vec::new();
893        // Only include text part if non-empty
894        if !text_content.is_empty() {
895            parts.push(ContentPart::text(text_content));
896        }
897        for tc in tool_calls {
898            parts.push(ContentPart::ToolCall(ToolCallContentPart {
899                native: None,
900                id: tc.id,
901                name: tc.name,
902                arguments: tc.arguments,
903            }));
904        }
905        Self {
906            id: MessageId::new(),
907            role: MessageRole::Agent,
908            content: parts,
909            phase: None,
910            phase_source: None,
911            controls: None,
912            metadata: None,
913            external_actor: None,
914            created_at: Utc::now(),
915        }
916    }
917
918    /// Create a new system message
919    pub fn system(content: impl Into<String>) -> Self {
920        Self {
921            id: MessageId::new(),
922            role: MessageRole::System,
923            content: vec![ContentPart::text(content)],
924            phase: None,
925            phase_source: None,
926            controls: None,
927            metadata: None,
928            external_actor: None,
929            created_at: Utc::now(),
930        }
931    }
932
933    /// Create a tool result message
934    pub fn tool_result(
935        tool_call_id: impl Into<String>,
936        result: Option<serde_json::Value>,
937        error: Option<String>,
938    ) -> Self {
939        let tool_call_id = tool_call_id.into();
940        Self {
941            id: MessageId::new(),
942            role: MessageRole::ToolResult,
943            content: vec![ContentPart::ToolResult(ToolResultContentPart::new(
944                tool_call_id,
945                result,
946                error,
947            ))],
948            phase: None,
949            phase_source: None,
950            controls: None,
951            metadata: None,
952            external_actor: None,
953            created_at: Utc::now(),
954        }
955    }
956
957    /// Create a tool result message with images.
958    ///
959    /// Images are included as `ContentPart::Image` alongside the `ToolResult` part.
960    /// When converted to `LlmMessage`, images become native image content blocks
961    /// that the LLM can see visually (not just stringified base64).
962    pub fn tool_result_with_images(
963        tool_call_id: impl Into<String>,
964        result: Option<serde_json::Value>,
965        images: Vec<everruns_provider::tool_types::ToolResultImage>,
966    ) -> Self {
967        let tool_call_id = tool_call_id.into();
968        let mut content = vec![ContentPart::ToolResult(ToolResultContentPart::new(
969            tool_call_id,
970            result,
971            None,
972        ))];
973        for img in images {
974            content.push(ContentPart::Image(ImageContentPart::from_base64(
975                img.base64,
976                img.media_type,
977            )));
978        }
979        Self {
980            id: MessageId::new(),
981            role: MessageRole::ToolResult,
982            content,
983            phase: None,
984            phase_source: None,
985            controls: None,
986            metadata: None,
987            external_actor: None,
988            created_at: Utc::now(),
989        }
990    }
991
992    /// Set the execution phase on this message and return self.
993    pub fn with_phase(mut self, phase: ExecutionPhase) -> Self {
994        self.phase = Some(phase);
995        self
996    }
997
998    /// Set the phase together with where it came from.
999    pub fn with_phase_from(mut self, phase: ExecutionPhase, source: PhaseSource) -> Self {
1000        self.phase = Some(phase);
1001        self.phase_source = Some(source);
1002        self
1003    }
1004
1005    /// Get the tool_call_id from a tool result message
1006    ///
1007    /// Returns the tool_call_id from the first ToolResult content part, if any.
1008    pub fn tool_call_id(&self) -> Option<&str> {
1009        self.content.iter().find_map(|p| match p {
1010            ContentPart::ToolResult(tr) => Some(tr.tool_call_id.as_str()),
1011            _ => None,
1012        })
1013    }
1014
1015    /// Get first text content from the message
1016    pub fn text(&self) -> Option<&str> {
1017        self.content.iter().find_map(|p| p.as_text())
1018    }
1019
1020    /// Get all tool calls from the message content
1021    pub fn tool_calls(&self) -> Vec<&ToolCallContentPart> {
1022        self.content
1023            .iter()
1024            .filter_map(|p| match p {
1025                ContentPart::ToolCall(tc) => Some(tc),
1026                _ => None,
1027            })
1028            .collect()
1029    }
1030
1031    /// Check if this message has tool calls
1032    pub fn has_tool_calls(&self) -> bool {
1033        self.content
1034            .iter()
1035            .any(|p| matches!(p, ContentPart::ToolCall(_)))
1036    }
1037
1038    /// Get the first tool result from the message content
1039    pub fn tool_result_content(&self) -> Option<&ToolResultContentPart> {
1040        self.content.iter().find_map(|p| match p {
1041            ContentPart::ToolResult(tr) => Some(tr),
1042            _ => None,
1043        })
1044    }
1045
1046    /// Convert content to LLM-compatible string representation
1047    pub fn content_to_llm_string(&self) -> String {
1048        self.content
1049            .iter()
1050            .map(|part| match part {
1051                ContentPart::Text(t) => t.text.clone(),
1052                // Reasoning is replayed as provider-native artifacts on
1053                // `LlmMessage::reasoning`; it must never be flattened into
1054                // prompt text. Filtered out below.
1055                ContentPart::Reasoning(_) => String::new(),
1056                ContentPart::Image(_) => "[Image]".to_string(),
1057                ContentPart::ImageFile(_) => "[Image File]".to_string(),
1058                ContentPart::File(part) => part
1059                    .filename
1060                    .clone()
1061                    .map(|n| format!("[PDF File: {}]", n))
1062                    .unwrap_or_else(|| "[PDF File]".to_string()),
1063                ContentPart::ToolCall(tc) => {
1064                    format!(
1065                        "Tool call: {} with arguments: {}",
1066                        tc.name,
1067                        serde_json::to_string(&tc.arguments).unwrap_or_default()
1068                    )
1069                }
1070                ContentPart::ToolResult(tr) => {
1071                    if let Some(err) = &tr.error {
1072                        format!("Tool error: {}", err)
1073                    } else if let Some(res) = &tr.result {
1074                        serde_json::to_string(res).unwrap_or_else(|_| "{}".to_string())
1075                    } else {
1076                        "{}".to_string()
1077                    }
1078                }
1079            })
1080            .filter(|rendered| !rendered.is_empty())
1081            .collect::<Vec<_>>()
1082            .join("\n")
1083    }
1084
1085    /// Convert message to OpenAI-compatible format
1086    ///
1087    /// Transforms internal message format to OpenAI API format:
1088    /// - `agent` role → `assistant`
1089    /// - `tool_result` role → `tool` (with tool_call_id at message level)
1090    /// - Tool calls formatted as `{id, type: "function", function: {name, arguments}}`
1091    ///
1092    /// Used by observability backends (e.g., Braintrust) that expect OpenAI format.
1093    pub fn to_openai_format(&self) -> serde_json::Value {
1094        let role = match self.role {
1095            MessageRole::System => "system",
1096            MessageRole::User => "user",
1097            MessageRole::Agent => "assistant",
1098            MessageRole::ToolResult => "tool",
1099        };
1100
1101        // Handle tool result messages (need tool_call_id at message level)
1102        if self.role == MessageRole::ToolResult {
1103            let tool_call_id = self.tool_call_id().unwrap_or("");
1104            let content = self
1105                .content
1106                .iter()
1107                .find_map(|p| match p {
1108                    ContentPart::ToolResult(tr) => {
1109                        if let Some(error) = &tr.error {
1110                            Some(format!("Error: {}", error))
1111                        } else if let Some(result) = &tr.result {
1112                            Some(serde_json::to_string(result).unwrap_or_else(|_| "{}".to_string()))
1113                        } else {
1114                            Some("{}".to_string())
1115                        }
1116                    }
1117                    _ => None,
1118                })
1119                .unwrap_or_else(|| "{}".to_string());
1120
1121            return serde_json::json!({
1122                "role": role,
1123                "content": content,
1124                "tool_call_id": tool_call_id
1125            });
1126        }
1127
1128        // Handle assistant messages with tool calls
1129        if self.role == MessageRole::Agent {
1130            let tool_calls: Vec<serde_json::Value> = self
1131                .content
1132                .iter()
1133                .filter_map(|p| match p {
1134                    ContentPart::ToolCall(tc) => Some(serde_json::json!({
1135                        "id": tc.id,
1136                        "type": "function",
1137                        "function": {
1138                            "name": tc.name,
1139                            "arguments": serde_json::to_string(&tc.arguments).unwrap_or_else(|_| "{}".to_string())
1140                        }
1141                    })),
1142                    _ => None,
1143                })
1144                .collect();
1145
1146            let text_content: String = self
1147                .content
1148                .iter()
1149                .filter_map(|p| match p {
1150                    ContentPart::Text(t) => Some(t.text.clone()),
1151                    _ => None,
1152                })
1153                .collect::<Vec<_>>()
1154                .join("\n");
1155
1156            if tool_calls.is_empty() {
1157                return serde_json::json!({
1158                    "role": role,
1159                    "content": text_content
1160                });
1161            } else {
1162                let mut result = serde_json::json!({
1163                    "role": role,
1164                    "tool_calls": tool_calls
1165                });
1166                if !text_content.is_empty() {
1167                    result["content"] = serde_json::json!(text_content);
1168                }
1169                return result;
1170            }
1171        }
1172
1173        // For system/user messages, convert content parts
1174        let content = self.content_to_openai_format();
1175        serde_json::json!({
1176            "role": role,
1177            "content": content
1178        })
1179    }
1180
1181    /// Convert content parts to OpenAI-compatible format
1182    fn content_to_openai_format(&self) -> serde_json::Value {
1183        // Single text content → string
1184        if self.content.len() == 1
1185            && let ContentPart::Text(t) = &self.content[0]
1186        {
1187            return serde_json::json!(t.text);
1188        }
1189
1190        // Convert each content part
1191        let parts: Vec<serde_json::Value> = self
1192            .content
1193            .iter()
1194            .filter_map(|part| part.to_openai_format())
1195            .collect();
1196
1197        if parts.is_empty() {
1198            return serde_json::json!("");
1199        }
1200
1201        // Single text part after filtering → string
1202        if parts.len() == 1
1203            && let Some(text) = parts[0].get("text")
1204        {
1205            return text.clone();
1206        }
1207
1208        serde_json::json!(parts)
1209    }
1210}
1211
1212/// Patch dangling tool calls by adding synthetic "cancelled" results.
1213///
1214/// This ensures every tool call has a corresponding tool result,
1215/// preventing LLM API errors (e.g., OpenAI requires every tool_call to have a result).
1216///
1217/// This is the simple, store-free patcher used by out-of-band completions
1218/// (see `crate::command_host`). The main reason path uses the durable-store-aware
1219/// the execution kernel's transcript-repair path instead (EVE-533),
1220/// which can replay settled results rather than synthesizing cancellations.
1221pub fn patch_dangling_tool_calls(messages: &[Message]) -> Vec<Message> {
1222    let mut result = Vec::new();
1223
1224    for (i, msg) in messages.iter().enumerate() {
1225        result.push(msg.clone());
1226
1227        // After an assistant message with tool calls, add cancelled results for any missing ones
1228        if msg.role == MessageRole::Agent && msg.has_tool_calls() {
1229            for tc in msg.tool_calls() {
1230                // Look for a matching tool result in ALL subsequent messages
1231                let has_result = messages[(i + 1)..]
1232                    .iter()
1233                    .any(|m| m.role == MessageRole::ToolResult && m.tool_call_id() == Some(&tc.id));
1234
1235                if !has_result {
1236                    result.push(Message::tool_result(
1237                        &tc.id,
1238                        None,
1239                        Some(
1240                            "cancelled - another message came in before it could be completed"
1241                                .to_string(),
1242                        ),
1243                    ));
1244                }
1245            }
1246        }
1247    }
1248
1249    result
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254    use super::*;
1255    use crate::tool_types::ToolCall;
1256    use serde_json::json;
1257
1258    fn calls() -> Vec<ToolCall> {
1259        vec![
1260            ToolCall {
1261                id: "call_search".into(),
1262                name: "search".into(),
1263                arguments: json!({"q": "rust"}),
1264            },
1265            ToolCall {
1266                id: "call_fetch".into(),
1267                name: "fetch".into(),
1268                arguments: json!({"url": "https://example.com"}),
1269            },
1270        ]
1271    }
1272
1273    fn assert_messages(actual: &[Message], expected: &[Message]) {
1274        assert_eq!(
1275            serde_json::to_value(actual).unwrap(),
1276            serde_json::to_value(expected).unwrap()
1277        );
1278    }
1279
1280    #[test]
1281    fn native_custom_call_survives_transcript_serialization_and_conversion() {
1282        let native = everruns_provider::native_async::NativeToolCall::Custom {
1283            call_id: "original-call".into(),
1284            name: "lookup".into(),
1285            input: "raw\nquery: \"value\"".into(),
1286            asynchronous: true,
1287        };
1288        let mut message = Message::assistant("");
1289        message.content.push(ContentPart::ToolCall(
1290            ToolCallContentPart::from_native(native.clone()).unwrap(),
1291        ));
1292        let restored: Message =
1293            serde_json::from_slice(&serde_json::to_vec(&message).unwrap()).unwrap();
1294        assert_eq!(restored.tool_calls()[0].native.as_ref(), Some(&native));
1295        let llm = crate::llm_conversions::llm_message_from_message(&restored);
1296        assert_eq!(llm.native_tool_calls, vec![native]);
1297        assert_eq!(llm.tool_calls.unwrap()[0].id, "original-call");
1298    }
1299
1300    #[test]
1301    fn settled_transcripts_are_preserved_without_synthetic_results() {
1302        for messages in [
1303            vec![],
1304            vec![Message::user("Hello"), Message::assistant("Hi")],
1305            vec![
1306                Message::assistant_with_tools("Searching", vec![calls()[0].clone()]),
1307                Message::tool_result("call_search", Some(json!({"found": 2})), None),
1308            ],
1309        ] {
1310            assert_messages(&patch_dangling_tool_calls(&messages), &messages);
1311        }
1312    }
1313
1314    #[test]
1315    fn dangling_calls_get_only_missing_cancellations_and_patching_is_idempotent() {
1316        let messages = vec![
1317            Message::user("Search then fetch"),
1318            Message::assistant_with_tools("Working", calls()),
1319            Message::user("Never mind"),
1320            Message::tool_result("call_search", Some(json!({"found": 2})), None),
1321        ];
1322        let patched = patch_dangling_tool_calls(&messages);
1323        assert_eq!(patched.len(), 5);
1324        assert_messages(&patched[..2], &messages[..2]);
1325        assert_messages(&patched[3..], &messages[2..]);
1326        assert_eq!(patched[2].role, MessageRole::ToolResult);
1327        assert_eq!(
1328            serde_json::to_value(&patched[2].content).unwrap(),
1329            json!([{
1330                "type": "tool_result", "tool_call_id": "call_fetch",
1331                "error": "cancelled - another message came in before it could be completed"
1332            }])
1333        );
1334        assert_messages(&patch_dangling_tool_calls(&patched), &patched);
1335    }
1336
1337    #[test]
1338    fn plain_message_constructors_preserve_role_and_text() {
1339        for (message, role, text) in [
1340            (Message::user("question"), MessageRole::User, "question"),
1341            (Message::assistant("answer"), MessageRole::Agent, "answer"),
1342            (
1343                Message::system("instruction"),
1344                MessageRole::System,
1345                "instruction",
1346            ),
1347        ] {
1348            assert_eq!(message.role, role);
1349            assert_eq!(message.text(), Some(text));
1350            assert_eq!(message.content, vec![ContentPart::text(text)]);
1351            assert!(!message.has_tool_calls());
1352        }
1353    }
1354
1355    #[test]
1356    fn tool_result_constructor_preserves_result_and_error_fields() {
1357        for (result, error) in [
1358            (Some(json!({"count": 2})), None),
1359            (None, Some("timeout".to_owned())),
1360            (Some(json!(false)), Some("partial".to_owned())),
1361        ] {
1362            let message = Message::tool_result("call_result", result.clone(), error.clone());
1363            assert_eq!(message.role, MessageRole::ToolResult);
1364            assert_eq!(message.tool_call_id(), Some("call_result"));
1365            assert_eq!(
1366                message.content,
1367                vec![ContentPart::tool_result("call_result", result, error)]
1368            );
1369        }
1370    }
1371
1372    #[test]
1373    fn assistant_tool_messages_preserve_calls_and_distinguish_empty_from_whitespace_text() {
1374        for text in ["", "   ", "Working"] {
1375            let message = Message::assistant_with_tools(text, calls());
1376            let tool_parts: Vec<_> = calls()
1377                .into_iter()
1378                .map(|c| ContentPart::tool_call(c.id, c.name, c.arguments))
1379                .collect();
1380            let mut expected = vec![];
1381            if !text.is_empty() {
1382                expected.push(ContentPart::text(text));
1383            }
1384            expected.extend(tool_parts);
1385            assert_eq!(message.role, MessageRole::Agent);
1386            assert_eq!(message.text(), (!text.is_empty()).then_some(text));
1387            assert_eq!(message.content, expected);
1388            assert!(message.has_tool_calls());
1389            assert_eq!(
1390                serde_json::to_value(message.tool_calls()).unwrap(),
1391                serde_json::to_value(calls()).unwrap()
1392            );
1393        }
1394    }
1395
1396    #[test]
1397    fn openai_plain_messages_map_internal_roles_and_preserve_text() {
1398        for (message, expected) in [
1399            (
1400                Message::user("question"),
1401                json!({"role": "user", "content": "question"}),
1402            ),
1403            (
1404                Message::system("instruction"),
1405                json!({"role": "system", "content": "instruction"}),
1406            ),
1407            (
1408                Message::assistant("answer"),
1409                json!({"role": "assistant", "content": "answer"}),
1410            ),
1411        ] {
1412            assert_eq!(message.to_openai_format(), expected);
1413        }
1414    }
1415
1416    #[test]
1417    fn openai_tool_calls_preserve_ids_arguments_and_optional_text() {
1418        for text in ["", "Working"] {
1419            let message = Message::assistant_with_tools(text, calls());
1420            let mut expected = json!({"role": "assistant", "tool_calls": [
1421                {"id": "call_search", "type": "function", "function": {"name": "search", "arguments": "{\"q\":\"rust\"}"}},
1422                {"id": "call_fetch", "type": "function", "function": {"name": "fetch", "arguments": "{\"url\":\"https://example.com\"}"}}
1423            ]});
1424            if !text.is_empty() {
1425                expected["content"] = text.into();
1426            }
1427            assert_eq!(message.to_openai_format(), expected);
1428        }
1429    }
1430
1431    #[test]
1432    fn openai_tool_results_prefer_errors_and_preserve_call_identity() {
1433        for (result, error, content) in [
1434            (
1435                Some(json!({"temperature":72})),
1436                None,
1437                "{\"temperature\":72}",
1438            ),
1439            (None, Some("timeout"), "Error: timeout"),
1440            (
1441                Some(json!({"partial":true})),
1442                Some("partial failure"),
1443                "Error: partial failure",
1444            ),
1445            (None, None, "{}"),
1446        ] {
1447            let message = Message::tool_result("call_result", result, error.map(str::to_owned));
1448            assert_eq!(
1449                message.to_openai_format(),
1450                json!({"role":"tool", "tool_call_id":"call_result", "content":content})
1451            );
1452        }
1453    }
1454
1455    #[test]
1456    fn openai_content_parts_preserve_text_and_image_sources() {
1457        for (part, expected) in [
1458            (
1459                ContentPart::text("Hello"),
1460                json!({"type":"text", "text":"Hello"}),
1461            ),
1462            (
1463                ContentPart::image_url("https://example.com/img.png"),
1464                json!({"type":"image_url", "image_url":{"url":"https://example.com/img.png"}}),
1465            ),
1466            (
1467                ContentPart::Image(ImageContentPart::from_base64("YWJj", "image/jpeg")),
1468                json!({"type":"image_url", "image_url":{"url":"data:image/jpeg;base64,YWJj"}}),
1469            ),
1470            (
1471                ContentPart::Image(ImageContentPart {
1472                    url: None,
1473                    base64: Some("YWJj".into()),
1474                    media_type: None,
1475                }),
1476                json!({"type":"image_url", "image_url":{"url":"data:image/png;base64,YWJj"}}),
1477            ),
1478            (
1479                ContentPart::Image(ImageContentPart {
1480                    url: Some("https://example.com/preferred".into()),
1481                    base64: Some("YWJj".into()),
1482                    media_type: Some("image/jpeg".into()),
1483                }),
1484                json!({"type":"image_url", "image_url":{"url":"https://example.com/preferred"}}),
1485            ),
1486        ] {
1487            assert_eq!(part.to_openai_format(), Some(expected));
1488        }
1489        assert!(
1490            ContentPart::Image(ImageContentPart {
1491                url: None,
1492                base64: None,
1493                media_type: None
1494            })
1495            .to_openai_format()
1496            .is_none()
1497        );
1498    }
1499
1500    #[test]
1501    fn openai_content_parts_exclude_tool_file_and_reasoning_artifacts() {
1502        for part in [
1503            ContentPart::tool_call("call_1", "lookup", json!({})),
1504            ContentPart::tool_result("call_1", Some(json!(42)), None),
1505            ContentPart::image_file(ImageId::new()),
1506            ContentPart::reasoning(
1507                ReasoningContentPart::opaque("test").with_signature("private-signature"),
1508            ),
1509        ] {
1510            assert!(part.to_openai_format().is_none());
1511        }
1512    }
1513
1514    #[test]
1515    fn openai_message_content_preserves_multimodal_order_and_filters_unsupported_parts() {
1516        let mut message = Message::user("before");
1517        message
1518            .content
1519            .push(ContentPart::image_url("https://example.com/image"));
1520        message.content.push(ContentPart::text("after"));
1521        assert_eq!(
1522            message.to_openai_format(),
1523            json!({"role":"user", "content":[
1524                {"type":"text", "text":"before"}, {"type":"image_url", "image_url":{"url":"https://example.com/image"}},
1525                {"type":"text", "text":"after"}
1526            ]})
1527        );
1528        message.content = vec![
1529            ContentPart::tool_call("ignored", "tool", json!({})),
1530            ContentPart::text("kept"),
1531        ];
1532        assert_eq!(
1533            message.to_openai_format(),
1534            json!({"role":"user", "content":"kept"})
1535        );
1536        message.content.remove(1);
1537        assert_eq!(
1538            message.to_openai_format(),
1539            json!({"role":"user", "content":""})
1540        );
1541        let mut assistant = Message::assistant("first");
1542        assistant.content.push(ContentPart::text("second"));
1543        assert_eq!(
1544            assistant.to_openai_format(),
1545            json!({"role":"assistant", "content":"first\nsecond"})
1546        );
1547    }
1548
1549    #[test]
1550    fn message_phase_wire_contract_preserves_optional_source() {
1551        for (phase, wire) in [
1552            (None, None),
1553            (Some(ExecutionPhase::Commentary), Some("commentary")),
1554            (Some(ExecutionPhase::FinalAnswer), Some("final_answer")),
1555        ] {
1556            for source in [
1557                None,
1558                Some(PhaseSource::Provider),
1559                Some(PhaseSource::Derived),
1560            ] {
1561                if phase.is_none() && source.is_some() {
1562                    continue;
1563                }
1564                let message = match (phase, source) {
1565                    (Some(phase), Some(source)) => {
1566                        Message::assistant("answer").with_phase_from(phase, source)
1567                    }
1568                    (Some(phase), None) => Message::assistant("answer").with_phase(phase),
1569                    _ => Message::assistant("answer"),
1570                };
1571                let json = serde_json::to_value(&message).unwrap();
1572                assert_eq!(
1573                    json.get("phase"),
1574                    wire.map(serde_json::Value::from).as_ref()
1575                );
1576                let source_wire = match source {
1577                    Some(PhaseSource::Provider) => Some("provider"),
1578                    Some(PhaseSource::Derived) => Some("derived"),
1579                    None => None,
1580                };
1581                assert_eq!(
1582                    json.get("phase_source"),
1583                    source_wire.map(serde_json::Value::from).as_ref()
1584                );
1585                let decoded: Message = serde_json::from_value(json.clone()).unwrap();
1586                assert_eq!(decoded.phase, phase);
1587                assert_eq!(decoded.phase_source, source);
1588                assert_eq!(decoded.text(), Some("answer"));
1589                assert_eq!(serde_json::to_value(decoded).unwrap(), json);
1590            }
1591        }
1592    }
1593
1594    #[test]
1595    fn hints_merge_shallowly_with_message_precedence() {
1596        let session = std::collections::HashMap::from([
1597            ("shared".into(), json!({"old":1})),
1598            ("session_only".into(), json!(42)),
1599        ]);
1600        let message = std::collections::HashMap::from([
1601            ("shared".into(), json!({"new":2})),
1602            ("message_only".into(), json!(null)),
1603        ]);
1604        for (left, right, expected) in [
1605            (None, None, json!({})),
1606            (
1607                Some(&session),
1608                None,
1609                json!({"shared":{"old":1},"session_only":42}),
1610            ),
1611            (
1612                None,
1613                Some(&message),
1614                json!({"shared":{"new":2},"message_only":null}),
1615            ),
1616            (
1617                Some(&session),
1618                Some(&message),
1619                json!({"shared":{"new":2},"session_only":42,"message_only":null}),
1620            ),
1621        ] {
1622            assert_eq!(
1623                serde_json::to_value(Controls::resolve_hints(left, right)).unwrap(),
1624                expected
1625            );
1626        }
1627    }
1628
1629    #[test]
1630    fn controls_wire_contract_preserves_all_overrides_and_legacy_defaults() {
1631        let expected = json!({"model_id":"model_00000000000000000000000000000006", "locale":"uk-UA",
1632            "reasoning":{"effort":"high"}, "speed":"priority", "verbosity":"low", "error_disclosure":"generic",
1633            "hints":{"setup_connection":true,"theme":"dark"}});
1634        let controls = Controls {
1635            model_id: Some(ModelId::from_uuid(uuid::Uuid::from_u128(6))),
1636            locale: Some("uk-UA".into()),
1637            reasoning: Some(ReasoningConfig {
1638                effort: Some(everruns_provider::model::ReasoningEffort::High),
1639            }),
1640            speed: Some("priority".into()),
1641            verbosity: Some("low".into()),
1642            error_disclosure: Some("generic".into()),
1643            hints: Some(std::collections::HashMap::from([
1644                ("setup_connection".into(), json!(true)),
1645                ("theme".into(), json!("dark")),
1646            ])),
1647        };
1648        assert_eq!(serde_json::to_value(&controls).unwrap(), expected);
1649        assert_eq!(
1650            serde_json::from_value::<Controls>(expected).unwrap(),
1651            controls
1652        );
1653        let legacy: Controls = serde_json::from_value(json!({})).unwrap();
1654        assert_eq!(serde_json::to_value(legacy).unwrap(), json!({}));
1655    }
1656
1657    #[test]
1658    fn tool_result_text_preserves_strings_without_json_escaping() {
1659        let value = serde_json::json!("{\n  \"count\": 1\n}");
1660        assert_eq!(
1661            ContentPart::tool_result_text(&value).as_text(),
1662            Some("{\n  \"count\": 1\n}")
1663        );
1664    }
1665
1666    #[test]
1667    fn tool_result_text_serializes_structured_values() {
1668        for (value, expected) in [
1669            (json!({"count":1}), "{\"count\":1}"),
1670            (json!([true, 2]), "[true,2]"),
1671            (json!(null), "null"),
1672        ] {
1673            assert_eq!(
1674                ContentPart::tool_result_text(&value).as_text(),
1675                Some(expected)
1676            );
1677        }
1678    }
1679    #[test]
1680    fn file_content_part_serde_roundtrip() {
1681        let part = ContentPart::File(FileContentPart::with_filename(FileId::new(), "report.pdf"));
1682        let v = serde_json::to_value(&part).unwrap();
1683        assert_eq!(v["type"], serde_json::json!("file"));
1684        assert_eq!(v["filename"], serde_json::json!("report.pdf"));
1685        let back: ContentPart = serde_json::from_value(v).unwrap();
1686        assert_eq!(back, part);
1687        assert!(back.is_file());
1688        assert_eq!(back.content_type(), ContentType::File);
1689        assert_eq!(ContentType::File.to_string(), "file");
1690    }
1691}