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