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