Skip to main content

everruns_core/
message.rs

1// Message types
2//
3// Message is a DB-agnostic message type that represents
4// a single message in the conversation history.
5//
6// Content is stored as Vec<ContentPart> for unified representation
7// across storage and runtime layers.
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12use crate::typed_id::{ImageId, MessageId, ModelId};
13
14#[cfg(feature = "openapi")]
15use utoipa::ToSchema;
16
17use everruns_provider::execution_phase::{ExecutionPhase, PhaseSource};
18use everruns_provider::reasoning::ReasoningContentPart;
19/// Message role in the conversation
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[cfg_attr(feature = "openapi", derive(ToSchema))]
22// Published as `RuntimeMessageRole`. The REST API exposes only `user` and
23// `agent` (`api::messages::MessageRole`); publishing this four-variant runtime
24// enum under the plain name made clients model roles the API never returns.
25#[cfg_attr(feature = "openapi", schema(as = RuntimeMessageRole))]
26#[serde(rename_all = "snake_case")]
27pub enum MessageRole {
28    /// System message (instructions)
29    System,
30    /// User message
31    User,
32    /// Agent response (may contain tool calls in content)
33    Agent,
34    /// Tool execution result
35    ToolResult,
36}
37
38impl std::fmt::Display for MessageRole {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            MessageRole::System => write!(f, "system"),
42            MessageRole::User => write!(f, "user"),
43            MessageRole::Agent => write!(f, "agent"),
44            MessageRole::ToolResult => write!(f, "tool_result"),
45        }
46    }
47}
48
49impl From<&str> for MessageRole {
50    fn from(s: &str) -> Self {
51        match s.to_lowercase().as_str() {
52            "system" => MessageRole::System,
53            "user" => MessageRole::User,
54            // Accept both "agent" and legacy "assistant"
55            "agent" | "assistant" => MessageRole::Agent,
56            "tool_result" => MessageRole::ToolResult,
57            _ => MessageRole::User,
58        }
59    }
60}
61
62// ============================================
63// External Actor (channel-agnostic user identity)
64// ============================================
65
66/// External actor identity for messages originating from external channels
67/// (Slack, Discord, Teams, etc.).
68///
69/// Channel adapters populate this to identify the sender without coupling
70/// core logic to any specific channel. The ReasonAtom uses this to prefix
71/// user messages so the LLM knows who is speaking.
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73#[cfg_attr(feature = "openapi", derive(ToSchema))]
74pub struct ExternalActor {
75    /// Opaque actor identifier from the source channel (e.g. Slack user ID "U0123456789")
76    pub actor_id: String,
77    /// Resolved display name (e.g. "Alice"). Falls back to actor_id if absent.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub actor_name: Option<String>,
80    /// Source channel identifier (e.g. "slack", "discord")
81    pub source: String,
82    /// Channel-specific metadata (e.g. team_id, channel_id)
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub metadata: Option<std::collections::HashMap<String, String>>,
85}
86
87impl ExternalActor {
88    /// Human-readable label: display name if available, otherwise actor_id.
89    pub fn display_label(&self) -> &str {
90        self.actor_name.as_deref().unwrap_or(&self.actor_id)
91    }
92}
93
94// ============================================
95// Controls (runtime options for message processing)
96// ============================================
97
98/// Reasoning configuration for the model
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100#[cfg_attr(feature = "openapi", derive(ToSchema))]
101pub struct ReasoningConfig {
102    /// Effort level for reasoning.
103    ///
104    /// Typed rather than free-form: the effort taxonomy is closed, and each
105    /// driver previously re-parsed the string with its own case handling, which
106    /// let `minimal` silently mean "no reasoning" on budget-based models.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub effort: Option<everruns_provider::model::ReasoningEffort>,
109}
110
111/// Runtime controls for message processing
112#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
113#[cfg_attr(feature = "openapi", derive(ToSchema))]
114pub struct Controls {
115    /// Model ID to use for this message (format: model_{32-hex}).
116    /// Overrides session and agent model settings.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "model_01933b5a00007000800000000000001"))]
119    pub model_id: Option<ModelId>,
120
121    /// Locale override for this message turn (BCP 47, e.g. `uk-UA`).
122    /// Overrides the session locale for backend-authored strings and prompts.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub locale: Option<String>,
125
126    /// Reasoning configuration
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub reasoning: Option<ReasoningConfig>,
129
130    /// Speed (service tier) for this message turn: "flex", "default", or
131    /// "priority". Only sent to providers whose model profile advertises a
132    /// speed config (OpenAI `service_tier`).
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub speed: Option<String>,
135
136    /// Verbosity for this message turn: "low", "medium", or "high". Only sent
137    /// to providers whose model profile advertises a verbosity config (OpenAI
138    /// `verbosity`).
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub verbosity: Option<String>,
141
142    /// Error disclosure override for this turn: "generic", "standard", or
143    /// "detailed". Clamped to at most the mode allowed by the agent's
144    /// `error_disclosure` capability (capability absent => "standard"), so a
145    /// client can narrow but never widen disclosure.
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub error_disclosure: Option<String>,
148
149    /// Generic client hints — arbitrary key-value pairs declared by the client.
150    /// Session-level defaults are set at session creation; per-message values
151    /// override session hints key-by-key (shallow merge).
152    ///
153    /// Examples: `{"setup_connection": true, "rich_media": true}`
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
156    pub hints: Option<std::collections::HashMap<String, serde_json::Value>>,
157}
158
159impl Controls {
160    /// Resolve effective hints by shallow-merging session-level defaults with
161    /// per-message overrides. Per-message hints take precedence key-by-key.
162    pub fn resolve_hints(
163        session_hints: Option<&std::collections::HashMap<String, serde_json::Value>>,
164        message_hints: Option<&std::collections::HashMap<String, serde_json::Value>>,
165    ) -> std::collections::HashMap<String, serde_json::Value> {
166        match (session_hints, message_hints) {
167            (None, None) => std::collections::HashMap::new(),
168            (Some(s), None) => s.clone(),
169            (None, Some(m)) => m.clone(),
170            (Some(s), Some(m)) => {
171                let mut merged = s.clone();
172                merged.extend(m.iter().map(|(k, v)| (k.clone(), v.clone())));
173                merged
174            }
175        }
176    }
177}
178
179/// A message in the conversation
180#[derive(Debug, Clone, Serialize, Deserialize)]
181#[cfg_attr(feature = "openapi", derive(ToSchema))]
182// Published as `RuntimeMessage`: this is the canonical runtime/event message,
183// distinct from the REST resource in `api::messages::Message` (which adds
184// `session_id` and `sequence`). Both previously claimed the name `Message` in
185// one OpenAPI document, so generated clients saw whichever won.
186#[cfg_attr(feature = "openapi", schema(as = RuntimeMessage))]
187pub struct Message {
188    /// Unique message ID (format: message_{32-hex})
189    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "message_01933b5a00007000800000000000001"))]
190    pub id: MessageId,
191
192    /// Message role
193    pub role: MessageRole,
194
195    /// Message content as array of content parts (text, images, tool calls, tool results)
196    pub content: Vec<ContentPart>,
197
198    /// Execution phase for this message.
199    ///
200    /// Helps LLMs distinguish between intermediate working commentary and completed
201    /// answers in multi-step tool-calling flows. Only set on agent (assistant) messages.
202    /// Providers with native phase support (OpenAI GPT-5.x) send this value in the API
203    /// request; others derive it from state but don't send it to the provider.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub phase: Option<ExecutionPhase>,
206
207    /// Whether [`Self::phase`] was reported by the provider or inferred from
208    /// tool-call presence. A derived phase carries no information beyond
209    /// "this message called tools", so consumers that need a real
210    /// classification must be able to tell the two apart.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub phase_source: Option<PhaseSource>,
213
214    /// Runtime controls (model, reasoning, etc.)
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub controls: Option<Controls>,
217
218    /// Message-level metadata
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
221    pub metadata: Option<std::collections::HashMap<String, serde_json::Value>>,
222
223    /// External actor identity (for messages from external channels like Slack)
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub external_actor: Option<ExternalActor>,
226
227    /// Timestamp when the message was created
228    pub created_at: DateTime<Utc>,
229}
230
231// ============================================
232// Content Type Enum
233// ============================================
234
235/// Content type discriminator
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
237#[cfg_attr(feature = "openapi", derive(ToSchema))]
238#[serde(rename_all = "snake_case")]
239pub enum ContentType {
240    Text,
241    Image,
242    ImageFile,
243    ToolCall,
244    ToolResult,
245    Reasoning,
246}
247
248impl std::fmt::Display for ContentType {
249    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250        match self {
251            ContentType::Text => write!(f, "text"),
252            ContentType::Image => write!(f, "image"),
253            ContentType::ImageFile => write!(f, "image_file"),
254            ContentType::ToolCall => write!(f, "tool_call"),
255            ContentType::ToolResult => write!(f, "tool_result"),
256            ContentType::Reasoning => write!(f, "reasoning"),
257        }
258    }
259}
260
261impl From<&str> for ContentType {
262    fn from(s: &str) -> Self {
263        match s {
264            "image" => ContentType::Image,
265            "image_file" => ContentType::ImageFile,
266            "tool_call" => ContentType::ToolCall,
267            "tool_result" => ContentType::ToolResult,
268            "reasoning" => ContentType::Reasoning,
269            _ => ContentType::Text,
270        }
271    }
272}
273
274// ============================================
275// Content Part Structs
276// ============================================
277
278/// Text content part
279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
280#[cfg_attr(feature = "openapi", derive(ToSchema))]
281pub struct TextContentPart {
282    pub text: String,
283    /// Claim-level citations attached to spans of `text`.
284    ///
285    /// The narrow render contract shared by all citation capabilities (see
286    /// `knowledge/runtime-resources/citations.md`). Empty for non-cited text, so the wire shape of
287    /// existing messages is unchanged.
288    #[serde(default, skip_serializing_if = "Vec::is_empty")]
289    pub annotations: Vec<TextAnnotation>,
290}
291
292impl TextContentPart {
293    pub fn new(text: impl Into<String>) -> Self {
294        Self {
295            text: text.into(),
296            annotations: Vec::new(),
297        }
298    }
299
300    /// Attach citation annotations, replacing any existing ones.
301    pub fn with_annotations(mut self, annotations: Vec<TextAnnotation>) -> Self {
302        self.annotations = annotations;
303        self
304    }
305}
306
307/// A claim-level citation attached to a span of generated text.
308///
309/// The single shared type across every citation capability: a text span linked
310/// to a source. Producers agree only on this render contract — each capability
311/// keeps its own richer representation (e.g. `KnowledgeIndexCitation`) and maps
312/// into this envelope at emit time. See `knowledge/runtime-resources/citations.md`.
313#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
314#[cfg_attr(feature = "openapi", derive(ToSchema))]
315pub struct TextAnnotation {
316    /// 0-indexed start char offset into the enclosing `TextContentPart.text`.
317    #[cfg_attr(feature = "openapi", schema(example = 0))]
318    pub start: usize,
319    /// Exclusive end char offset.
320    #[cfg_attr(feature = "openapi", schema(example = 19))]
321    pub end: usize,
322    /// Capability id that produced this annotation (e.g. `citation_retrieval`).
323    /// Lets the UI and evals attribute and filter each citation by feed.
324    #[cfg_attr(feature = "openapi", schema(example = "citation_retrieval"))]
325    pub origin: String,
326    /// The cited source.
327    pub source: AnnotationSource,
328    /// Opaque producer id (e.g. `kchk_…`, `kbe_…`, a URL hash). Not interpreted
329    /// by the render contract.
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    #[cfg_attr(feature = "openapi", schema(example = "kchk_01j9y3q8w2"))]
332    pub external_id: Option<String>,
333    /// Verification verdict, filled by the `citation_verification` capability.
334    /// Absent means unverified (not "unsupported").
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub verified: Option<VerificationVerdict>,
337}
338
339/// The source a [`TextAnnotation`] points to.
340#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
341#[cfg_attr(feature = "openapi", derive(ToSchema))]
342pub struct AnnotationSource {
343    /// Stable, linkable locator (e.g. `github://owner/repo@main/docs/x.md` or an
344    /// `https://` URL).
345    #[cfg_attr(
346        feature = "openapi",
347        schema(example = "github://owner/repo@main/docs/x.md")
348    )]
349    pub uri: String,
350    /// Human-readable source title, when known.
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    #[cfg_attr(feature = "openapi", schema(example = "Architecture Overview"))]
353    pub title: Option<String>,
354    /// Trimmed passage that backs the claim. Display-only; never relied on for
355    /// prompt reconstruction.
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    #[cfg_attr(
358        feature = "openapi",
359        schema(example = "The control plane owns durable state.")
360    )]
361    pub snippet: Option<String>,
362    /// Provenance within the document (line / char / page / block ranges),
363    /// reusing the retrieval `location` JSONB shape.
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub location: Option<serde_json::Value>,
366}
367
368/// Outcome of citation verification (see the `citation_verification` capability).
369#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
370#[cfg_attr(feature = "openapi", derive(ToSchema))]
371pub struct VerificationVerdict {
372    /// Whether the cited source supports the claim.
373    pub status: VerificationStatus,
374    /// Entailment confidence in `[0, 1]`, when the verifier produced one.
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    #[cfg_attr(feature = "openapi", schema(example = 0.92))]
377    pub score: Option<f32>,
378}
379
380/// Whether a cited source entails the claim it is attached to.
381#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
382#[cfg_attr(feature = "openapi", derive(ToSchema))]
383#[cfg_attr(feature = "openapi", schema(example = "entailed"))]
384#[serde(rename_all = "snake_case")]
385pub enum VerificationStatus {
386    /// The source supports the claim.
387    Entailed,
388    /// The source does not support the claim.
389    Unsupported,
390    /// The verifier could not decide.
391    Uncertain,
392}
393
394/// Image content part (base64 or URL)
395#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
396#[cfg_attr(feature = "openapi", derive(ToSchema))]
397pub struct ImageContentPart {
398    #[serde(skip_serializing_if = "Option::is_none")]
399    pub url: Option<String>,
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub base64: Option<String>,
402    #[serde(skip_serializing_if = "Option::is_none")]
403    pub media_type: Option<String>,
404}
405
406impl ImageContentPart {
407    pub fn from_url(url: impl Into<String>) -> Self {
408        Self {
409            url: Some(url.into()),
410            base64: None,
411            media_type: None,
412        }
413    }
414
415    pub fn from_base64(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
416        Self {
417            url: None,
418            base64: Some(base64.into()),
419            media_type: Some(media_type.into()),
420        }
421    }
422}
423
424/// Image file content part (reference to uploaded image)
425///
426/// This is used for images uploaded via the /images API.
427/// The image data is stored separately and referenced by ID.
428/// Note: Currently filtered out before sending to LLM.
429#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
430#[cfg_attr(feature = "openapi", derive(ToSchema))]
431pub struct ImageFileContentPart {
432    /// ID of the uploaded image (format: img_{32-hex})
433    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "img_01933b5a00007000800000000000001"))]
434    pub image_id: ImageId,
435    /// Original filename (for display)
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub filename: Option<String>,
438}
439
440impl ImageFileContentPart {
441    pub fn new(image_id: ImageId) -> Self {
442        Self {
443            image_id,
444            filename: None,
445        }
446    }
447
448    pub fn with_filename(image_id: ImageId, filename: impl Into<String>) -> Self {
449        Self {
450            image_id,
451            filename: Some(filename.into()),
452        }
453    }
454}
455
456/// Tool call content part (assistant requesting tool execution)
457#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
458#[cfg_attr(feature = "openapi", derive(ToSchema))]
459pub struct ToolCallContentPart {
460    pub id: String,
461    pub name: String,
462    pub arguments: serde_json::Value,
463}
464
465impl ToolCallContentPart {
466    pub fn new(
467        id: impl Into<String>,
468        name: impl Into<String>,
469        arguments: serde_json::Value,
470    ) -> Self {
471        Self {
472            id: id.into(),
473            name: name.into(),
474            arguments,
475        }
476    }
477}
478
479/// Tool result content part (result of tool execution)
480#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
481#[cfg_attr(feature = "openapi", derive(ToSchema))]
482pub struct ToolResultContentPart {
483    /// ID of the tool call this result corresponds to
484    pub tool_call_id: String,
485    #[serde(skip_serializing_if = "Option::is_none")]
486    pub result: Option<serde_json::Value>,
487    #[serde(skip_serializing_if = "Option::is_none")]
488    pub error: Option<String>,
489}
490
491impl ToolResultContentPart {
492    pub fn new(
493        tool_call_id: impl Into<String>,
494        result: Option<serde_json::Value>,
495        error: Option<String>,
496    ) -> Self {
497        Self {
498            tool_call_id: tool_call_id.into(),
499            result,
500            error,
501        }
502    }
503
504    pub fn success(tool_call_id: impl Into<String>, result: serde_json::Value) -> Self {
505        Self {
506            tool_call_id: tool_call_id.into(),
507            result: Some(result),
508            error: None,
509        }
510    }
511
512    pub fn error(tool_call_id: impl Into<String>, error: impl Into<String>) -> Self {
513        Self {
514            tool_call_id: tool_call_id.into(),
515            result: None,
516            error: Some(error.into()),
517        }
518    }
519}
520
521// ============================================
522// Content Part Enums
523// ============================================
524
525/// A part of message content - can be text, image, image_file, tool_call, or tool_result
526///
527/// This is the canonical content part type used across the system.
528/// API layer enables the "openapi" feature to add ToSchema derive.
529#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
530#[cfg_attr(feature = "openapi", derive(ToSchema))]
531#[serde(tag = "type", rename_all = "snake_case")]
532pub enum ContentPart {
533    /// Text content
534    Text(TextContentPart),
535    /// Image content (base64 or URL)
536    Image(ImageContentPart),
537    /// Image file content (reference to uploaded image by ID)
538    ImageFile(ImageFileContentPart),
539    /// Tool call content (assistant requesting tool execution)
540    ToolCall(ToolCallContentPart),
541    /// Tool result content (result of tool execution)
542    ToolResult(ToolResultContentPart),
543    /// Provider reasoning artifact, ordered against the text and tool calls it
544    /// was interleaved with.
545    Reasoning(ReasoningContentPart),
546}
547
548impl ContentPart {
549    /// Create a text content part
550    pub fn text(text: impl Into<String>) -> Self {
551        ContentPart::Text(TextContentPart::new(text))
552    }
553
554    /// Convert a JSON tool result into text without JSON-quoting string values.
555    /// Structured values retain their JSON representation for transport and details views.
556    pub fn tool_result_text(value: &serde_json::Value) -> Self {
557        match value {
558            serde_json::Value::String(text) => Self::text(text.clone()),
559            other => Self::text(other.to_string()),
560        }
561    }
562
563    /// Create an image content part from URL
564    pub fn image_url(url: impl Into<String>) -> Self {
565        ContentPart::Image(ImageContentPart::from_url(url))
566    }
567
568    /// Create an image file content part (reference to uploaded image)
569    pub fn image_file(image_id: ImageId) -> Self {
570        ContentPart::ImageFile(ImageFileContentPart::new(image_id))
571    }
572
573    /// Create a tool call content part
574    pub fn tool_call(
575        id: impl Into<String>,
576        name: impl Into<String>,
577        arguments: serde_json::Value,
578    ) -> Self {
579        ContentPart::ToolCall(ToolCallContentPart::new(id, name, arguments))
580    }
581
582    /// Create a tool result content part
583    pub fn tool_result(
584        tool_call_id: impl Into<String>,
585        result: Option<serde_json::Value>,
586        error: Option<String>,
587    ) -> Self {
588        ContentPart::ToolResult(ToolResultContentPart::new(tool_call_id, result, error))
589    }
590
591    /// Create a reasoning content part
592    pub fn reasoning(part: ReasoningContentPart) -> Self {
593        ContentPart::Reasoning(part)
594    }
595
596    /// Get the reasoning artifact if this is a reasoning part
597    pub fn as_reasoning(&self) -> Option<&ReasoningContentPart> {
598        match self {
599            ContentPart::Reasoning(r) => Some(r),
600            _ => None,
601        }
602    }
603
604    /// Whether this part is a reasoning artifact.
605    pub fn is_reasoning(&self) -> bool {
606        matches!(self, ContentPart::Reasoning(_))
607    }
608
609    /// Get text if this is a text part
610    pub fn as_text(&self) -> Option<&str> {
611        match self {
612            ContentPart::Text(t) => Some(&t.text),
613            _ => None,
614        }
615    }
616
617    /// Check if this is an ImageFile part
618    pub fn is_image_file(&self) -> bool {
619        matches!(self, ContentPart::ImageFile(_))
620    }
621
622    /// Get the content type
623    pub fn content_type(&self) -> ContentType {
624        match self {
625            ContentPart::Text(_) => ContentType::Text,
626            ContentPart::Image(_) => ContentType::Image,
627            ContentPart::ImageFile(_) => ContentType::ImageFile,
628            ContentPart::ToolCall(_) => ContentType::ToolCall,
629            ContentPart::ToolResult(_) => ContentType::ToolResult,
630            ContentPart::Reasoning(_) => ContentType::Reasoning,
631        }
632    }
633
634    /// Convert content part to OpenAI-compatible format
635    ///
636    /// Returns `None` for content types that aren't valid in user/system messages
637    /// (ImageFile, ToolCall, ToolResult are handled at message level).
638    pub fn to_openai_format(&self) -> Option<serde_json::Value> {
639        match self {
640            ContentPart::Text(t) => Some(serde_json::json!({
641                "type": "text",
642                "text": t.text
643            })),
644            ContentPart::Image(img) => {
645                if let Some(url) = &img.url {
646                    Some(serde_json::json!({
647                        "type": "image_url",
648                        "image_url": { "url": url }
649                    }))
650                } else if let Some(b64) = &img.base64 {
651                    let media_type = img.media_type.as_deref().unwrap_or("image/png");
652                    Some(serde_json::json!({
653                        "type": "image_url",
654                        "image_url": { "url": format!("data:{};base64,{}", media_type, b64) }
655                    }))
656                } else {
657                    None
658                }
659            }
660            // ImageFile, ToolCall, ToolResult handled at message level
661            _ => None,
662        }
663    }
664}
665
666/// Input content part - text, image, and image_file (for user input)
667///
668/// This is a subset of ContentPart that users can send.
669/// Tool calls and results are system-generated.
670#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
671#[cfg_attr(feature = "openapi", derive(ToSchema))]
672#[serde(tag = "type", rename_all = "snake_case")]
673pub enum InputContentPart {
674    /// Text content
675    Text(TextContentPart),
676    /// Image content (base64 or URL)
677    Image(ImageContentPart),
678    /// Image file content (reference to uploaded image by ID)
679    ImageFile(ImageFileContentPart),
680}
681
682impl From<InputContentPart> for ContentPart {
683    fn from(input: InputContentPart) -> Self {
684        match input {
685            InputContentPart::Text(t) => ContentPart::Text(t),
686            InputContentPart::Image(i) => ContentPart::Image(i),
687            InputContentPart::ImageFile(f) => ContentPart::ImageFile(f),
688        }
689    }
690}
691
692impl InputContentPart {
693    /// Create a text content part
694    pub fn text(text: impl Into<String>) -> Self {
695        InputContentPart::Text(TextContentPart::new(text))
696    }
697
698    /// Create an image content part from URL
699    pub fn image_url(url: impl Into<String>) -> Self {
700        InputContentPart::Image(ImageContentPart::from_url(url))
701    }
702
703    /// Create an image file content part (reference to uploaded image)
704    pub fn image_file(image_id: ImageId) -> Self {
705        InputContentPart::ImageFile(ImageFileContentPart::new(image_id))
706    }
707
708    /// Get text content if this is a Text part
709    pub fn as_text(&self) -> Option<&str> {
710        match self {
711            InputContentPart::Text(t) => Some(&t.text),
712            _ => None,
713        }
714    }
715
716    /// Get the content type
717    pub fn content_type(&self) -> ContentType {
718        match self {
719            InputContentPart::Text(_) => ContentType::Text,
720            InputContentPart::Image(_) => ContentType::Image,
721            InputContentPart::ImageFile(_) => ContentType::ImageFile,
722        }
723    }
724}
725
726impl Message {
727    /// Reasoning artifacts carried by this message, in emission order.
728    pub fn reasoning_parts(&self) -> impl Iterator<Item = &ReasoningContentPart> {
729        self.content.iter().filter_map(ContentPart::as_reasoning)
730    }
731
732    /// Whether this message carries any provider reasoning artifact.
733    pub fn has_reasoning(&self) -> bool {
734        self.content.iter().any(ContentPart::is_reasoning)
735    }
736
737    /// Readable reasoning across every artifact, joined for display.
738    ///
739    /// Display only. Replay must walk [`Message::reasoning_parts`] so each
740    /// artifact keeps its own signature and position.
741    pub fn reasoning_display_text(&self) -> Option<String> {
742        let joined = self
743            .reasoning_parts()
744            .filter_map(ReasoningContentPart::display_text)
745            .collect::<Vec<_>>()
746            .join("\n\n");
747        (!joined.is_empty()).then_some(joined)
748    }
749
750    /// Replace every reasoning part with its publishable projection, dropping
751    /// opaque provider replay state. Used at API boundaries.
752    pub fn into_public(mut self) -> Self {
753        for part in &mut self.content {
754            if let ContentPart::Reasoning(r) = part {
755                *r = r.to_public();
756            }
757        }
758        self
759    }
760
761    /// Override the generated message id.
762    ///
763    /// Streaming producers use this to allocate a public id before emitting
764    /// `output.message.started`, then reuse it on the completed message.
765    pub fn with_id(mut self, id: MessageId) -> Self {
766        self.id = id;
767        self
768    }
769
770    /// Create a new user message
771    pub fn user(content: impl Into<String>) -> Self {
772        Self {
773            id: MessageId::new(),
774            role: MessageRole::User,
775            content: vec![ContentPart::text(content)],
776            phase: None,
777            phase_source: None,
778            controls: None,
779            metadata: None,
780            external_actor: None,
781            created_at: Utc::now(),
782        }
783    }
784
785    /// Create a new assistant message
786    pub fn assistant(content: impl Into<String>) -> Self {
787        Self {
788            id: MessageId::new(),
789            role: MessageRole::Agent,
790            content: vec![ContentPart::text(content)],
791            phase: None,
792            phase_source: None,
793            controls: None,
794            metadata: None,
795            external_actor: None,
796            created_at: Utc::now(),
797        }
798    }
799
800    /// Create a new assistant message with tool calls
801    ///
802    /// Tool calls are stored as ContentPart::ToolCall in the content array
803    /// alongside the text content. Empty text content is omitted to avoid
804    /// LLM API errors (e.g., Anthropic requires non-empty text blocks).
805    pub fn assistant_with_tools(
806        content: impl Into<String>,
807        tool_calls: Vec<crate::tool_types::ToolCall>,
808    ) -> Self {
809        let text_content = content.into();
810        let mut parts = Vec::new();
811        // Only include text part if non-empty
812        if !text_content.is_empty() {
813            parts.push(ContentPart::text(text_content));
814        }
815        for tc in tool_calls {
816            parts.push(ContentPart::ToolCall(ToolCallContentPart {
817                id: tc.id,
818                name: tc.name,
819                arguments: tc.arguments,
820            }));
821        }
822        Self {
823            id: MessageId::new(),
824            role: MessageRole::Agent,
825            content: parts,
826            phase: None,
827            phase_source: None,
828            controls: None,
829            metadata: None,
830            external_actor: None,
831            created_at: Utc::now(),
832        }
833    }
834
835    /// Create a new system message
836    pub fn system(content: impl Into<String>) -> Self {
837        Self {
838            id: MessageId::new(),
839            role: MessageRole::System,
840            content: vec![ContentPart::text(content)],
841            phase: None,
842            phase_source: None,
843            controls: None,
844            metadata: None,
845            external_actor: None,
846            created_at: Utc::now(),
847        }
848    }
849
850    /// Create a tool result message
851    pub fn tool_result(
852        tool_call_id: impl Into<String>,
853        result: Option<serde_json::Value>,
854        error: Option<String>,
855    ) -> Self {
856        let tool_call_id = tool_call_id.into();
857        Self {
858            id: MessageId::new(),
859            role: MessageRole::ToolResult,
860            content: vec![ContentPart::ToolResult(ToolResultContentPart::new(
861                tool_call_id,
862                result,
863                error,
864            ))],
865            phase: None,
866            phase_source: None,
867            controls: None,
868            metadata: None,
869            external_actor: None,
870            created_at: Utc::now(),
871        }
872    }
873
874    /// Create a tool result message with images.
875    ///
876    /// Images are included as `ContentPart::Image` alongside the `ToolResult` part.
877    /// When converted to `LlmMessage`, images become native image content blocks
878    /// that the LLM can see visually (not just stringified base64).
879    pub fn tool_result_with_images(
880        tool_call_id: impl Into<String>,
881        result: Option<serde_json::Value>,
882        images: Vec<everruns_provider::tool_types::ToolResultImage>,
883    ) -> Self {
884        let tool_call_id = tool_call_id.into();
885        let mut content = vec![ContentPart::ToolResult(ToolResultContentPart::new(
886            tool_call_id,
887            result,
888            None,
889        ))];
890        for img in images {
891            content.push(ContentPart::Image(ImageContentPart::from_base64(
892                img.base64,
893                img.media_type,
894            )));
895        }
896        Self {
897            id: MessageId::new(),
898            role: MessageRole::ToolResult,
899            content,
900            phase: None,
901            phase_source: None,
902            controls: None,
903            metadata: None,
904            external_actor: None,
905            created_at: Utc::now(),
906        }
907    }
908
909    /// Set the execution phase on this message and return self.
910    pub fn with_phase(mut self, phase: ExecutionPhase) -> Self {
911        self.phase = Some(phase);
912        self
913    }
914
915    /// Set the phase together with where it came from.
916    pub fn with_phase_from(mut self, phase: ExecutionPhase, source: PhaseSource) -> Self {
917        self.phase = Some(phase);
918        self.phase_source = Some(source);
919        self
920    }
921
922    /// Get the tool_call_id from a tool result message
923    ///
924    /// Returns the tool_call_id from the first ToolResult content part, if any.
925    pub fn tool_call_id(&self) -> Option<&str> {
926        self.content.iter().find_map(|p| match p {
927            ContentPart::ToolResult(tr) => Some(tr.tool_call_id.as_str()),
928            _ => None,
929        })
930    }
931
932    /// Get first text content from the message
933    pub fn text(&self) -> Option<&str> {
934        self.content.iter().find_map(|p| p.as_text())
935    }
936
937    /// Get all tool calls from the message content
938    pub fn tool_calls(&self) -> Vec<&ToolCallContentPart> {
939        self.content
940            .iter()
941            .filter_map(|p| match p {
942                ContentPart::ToolCall(tc) => Some(tc),
943                _ => None,
944            })
945            .collect()
946    }
947
948    /// Check if this message has tool calls
949    pub fn has_tool_calls(&self) -> bool {
950        self.content
951            .iter()
952            .any(|p| matches!(p, ContentPart::ToolCall(_)))
953    }
954
955    /// Get the first tool result from the message content
956    pub fn tool_result_content(&self) -> Option<&ToolResultContentPart> {
957        self.content.iter().find_map(|p| match p {
958            ContentPart::ToolResult(tr) => Some(tr),
959            _ => None,
960        })
961    }
962
963    /// Convert content to LLM-compatible string representation
964    pub fn content_to_llm_string(&self) -> String {
965        self.content
966            .iter()
967            .map(|part| match part {
968                ContentPart::Text(t) => t.text.clone(),
969                // Reasoning is replayed as provider-native artifacts on
970                // `LlmMessage::reasoning`; it must never be flattened into
971                // prompt text. Filtered out below.
972                ContentPart::Reasoning(_) => String::new(),
973                ContentPart::Image(_) => "[Image]".to_string(),
974                ContentPart::ImageFile(_) => "[Image File]".to_string(),
975                ContentPart::ToolCall(tc) => {
976                    format!(
977                        "Tool call: {} with arguments: {}",
978                        tc.name,
979                        serde_json::to_string(&tc.arguments).unwrap_or_default()
980                    )
981                }
982                ContentPart::ToolResult(tr) => {
983                    if let Some(err) = &tr.error {
984                        format!("Tool error: {}", err)
985                    } else if let Some(res) = &tr.result {
986                        serde_json::to_string(res).unwrap_or_else(|_| "{}".to_string())
987                    } else {
988                        "{}".to_string()
989                    }
990                }
991            })
992            .filter(|rendered| !rendered.is_empty())
993            .collect::<Vec<_>>()
994            .join("\n")
995    }
996
997    /// Convert message to OpenAI-compatible format
998    ///
999    /// Transforms internal message format to OpenAI API format:
1000    /// - `agent` role → `assistant`
1001    /// - `tool_result` role → `tool` (with tool_call_id at message level)
1002    /// - Tool calls formatted as `{id, type: "function", function: {name, arguments}}`
1003    ///
1004    /// Used by observability backends (e.g., Braintrust) that expect OpenAI format.
1005    pub fn to_openai_format(&self) -> serde_json::Value {
1006        let role = match self.role {
1007            MessageRole::System => "system",
1008            MessageRole::User => "user",
1009            MessageRole::Agent => "assistant",
1010            MessageRole::ToolResult => "tool",
1011        };
1012
1013        // Handle tool result messages (need tool_call_id at message level)
1014        if self.role == MessageRole::ToolResult {
1015            let tool_call_id = self.tool_call_id().unwrap_or("");
1016            let content = self
1017                .content
1018                .iter()
1019                .find_map(|p| match p {
1020                    ContentPart::ToolResult(tr) => {
1021                        if let Some(error) = &tr.error {
1022                            Some(format!("Error: {}", error))
1023                        } else if let Some(result) = &tr.result {
1024                            Some(serde_json::to_string(result).unwrap_or_else(|_| "{}".to_string()))
1025                        } else {
1026                            Some("{}".to_string())
1027                        }
1028                    }
1029                    _ => None,
1030                })
1031                .unwrap_or_else(|| "{}".to_string());
1032
1033            return serde_json::json!({
1034                "role": role,
1035                "content": content,
1036                "tool_call_id": tool_call_id
1037            });
1038        }
1039
1040        // Handle assistant messages with tool calls
1041        if self.role == MessageRole::Agent {
1042            let tool_calls: Vec<serde_json::Value> = self
1043                .content
1044                .iter()
1045                .filter_map(|p| match p {
1046                    ContentPart::ToolCall(tc) => Some(serde_json::json!({
1047                        "id": tc.id,
1048                        "type": "function",
1049                        "function": {
1050                            "name": tc.name,
1051                            "arguments": serde_json::to_string(&tc.arguments).unwrap_or_else(|_| "{}".to_string())
1052                        }
1053                    })),
1054                    _ => None,
1055                })
1056                .collect();
1057
1058            let text_content: String = self
1059                .content
1060                .iter()
1061                .filter_map(|p| match p {
1062                    ContentPart::Text(t) => Some(t.text.clone()),
1063                    _ => None,
1064                })
1065                .collect::<Vec<_>>()
1066                .join("\n");
1067
1068            if tool_calls.is_empty() {
1069                return serde_json::json!({
1070                    "role": role,
1071                    "content": text_content
1072                });
1073            } else {
1074                let mut result = serde_json::json!({
1075                    "role": role,
1076                    "tool_calls": tool_calls
1077                });
1078                if !text_content.is_empty() {
1079                    result["content"] = serde_json::json!(text_content);
1080                }
1081                return result;
1082            }
1083        }
1084
1085        // For system/user messages, convert content parts
1086        let content = self.content_to_openai_format();
1087        serde_json::json!({
1088            "role": role,
1089            "content": content
1090        })
1091    }
1092
1093    /// Convert content parts to OpenAI-compatible format
1094    fn content_to_openai_format(&self) -> serde_json::Value {
1095        // Single text content → string
1096        if self.content.len() == 1
1097            && let ContentPart::Text(t) = &self.content[0]
1098        {
1099            return serde_json::json!(t.text);
1100        }
1101
1102        // Convert each content part
1103        let parts: Vec<serde_json::Value> = self
1104            .content
1105            .iter()
1106            .filter_map(|part| part.to_openai_format())
1107            .collect();
1108
1109        if parts.is_empty() {
1110            return serde_json::json!("");
1111        }
1112
1113        // Single text part after filtering → string
1114        if parts.len() == 1
1115            && let Some(text) = parts[0].get("text")
1116        {
1117            return text.clone();
1118        }
1119
1120        serde_json::json!(parts)
1121    }
1122}
1123
1124/// Patch dangling tool calls by adding synthetic "cancelled" results.
1125///
1126/// This ensures every tool call has a corresponding tool result,
1127/// preventing LLM API errors (e.g., OpenAI requires every tool_call to have a result).
1128///
1129/// This is the simple, store-free patcher used by out-of-band completions
1130/// (see `crate::command_host`). The main reason path uses the durable-store-aware
1131/// the execution kernel's transcript-repair path instead (EVE-533),
1132/// which can replay settled results rather than synthesizing cancellations.
1133pub fn patch_dangling_tool_calls(messages: &[Message]) -> Vec<Message> {
1134    let mut result = Vec::new();
1135
1136    for (i, msg) in messages.iter().enumerate() {
1137        result.push(msg.clone());
1138
1139        // After an assistant message with tool calls, add cancelled results for any missing ones
1140        if msg.role == MessageRole::Agent && msg.has_tool_calls() {
1141            for tc in msg.tool_calls() {
1142                // Look for a matching tool result in ALL subsequent messages
1143                let has_result = messages[(i + 1)..]
1144                    .iter()
1145                    .any(|m| m.role == MessageRole::ToolResult && m.tool_call_id() == Some(&tc.id));
1146
1147                if !has_result {
1148                    result.push(Message::tool_result(
1149                        &tc.id,
1150                        None,
1151                        Some(
1152                            "cancelled - another message came in before it could be completed"
1153                                .to_string(),
1154                        ),
1155                    ));
1156                }
1157            }
1158        }
1159    }
1160
1161    result
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166    use super::*;
1167    use crate::tool_types::ToolCall;
1168
1169    #[test]
1170    fn test_patch_dangling_tool_calls_no_tool_calls() {
1171        let messages = vec![Message::user("Hello"), Message::assistant("Hi there!")];
1172        let patched = patch_dangling_tool_calls(&messages);
1173        assert_eq!(patched.len(), 2);
1174    }
1175
1176    #[test]
1177    fn test_patch_dangling_tool_calls_with_result() {
1178        let tool_call = ToolCall {
1179            id: "call_123".to_string(),
1180            name: "get_weather".to_string(),
1181            arguments: serde_json::json!({"city": "NYC"}),
1182        };
1183
1184        let messages = vec![
1185            Message::user("What's the weather?"),
1186            Message::assistant_with_tools("Let me check", vec![tool_call]),
1187            Message::tool_result("call_123", Some(serde_json::json!({"temp": 72})), None),
1188        ];
1189
1190        let patched = patch_dangling_tool_calls(&messages);
1191        assert_eq!(patched.len(), 3);
1192    }
1193
1194    #[test]
1195    fn test_patch_dangling_tool_calls_missing_result() {
1196        let tool_call = ToolCall {
1197            id: "call_456".to_string(),
1198            name: "search_web".to_string(),
1199            arguments: serde_json::json!({"query": "rust"}),
1200        };
1201
1202        let messages = vec![
1203            Message::user("Search for rust"),
1204            Message::assistant_with_tools("Searching...", vec![tool_call]),
1205            Message::user("Actually, never mind"),
1206        ];
1207
1208        let patched = patch_dangling_tool_calls(&messages);
1209        // Should have added a cancelled result
1210        assert_eq!(patched.len(), 4);
1211        assert_eq!(patched[2].role, MessageRole::ToolResult);
1212        assert_eq!(patched[2].tool_call_id(), Some("call_456"));
1213    }
1214
1215    #[test]
1216    fn test_user_message() {
1217        let msg = Message::user("Hello");
1218        assert_eq!(msg.role, MessageRole::User);
1219        assert_eq!(msg.text(), Some("Hello"));
1220    }
1221
1222    #[test]
1223    fn test_assistant_message() {
1224        let msg = Message::assistant("Hi there!");
1225        assert_eq!(msg.role, MessageRole::Agent);
1226        assert_eq!(msg.text(), Some("Hi there!"));
1227    }
1228
1229    #[test]
1230    fn test_tool_result_message() {
1231        let msg = Message::tool_result(
1232            "call_123",
1233            Some(serde_json::json!({"result": "success"})),
1234            None,
1235        );
1236        assert_eq!(msg.role, MessageRole::ToolResult);
1237        assert_eq!(msg.tool_call_id(), Some("call_123"));
1238    }
1239
1240    #[test]
1241    fn test_assistant_with_tools_and_text() {
1242        let tool_call = ToolCall {
1243            id: "call_123".to_string(),
1244            name: "get_weather".to_string(),
1245            arguments: serde_json::json!({"location": "Tokyo"}),
1246        };
1247        let msg = Message::assistant_with_tools("Let me check the weather.", vec![tool_call]);
1248
1249        assert_eq!(msg.role, MessageRole::Agent);
1250        assert_eq!(msg.text(), Some("Let me check the weather."));
1251        assert_eq!(msg.tool_calls().len(), 1);
1252        assert_eq!(msg.tool_calls()[0].name, "get_weather");
1253    }
1254
1255    #[test]
1256    fn test_assistant_with_tools_empty_text() {
1257        // When LLM returns only tool calls without text, we shouldn't include an empty text block
1258        // This is important for Anthropic API which rejects empty text content blocks
1259        let tool_call = ToolCall {
1260            id: "call_123".to_string(),
1261            name: "search".to_string(),
1262            arguments: serde_json::json!({"query": "rust"}),
1263        };
1264        let msg = Message::assistant_with_tools("", vec![tool_call]);
1265
1266        assert_eq!(msg.role, MessageRole::Agent);
1267        // Empty text should result in None, not Some("")
1268        assert_eq!(msg.text(), None);
1269        // But tool calls should still be present
1270        assert_eq!(msg.tool_calls().len(), 1);
1271        assert_eq!(msg.tool_calls()[0].name, "search");
1272        // Content should only have tool_call, no empty text part
1273        assert_eq!(msg.content.len(), 1);
1274        assert!(matches!(msg.content[0], ContentPart::ToolCall(_)));
1275    }
1276
1277    #[test]
1278    fn test_assistant_with_tools_whitespace_text() {
1279        // Whitespace-only text is not empty (could be intentional)
1280        let tool_call = ToolCall {
1281            id: "call_456".to_string(),
1282            name: "fetch".to_string(),
1283            arguments: serde_json::json!({}),
1284        };
1285        let msg = Message::assistant_with_tools("   ", vec![tool_call]);
1286
1287        // Whitespace text is preserved (not treated as empty)
1288        assert_eq!(msg.text(), Some("   "));
1289        assert_eq!(msg.content.len(), 2); // Text + ToolCall
1290    }
1291
1292    #[test]
1293    fn test_assistant_with_multiple_tool_calls() {
1294        let tool_calls = vec![
1295            ToolCall {
1296                id: "call_1".to_string(),
1297                name: "search".to_string(),
1298                arguments: serde_json::json!({"q": "a"}),
1299            },
1300            ToolCall {
1301                id: "call_2".to_string(),
1302                name: "fetch".to_string(),
1303                arguments: serde_json::json!({"url": "http://example.com"}),
1304            },
1305        ];
1306        let msg = Message::assistant_with_tools("", tool_calls);
1307
1308        assert_eq!(msg.tool_calls().len(), 2);
1309        // Only tool calls, no empty text
1310        assert_eq!(msg.content.len(), 2);
1311    }
1312
1313    // =========================================================================
1314    // OpenAI Format Conversion Tests
1315    // =========================================================================
1316
1317    #[test]
1318    fn test_to_openai_format_user_message() {
1319        let msg = Message::user("Hello, world!");
1320        let converted = msg.to_openai_format();
1321
1322        assert_eq!(converted["role"], "user");
1323        assert_eq!(converted["content"], "Hello, world!");
1324    }
1325
1326    #[test]
1327    fn test_to_openai_format_system_message() {
1328        let msg = Message::system("You are a helpful assistant.");
1329        let converted = msg.to_openai_format();
1330
1331        assert_eq!(converted["role"], "system");
1332        assert_eq!(converted["content"], "You are a helpful assistant.");
1333    }
1334
1335    #[test]
1336    fn test_to_openai_format_assistant_role_mapping() {
1337        // Internal "agent" role → "assistant"
1338        let msg = Message::assistant("Hi there!");
1339        let converted = msg.to_openai_format();
1340
1341        assert_eq!(converted["role"], "assistant");
1342        assert_eq!(converted["content"], "Hi there!");
1343    }
1344
1345    #[test]
1346    fn test_to_openai_format_assistant_with_tool_calls() {
1347        let tool_call = ToolCall {
1348            id: "call_123".to_string(),
1349            name: "get_weather".to_string(),
1350            arguments: serde_json::json!({"location": "Tokyo"}),
1351        };
1352        let msg = Message::assistant_with_tools("Let me check.", vec![tool_call]);
1353        let converted = msg.to_openai_format();
1354
1355        assert_eq!(converted["role"], "assistant");
1356        assert_eq!(converted["content"], "Let me check.");
1357
1358        let tool_calls = converted["tool_calls"].as_array().unwrap();
1359        assert_eq!(tool_calls.len(), 1);
1360        assert_eq!(tool_calls[0]["id"], "call_123");
1361        assert_eq!(tool_calls[0]["type"], "function");
1362        assert_eq!(tool_calls[0]["function"]["name"], "get_weather");
1363        assert_eq!(
1364            tool_calls[0]["function"]["arguments"],
1365            r#"{"location":"Tokyo"}"#
1366        );
1367    }
1368
1369    #[test]
1370    fn test_to_openai_format_assistant_tool_calls_only() {
1371        // Assistant message with only tool calls (no text)
1372        let tool_call = ToolCall {
1373            id: "call_abc".to_string(),
1374            name: "search".to_string(),
1375            arguments: serde_json::json!({"query": "rust"}),
1376        };
1377        let msg = Message::assistant_with_tools("", vec![tool_call]);
1378        let converted = msg.to_openai_format();
1379
1380        assert_eq!(converted["role"], "assistant");
1381        // No content field when text is empty
1382        assert!(converted.get("content").is_none());
1383        assert!(converted["tool_calls"].is_array());
1384    }
1385
1386    #[test]
1387    fn test_to_openai_format_tool_result_role_mapping() {
1388        // Internal "tool_result" role → "tool"
1389        let msg = Message::tool_result(
1390            "call_123",
1391            Some(serde_json::json!({"temperature": 72})),
1392            None,
1393        );
1394        let converted = msg.to_openai_format();
1395
1396        assert_eq!(converted["role"], "tool");
1397        assert_eq!(converted["tool_call_id"], "call_123");
1398        assert_eq!(converted["content"], r#"{"temperature":72}"#);
1399    }
1400
1401    #[test]
1402    fn test_to_openai_format_tool_result_error() {
1403        let msg = Message::tool_result("call_456", None, Some("API timeout".to_string()));
1404        let converted = msg.to_openai_format();
1405
1406        assert_eq!(converted["role"], "tool");
1407        assert_eq!(converted["tool_call_id"], "call_456");
1408        assert_eq!(converted["content"], "Error: API timeout");
1409    }
1410
1411    #[test]
1412    fn test_to_openai_format_full_conversation() {
1413        // Full conversation: user → assistant (tool call) → tool result → assistant
1414        let tool_call = ToolCall {
1415            id: "call_abc".to_string(),
1416            name: "search".to_string(),
1417            arguments: serde_json::json!({"query": "rust"}),
1418        };
1419
1420        let messages = [
1421            Message::user("Search for rust"),
1422            Message::assistant_with_tools("", vec![tool_call]),
1423            Message::tool_result(
1424                "call_abc",
1425                Some(serde_json::json!({"results": ["rust-lang.org"]})),
1426                None,
1427            ),
1428            Message::assistant("Here are the search results."),
1429        ];
1430        let converted: Vec<_> = messages.iter().map(|m| m.to_openai_format()).collect();
1431
1432        assert_eq!(converted.len(), 4);
1433        assert_eq!(converted[0]["role"], "user");
1434        assert_eq!(converted[1]["role"], "assistant");
1435        assert!(converted[1]["tool_calls"].is_array());
1436        assert_eq!(converted[2]["role"], "tool");
1437        assert_eq!(converted[2]["tool_call_id"], "call_abc");
1438        assert_eq!(converted[3]["role"], "assistant");
1439    }
1440
1441    // =========================================================================
1442    // ContentPart::to_openai_format Tests
1443    // =========================================================================
1444
1445    #[test]
1446    fn test_content_part_to_openai_format_text() {
1447        let part = ContentPart::text("Hello");
1448        let converted = part.to_openai_format().unwrap();
1449
1450        assert_eq!(converted["type"], "text");
1451        assert_eq!(converted["text"], "Hello");
1452    }
1453
1454    #[test]
1455    fn test_content_part_to_openai_format_image_url() {
1456        let part = ContentPart::image_url("https://example.com/img.png");
1457        let converted = part.to_openai_format().unwrap();
1458
1459        assert_eq!(converted["type"], "image_url");
1460        assert_eq!(converted["image_url"]["url"], "https://example.com/img.png");
1461    }
1462
1463    #[test]
1464    fn test_content_part_to_openai_format_image_base64() {
1465        let part = ContentPart::Image(ImageContentPart::from_base64("abc123", "image/jpeg"));
1466        let converted = part.to_openai_format().unwrap();
1467
1468        assert_eq!(converted["type"], "image_url");
1469        assert_eq!(
1470            converted["image_url"]["url"],
1471            "data:image/jpeg;base64,abc123"
1472        );
1473    }
1474
1475    #[test]
1476    fn test_content_part_to_openai_format_tool_call_returns_none() {
1477        // ToolCall parts are handled at message level, not content part level
1478        let part = ContentPart::tool_call("call_1", "search", serde_json::json!({}));
1479        assert!(part.to_openai_format().is_none());
1480    }
1481
1482    #[test]
1483    fn test_content_part_to_openai_format_tool_result_returns_none() {
1484        // ToolResult parts are handled at message level
1485        let part = ContentPart::tool_result("call_1", Some(serde_json::json!({})), None);
1486        assert!(part.to_openai_format().is_none());
1487    }
1488
1489    #[test]
1490    fn test_execution_phase_from_has_tool_calls() {
1491        assert_eq!(
1492            ExecutionPhase::from_has_tool_calls(true),
1493            ExecutionPhase::Commentary
1494        );
1495        assert_eq!(
1496            ExecutionPhase::from_has_tool_calls(false),
1497            ExecutionPhase::FinalAnswer
1498        );
1499    }
1500
1501    #[test]
1502    fn test_execution_phase_refine_streamed_hint_monotonic() {
1503        use ExecutionPhase::{Commentary, FinalAnswer};
1504        // None advances to the first observed value.
1505        assert_eq!(
1506            ExecutionPhase::refine_streamed_hint(None, Commentary),
1507            Some(Commentary)
1508        );
1509        assert_eq!(
1510            ExecutionPhase::refine_streamed_hint(None, FinalAnswer),
1511            Some(FinalAnswer)
1512        );
1513        // First classification wins: a later hint never flips it...
1514        assert_eq!(
1515            ExecutionPhase::refine_streamed_hint(Some(Commentary), FinalAnswer),
1516            Some(Commentary)
1517        );
1518        assert_eq!(
1519            ExecutionPhase::refine_streamed_hint(Some(FinalAnswer), Commentary),
1520            Some(FinalAnswer)
1521        );
1522        // ...and never reverts to None (the input is never None-valued, but a
1523        // repeated identical hint is a no-op).
1524        assert_eq!(
1525            ExecutionPhase::refine_streamed_hint(Some(Commentary), Commentary),
1526            Some(Commentary)
1527        );
1528    }
1529
1530    #[test]
1531    fn test_execution_phase_serde_roundtrip() {
1532        let commentary = ExecutionPhase::Commentary;
1533        let json = serde_json::to_string(&commentary).unwrap();
1534        assert_eq!(json, "\"commentary\"");
1535        let deserialized: ExecutionPhase = serde_json::from_str(&json).unwrap();
1536        assert_eq!(deserialized, ExecutionPhase::Commentary);
1537
1538        let final_answer = ExecutionPhase::FinalAnswer;
1539        let json = serde_json::to_string(&final_answer).unwrap();
1540        assert_eq!(json, "\"final_answer\"");
1541        let deserialized: ExecutionPhase = serde_json::from_str(&json).unwrap();
1542        assert_eq!(deserialized, ExecutionPhase::FinalAnswer);
1543    }
1544
1545    #[test]
1546    fn test_execution_phase_deserialize_legacy() {
1547        let legacy_in_progress: ExecutionPhase = serde_json::from_str("\"in_progress\"").unwrap();
1548        assert_eq!(legacy_in_progress, ExecutionPhase::Commentary);
1549
1550        let legacy_completed: ExecutionPhase = serde_json::from_str("\"completed\"").unwrap();
1551        assert_eq!(legacy_completed, ExecutionPhase::FinalAnswer);
1552    }
1553
1554    #[test]
1555    fn test_execution_phase_deserialize_unknown_fails() {
1556        let result = serde_json::from_str::<ExecutionPhase>("\"bogus\"");
1557        assert!(result.is_err());
1558    }
1559
1560    #[test]
1561    fn test_message_with_phase() {
1562        let msg = Message::assistant("Hello").with_phase(ExecutionPhase::Commentary);
1563        assert_eq!(msg.phase, Some(ExecutionPhase::Commentary));
1564    }
1565
1566    #[test]
1567    fn test_message_phase_skipped_when_none() {
1568        let msg = Message::assistant("Hello");
1569        let json = serde_json::to_value(&msg).unwrap();
1570        assert!(json.get("phase").is_none());
1571    }
1572
1573    #[test]
1574    fn test_message_phase_included_when_set() {
1575        let msg = Message::assistant("Hello").with_phase(ExecutionPhase::FinalAnswer);
1576        let json = serde_json::to_value(&msg).unwrap();
1577        assert_eq!(json.get("phase").unwrap(), "final_answer");
1578    }
1579
1580    #[test]
1581    fn test_resolve_hints_both_none() {
1582        let result = Controls::resolve_hints(None, None);
1583        assert!(result.is_empty());
1584    }
1585
1586    #[test]
1587    fn test_resolve_hints_session_only() {
1588        let mut session = std::collections::HashMap::new();
1589        session.insert("key1".into(), serde_json::json!("val1"));
1590        session.insert("key2".into(), serde_json::json!(42));
1591
1592        let result = Controls::resolve_hints(Some(&session), None);
1593        assert_eq!(result.len(), 2);
1594        assert_eq!(result["key1"], serde_json::json!("val1"));
1595        assert_eq!(result["key2"], serde_json::json!(42));
1596    }
1597
1598    #[test]
1599    fn test_resolve_hints_message_only() {
1600        let mut message = std::collections::HashMap::new();
1601        message.insert("key1".into(), serde_json::json!(true));
1602
1603        let result = Controls::resolve_hints(None, Some(&message));
1604        assert_eq!(result.len(), 1);
1605        assert_eq!(result["key1"], serde_json::json!(true));
1606    }
1607
1608    #[test]
1609    fn test_resolve_hints_message_overrides_session() {
1610        let mut session = std::collections::HashMap::new();
1611        session.insert("shared".into(), serde_json::json!("session_val"));
1612        session.insert("session_only".into(), serde_json::json!(1));
1613
1614        let mut message = std::collections::HashMap::new();
1615        message.insert("shared".into(), serde_json::json!("message_val"));
1616        message.insert("message_only".into(), serde_json::json!(2));
1617
1618        let result = Controls::resolve_hints(Some(&session), Some(&message));
1619        assert_eq!(result.len(), 3);
1620        assert_eq!(result["shared"], serde_json::json!("message_val"));
1621        assert_eq!(result["session_only"], serde_json::json!(1));
1622        assert_eq!(result["message_only"], serde_json::json!(2));
1623    }
1624
1625    #[test]
1626    fn test_controls_hints_serde_roundtrip() {
1627        let mut hints = std::collections::HashMap::new();
1628        hints.insert("setup_connection".into(), serde_json::json!(true));
1629        hints.insert("theme".into(), serde_json::json!("dark"));
1630
1631        let controls = Controls {
1632            hints: Some(hints),
1633            ..Default::default()
1634        };
1635
1636        let json = serde_json::to_value(&controls).unwrap();
1637        let deserialized: Controls = serde_json::from_value(json).unwrap();
1638        let h = deserialized.hints.unwrap();
1639        assert_eq!(h["setup_connection"], serde_json::json!(true));
1640        assert_eq!(h["theme"], serde_json::json!("dark"));
1641    }
1642
1643    #[test]
1644    fn tool_result_text_preserves_strings_without_json_escaping() {
1645        let value = serde_json::json!("{\n  \"count\": 1\n}");
1646        assert_eq!(
1647            ContentPart::tool_result_text(&value).as_text(),
1648            Some("{\n  \"count\": 1\n}")
1649        );
1650    }
1651
1652    #[test]
1653    fn tool_result_text_serializes_structured_values() {
1654        let value = serde_json::json!({"count": 1});
1655        assert_eq!(
1656            ContentPart::tool_result_text(&value).as_text(),
1657            Some("{\"count\":1}")
1658        );
1659    }
1660}