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    /// `knowledge/runtime-resources/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 `knowledge/runtime-resources/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    /// Convert a JSON tool result into text without JSON-quoting string values.
541    /// Structured values retain their JSON representation for transport and details views.
542    pub fn tool_result_text(value: &serde_json::Value) -> Self {
543        match value {
544            serde_json::Value::String(text) => Self::text(text.clone()),
545            other => Self::text(other.to_string()),
546        }
547    }
548
549    /// Create an image content part from URL
550    pub fn image_url(url: impl Into<String>) -> Self {
551        ContentPart::Image(ImageContentPart::from_url(url))
552    }
553
554    /// Create an image file content part (reference to uploaded image)
555    pub fn image_file(image_id: ImageId) -> Self {
556        ContentPart::ImageFile(ImageFileContentPart::new(image_id))
557    }
558
559    /// Create a tool call content part
560    pub fn tool_call(
561        id: impl Into<String>,
562        name: impl Into<String>,
563        arguments: serde_json::Value,
564    ) -> Self {
565        ContentPart::ToolCall(ToolCallContentPart::new(id, name, arguments))
566    }
567
568    /// Create a tool result content part
569    pub fn tool_result(
570        tool_call_id: impl Into<String>,
571        result: Option<serde_json::Value>,
572        error: Option<String>,
573    ) -> Self {
574        ContentPart::ToolResult(ToolResultContentPart::new(tool_call_id, result, error))
575    }
576
577    /// Get text if this is a text part
578    pub fn as_text(&self) -> Option<&str> {
579        match self {
580            ContentPart::Text(t) => Some(&t.text),
581            _ => None,
582        }
583    }
584
585    /// Check if this is an ImageFile part
586    pub fn is_image_file(&self) -> bool {
587        matches!(self, ContentPart::ImageFile(_))
588    }
589
590    /// Get the content type
591    pub fn content_type(&self) -> ContentType {
592        match self {
593            ContentPart::Text(_) => ContentType::Text,
594            ContentPart::Image(_) => ContentType::Image,
595            ContentPart::ImageFile(_) => ContentType::ImageFile,
596            ContentPart::ToolCall(_) => ContentType::ToolCall,
597            ContentPart::ToolResult(_) => ContentType::ToolResult,
598        }
599    }
600
601    /// Convert content part to OpenAI-compatible format
602    ///
603    /// Returns `None` for content types that aren't valid in user/system messages
604    /// (ImageFile, ToolCall, ToolResult are handled at message level).
605    pub fn to_openai_format(&self) -> Option<serde_json::Value> {
606        match self {
607            ContentPart::Text(t) => Some(serde_json::json!({
608                "type": "text",
609                "text": t.text
610            })),
611            ContentPart::Image(img) => {
612                if let Some(url) = &img.url {
613                    Some(serde_json::json!({
614                        "type": "image_url",
615                        "image_url": { "url": url }
616                    }))
617                } else if let Some(b64) = &img.base64 {
618                    let media_type = img.media_type.as_deref().unwrap_or("image/png");
619                    Some(serde_json::json!({
620                        "type": "image_url",
621                        "image_url": { "url": format!("data:{};base64,{}", media_type, b64) }
622                    }))
623                } else {
624                    None
625                }
626            }
627            // ImageFile, ToolCall, ToolResult handled at message level
628            _ => None,
629        }
630    }
631}
632
633/// Input content part - text, image, and image_file (for user input)
634///
635/// This is a subset of ContentPart that users can send.
636/// Tool calls and results are system-generated.
637#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
638#[cfg_attr(feature = "openapi", derive(ToSchema))]
639#[serde(tag = "type", rename_all = "snake_case")]
640pub enum InputContentPart {
641    /// Text content
642    Text(TextContentPart),
643    /// Image content (base64 or URL)
644    Image(ImageContentPart),
645    /// Image file content (reference to uploaded image by ID)
646    ImageFile(ImageFileContentPart),
647}
648
649impl From<InputContentPart> for ContentPart {
650    fn from(input: InputContentPart) -> Self {
651        match input {
652            InputContentPart::Text(t) => ContentPart::Text(t),
653            InputContentPart::Image(i) => ContentPart::Image(i),
654            InputContentPart::ImageFile(f) => ContentPart::ImageFile(f),
655        }
656    }
657}
658
659impl InputContentPart {
660    /// Create a text content part
661    pub fn text(text: impl Into<String>) -> Self {
662        InputContentPart::Text(TextContentPart::new(text))
663    }
664
665    /// Create an image content part from URL
666    pub fn image_url(url: impl Into<String>) -> Self {
667        InputContentPart::Image(ImageContentPart::from_url(url))
668    }
669
670    /// Create an image file content part (reference to uploaded image)
671    pub fn image_file(image_id: ImageId) -> Self {
672        InputContentPart::ImageFile(ImageFileContentPart::new(image_id))
673    }
674
675    /// Get text content if this is a Text part
676    pub fn as_text(&self) -> Option<&str> {
677        match self {
678            InputContentPart::Text(t) => Some(&t.text),
679            _ => None,
680        }
681    }
682
683    /// Get the content type
684    pub fn content_type(&self) -> ContentType {
685        match self {
686            InputContentPart::Text(_) => ContentType::Text,
687            InputContentPart::Image(_) => ContentType::Image,
688            InputContentPart::ImageFile(_) => ContentType::ImageFile,
689        }
690    }
691}
692
693impl Message {
694    /// Override the generated message id.
695    ///
696    /// Streaming producers use this to allocate a public id before emitting
697    /// `output.message.started`, then reuse it on the completed message.
698    pub fn with_id(mut self, id: MessageId) -> Self {
699        self.id = id;
700        self
701    }
702
703    /// Create a new user message
704    pub fn user(content: impl Into<String>) -> Self {
705        Self {
706            id: MessageId::new(),
707            role: MessageRole::User,
708            content: vec![ContentPart::text(content)],
709            phase: None,
710            thinking: None,
711            thinking_signature: None,
712            controls: None,
713            metadata: None,
714            external_actor: None,
715            created_at: Utc::now(),
716        }
717    }
718
719    /// Create a new assistant message
720    pub fn assistant(content: impl Into<String>) -> Self {
721        Self {
722            id: MessageId::new(),
723            role: MessageRole::Agent,
724            content: vec![ContentPart::text(content)],
725            phase: None,
726            thinking: None,
727            thinking_signature: None,
728            controls: None,
729            metadata: None,
730            external_actor: None,
731            created_at: Utc::now(),
732        }
733    }
734
735    /// Create a new assistant message with tool calls
736    ///
737    /// Tool calls are stored as ContentPart::ToolCall in the content array
738    /// alongside the text content. Empty text content is omitted to avoid
739    /// LLM API errors (e.g., Anthropic requires non-empty text blocks).
740    pub fn assistant_with_tools(
741        content: impl Into<String>,
742        tool_calls: Vec<crate::tool_types::ToolCall>,
743    ) -> Self {
744        let text_content = content.into();
745        let mut parts = Vec::new();
746        // Only include text part if non-empty
747        if !text_content.is_empty() {
748            parts.push(ContentPart::text(text_content));
749        }
750        for tc in tool_calls {
751            parts.push(ContentPart::ToolCall(ToolCallContentPart {
752                id: tc.id,
753                name: tc.name,
754                arguments: tc.arguments,
755            }));
756        }
757        Self {
758            id: MessageId::new(),
759            role: MessageRole::Agent,
760            content: parts,
761            phase: None,
762            thinking: None,
763            thinking_signature: None,
764            controls: None,
765            metadata: None,
766            external_actor: None,
767            created_at: Utc::now(),
768        }
769    }
770
771    /// Create a new system message
772    pub fn system(content: impl Into<String>) -> Self {
773        Self {
774            id: MessageId::new(),
775            role: MessageRole::System,
776            content: vec![ContentPart::text(content)],
777            phase: None,
778            thinking: None,
779            thinking_signature: None,
780            controls: None,
781            metadata: None,
782            external_actor: None,
783            created_at: Utc::now(),
784        }
785    }
786
787    /// Create a tool result message
788    pub fn tool_result(
789        tool_call_id: impl Into<String>,
790        result: Option<serde_json::Value>,
791        error: Option<String>,
792    ) -> Self {
793        let tool_call_id = tool_call_id.into();
794        Self {
795            id: MessageId::new(),
796            role: MessageRole::ToolResult,
797            content: vec![ContentPart::ToolResult(ToolResultContentPart::new(
798                tool_call_id,
799                result,
800                error,
801            ))],
802            phase: None,
803            thinking: None,
804            thinking_signature: None,
805            controls: None,
806            metadata: None,
807            external_actor: None,
808            created_at: Utc::now(),
809        }
810    }
811
812    /// Create a tool result message with images.
813    ///
814    /// Images are included as `ContentPart::Image` alongside the `ToolResult` part.
815    /// When converted to `LlmMessage`, images become native image content blocks
816    /// that the LLM can see visually (not just stringified base64).
817    pub fn tool_result_with_images(
818        tool_call_id: impl Into<String>,
819        result: Option<serde_json::Value>,
820        images: Vec<crate::tools::ToolResultImage>,
821    ) -> Self {
822        let tool_call_id = tool_call_id.into();
823        let mut content = vec![ContentPart::ToolResult(ToolResultContentPart::new(
824            tool_call_id,
825            result,
826            None,
827        ))];
828        for img in images {
829            content.push(ContentPart::Image(ImageContentPart::from_base64(
830                img.base64,
831                img.media_type,
832            )));
833        }
834        Self {
835            id: MessageId::new(),
836            role: MessageRole::ToolResult,
837            content,
838            phase: None,
839            thinking: None,
840            thinking_signature: None,
841            controls: None,
842            metadata: None,
843            external_actor: None,
844            created_at: Utc::now(),
845        }
846    }
847
848    /// Set the execution phase on this message and return self.
849    pub fn with_phase(mut self, phase: ExecutionPhase) -> Self {
850        self.phase = Some(phase);
851        self
852    }
853
854    /// Get the tool_call_id from a tool result message
855    ///
856    /// Returns the tool_call_id from the first ToolResult content part, if any.
857    pub fn tool_call_id(&self) -> Option<&str> {
858        self.content.iter().find_map(|p| match p {
859            ContentPart::ToolResult(tr) => Some(tr.tool_call_id.as_str()),
860            _ => None,
861        })
862    }
863
864    /// Get first text content from the message
865    pub fn text(&self) -> Option<&str> {
866        self.content.iter().find_map(|p| p.as_text())
867    }
868
869    /// Get all tool calls from the message content
870    pub fn tool_calls(&self) -> Vec<&ToolCallContentPart> {
871        self.content
872            .iter()
873            .filter_map(|p| match p {
874                ContentPart::ToolCall(tc) => Some(tc),
875                _ => None,
876            })
877            .collect()
878    }
879
880    /// Check if this message has tool calls
881    pub fn has_tool_calls(&self) -> bool {
882        self.content
883            .iter()
884            .any(|p| matches!(p, ContentPart::ToolCall(_)))
885    }
886
887    /// Get the first tool result from the message content
888    pub fn tool_result_content(&self) -> Option<&ToolResultContentPart> {
889        self.content.iter().find_map(|p| match p {
890            ContentPart::ToolResult(tr) => Some(tr),
891            _ => None,
892        })
893    }
894
895    /// Convert content to LLM-compatible string representation
896    pub fn content_to_llm_string(&self) -> String {
897        self.content
898            .iter()
899            .map(|part| match part {
900                ContentPart::Text(t) => t.text.clone(),
901                ContentPart::Image(_) => "[Image]".to_string(),
902                ContentPart::ImageFile(_) => "[Image File]".to_string(),
903                ContentPart::ToolCall(tc) => {
904                    format!(
905                        "Tool call: {} with arguments: {}",
906                        tc.name,
907                        serde_json::to_string(&tc.arguments).unwrap_or_default()
908                    )
909                }
910                ContentPart::ToolResult(tr) => {
911                    if let Some(err) = &tr.error {
912                        format!("Tool error: {}", err)
913                    } else if let Some(res) = &tr.result {
914                        serde_json::to_string(res).unwrap_or_else(|_| "{}".to_string())
915                    } else {
916                        "{}".to_string()
917                    }
918                }
919            })
920            .collect::<Vec<_>>()
921            .join("\n")
922    }
923
924    /// Convert message to OpenAI-compatible format
925    ///
926    /// Transforms internal message format to OpenAI API format:
927    /// - `agent` role → `assistant`
928    /// - `tool_result` role → `tool` (with tool_call_id at message level)
929    /// - Tool calls formatted as `{id, type: "function", function: {name, arguments}}`
930    ///
931    /// Used by observability backends (e.g., Braintrust) that expect OpenAI format.
932    pub fn to_openai_format(&self) -> serde_json::Value {
933        let role = match self.role {
934            MessageRole::System => "system",
935            MessageRole::User => "user",
936            MessageRole::Agent => "assistant",
937            MessageRole::ToolResult => "tool",
938        };
939
940        // Handle tool result messages (need tool_call_id at message level)
941        if self.role == MessageRole::ToolResult {
942            let tool_call_id = self.tool_call_id().unwrap_or("");
943            let content = self
944                .content
945                .iter()
946                .find_map(|p| match p {
947                    ContentPart::ToolResult(tr) => {
948                        if let Some(error) = &tr.error {
949                            Some(format!("Error: {}", error))
950                        } else if let Some(result) = &tr.result {
951                            Some(serde_json::to_string(result).unwrap_or_else(|_| "{}".to_string()))
952                        } else {
953                            Some("{}".to_string())
954                        }
955                    }
956                    _ => None,
957                })
958                .unwrap_or_else(|| "{}".to_string());
959
960            return serde_json::json!({
961                "role": role,
962                "content": content,
963                "tool_call_id": tool_call_id
964            });
965        }
966
967        // Handle assistant messages with tool calls
968        if self.role == MessageRole::Agent {
969            let tool_calls: Vec<serde_json::Value> = self
970                .content
971                .iter()
972                .filter_map(|p| match p {
973                    ContentPart::ToolCall(tc) => Some(serde_json::json!({
974                        "id": tc.id,
975                        "type": "function",
976                        "function": {
977                            "name": tc.name,
978                            "arguments": serde_json::to_string(&tc.arguments).unwrap_or_else(|_| "{}".to_string())
979                        }
980                    })),
981                    _ => None,
982                })
983                .collect();
984
985            let text_content: String = self
986                .content
987                .iter()
988                .filter_map(|p| match p {
989                    ContentPart::Text(t) => Some(t.text.clone()),
990                    _ => None,
991                })
992                .collect::<Vec<_>>()
993                .join("\n");
994
995            if tool_calls.is_empty() {
996                return serde_json::json!({
997                    "role": role,
998                    "content": text_content
999                });
1000            } else {
1001                let mut result = serde_json::json!({
1002                    "role": role,
1003                    "tool_calls": tool_calls
1004                });
1005                if !text_content.is_empty() {
1006                    result["content"] = serde_json::json!(text_content);
1007                }
1008                return result;
1009            }
1010        }
1011
1012        // For system/user messages, convert content parts
1013        let content = self.content_to_openai_format();
1014        serde_json::json!({
1015            "role": role,
1016            "content": content
1017        })
1018    }
1019
1020    /// Convert content parts to OpenAI-compatible format
1021    fn content_to_openai_format(&self) -> serde_json::Value {
1022        // Single text content → string
1023        if self.content.len() == 1
1024            && let ContentPart::Text(t) = &self.content[0]
1025        {
1026            return serde_json::json!(t.text);
1027        }
1028
1029        // Convert each content part
1030        let parts: Vec<serde_json::Value> = self
1031            .content
1032            .iter()
1033            .filter_map(|part| part.to_openai_format())
1034            .collect();
1035
1036        if parts.is_empty() {
1037            return serde_json::json!("");
1038        }
1039
1040        // Single text part after filtering → string
1041        if parts.len() == 1
1042            && let Some(text) = parts[0].get("text")
1043        {
1044            return text.clone();
1045        }
1046
1047        serde_json::json!(parts)
1048    }
1049}
1050
1051/// Patch dangling tool calls by adding synthetic "cancelled" results.
1052///
1053/// This ensures every tool call has a corresponding tool result,
1054/// preventing LLM API errors (e.g., OpenAI requires every tool_call to have a result).
1055///
1056/// This is the simple, store-free patcher used by out-of-band completions
1057/// (see `crate::command_host`). The main reason path uses the durable-store-aware
1058/// `repair_dangling_tool_calls` in `crate::atoms::reason` instead (EVE-533),
1059/// which can replay settled results rather than synthesizing cancellations.
1060pub fn patch_dangling_tool_calls(messages: &[Message]) -> Vec<Message> {
1061    let mut result = Vec::new();
1062
1063    for (i, msg) in messages.iter().enumerate() {
1064        result.push(msg.clone());
1065
1066        // After an assistant message with tool calls, add cancelled results for any missing ones
1067        if msg.role == MessageRole::Agent && msg.has_tool_calls() {
1068            for tc in msg.tool_calls() {
1069                // Look for a matching tool result in ALL subsequent messages
1070                let has_result = messages[(i + 1)..]
1071                    .iter()
1072                    .any(|m| m.role == MessageRole::ToolResult && m.tool_call_id() == Some(&tc.id));
1073
1074                if !has_result {
1075                    result.push(Message::tool_result(
1076                        &tc.id,
1077                        None,
1078                        Some(
1079                            "cancelled - another message came in before it could be completed"
1080                                .to_string(),
1081                        ),
1082                    ));
1083                }
1084            }
1085        }
1086    }
1087
1088    result
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093    use super::*;
1094    use crate::tool_types::ToolCall;
1095
1096    #[test]
1097    fn test_patch_dangling_tool_calls_no_tool_calls() {
1098        let messages = vec![Message::user("Hello"), Message::assistant("Hi there!")];
1099        let patched = patch_dangling_tool_calls(&messages);
1100        assert_eq!(patched.len(), 2);
1101    }
1102
1103    #[test]
1104    fn test_patch_dangling_tool_calls_with_result() {
1105        let tool_call = ToolCall {
1106            id: "call_123".to_string(),
1107            name: "get_weather".to_string(),
1108            arguments: serde_json::json!({"city": "NYC"}),
1109        };
1110
1111        let messages = vec![
1112            Message::user("What's the weather?"),
1113            Message::assistant_with_tools("Let me check", vec![tool_call]),
1114            Message::tool_result("call_123", Some(serde_json::json!({"temp": 72})), None),
1115        ];
1116
1117        let patched = patch_dangling_tool_calls(&messages);
1118        assert_eq!(patched.len(), 3);
1119    }
1120
1121    #[test]
1122    fn test_patch_dangling_tool_calls_missing_result() {
1123        let tool_call = ToolCall {
1124            id: "call_456".to_string(),
1125            name: "search_web".to_string(),
1126            arguments: serde_json::json!({"query": "rust"}),
1127        };
1128
1129        let messages = vec![
1130            Message::user("Search for rust"),
1131            Message::assistant_with_tools("Searching...", vec![tool_call]),
1132            Message::user("Actually, never mind"),
1133        ];
1134
1135        let patched = patch_dangling_tool_calls(&messages);
1136        // Should have added a cancelled result
1137        assert_eq!(patched.len(), 4);
1138        assert_eq!(patched[2].role, MessageRole::ToolResult);
1139        assert_eq!(patched[2].tool_call_id(), Some("call_456"));
1140    }
1141
1142    #[test]
1143    fn test_user_message() {
1144        let msg = Message::user("Hello");
1145        assert_eq!(msg.role, MessageRole::User);
1146        assert_eq!(msg.text(), Some("Hello"));
1147    }
1148
1149    #[test]
1150    fn test_assistant_message() {
1151        let msg = Message::assistant("Hi there!");
1152        assert_eq!(msg.role, MessageRole::Agent);
1153        assert_eq!(msg.text(), Some("Hi there!"));
1154    }
1155
1156    #[test]
1157    fn test_tool_result_message() {
1158        let msg = Message::tool_result(
1159            "call_123",
1160            Some(serde_json::json!({"result": "success"})),
1161            None,
1162        );
1163        assert_eq!(msg.role, MessageRole::ToolResult);
1164        assert_eq!(msg.tool_call_id(), Some("call_123"));
1165    }
1166
1167    #[test]
1168    fn test_assistant_with_tools_and_text() {
1169        let tool_call = ToolCall {
1170            id: "call_123".to_string(),
1171            name: "get_weather".to_string(),
1172            arguments: serde_json::json!({"location": "Tokyo"}),
1173        };
1174        let msg = Message::assistant_with_tools("Let me check the weather.", vec![tool_call]);
1175
1176        assert_eq!(msg.role, MessageRole::Agent);
1177        assert_eq!(msg.text(), Some("Let me check the weather."));
1178        assert_eq!(msg.tool_calls().len(), 1);
1179        assert_eq!(msg.tool_calls()[0].name, "get_weather");
1180    }
1181
1182    #[test]
1183    fn test_assistant_with_tools_empty_text() {
1184        // When LLM returns only tool calls without text, we shouldn't include an empty text block
1185        // This is important for Anthropic API which rejects empty text content blocks
1186        let tool_call = ToolCall {
1187            id: "call_123".to_string(),
1188            name: "search".to_string(),
1189            arguments: serde_json::json!({"query": "rust"}),
1190        };
1191        let msg = Message::assistant_with_tools("", vec![tool_call]);
1192
1193        assert_eq!(msg.role, MessageRole::Agent);
1194        // Empty text should result in None, not Some("")
1195        assert_eq!(msg.text(), None);
1196        // But tool calls should still be present
1197        assert_eq!(msg.tool_calls().len(), 1);
1198        assert_eq!(msg.tool_calls()[0].name, "search");
1199        // Content should only have tool_call, no empty text part
1200        assert_eq!(msg.content.len(), 1);
1201        assert!(matches!(msg.content[0], ContentPart::ToolCall(_)));
1202    }
1203
1204    #[test]
1205    fn test_assistant_with_tools_whitespace_text() {
1206        // Whitespace-only text is not empty (could be intentional)
1207        let tool_call = ToolCall {
1208            id: "call_456".to_string(),
1209            name: "fetch".to_string(),
1210            arguments: serde_json::json!({}),
1211        };
1212        let msg = Message::assistant_with_tools("   ", vec![tool_call]);
1213
1214        // Whitespace text is preserved (not treated as empty)
1215        assert_eq!(msg.text(), Some("   "));
1216        assert_eq!(msg.content.len(), 2); // Text + ToolCall
1217    }
1218
1219    #[test]
1220    fn test_assistant_with_multiple_tool_calls() {
1221        let tool_calls = vec![
1222            ToolCall {
1223                id: "call_1".to_string(),
1224                name: "search".to_string(),
1225                arguments: serde_json::json!({"q": "a"}),
1226            },
1227            ToolCall {
1228                id: "call_2".to_string(),
1229                name: "fetch".to_string(),
1230                arguments: serde_json::json!({"url": "http://example.com"}),
1231            },
1232        ];
1233        let msg = Message::assistant_with_tools("", tool_calls);
1234
1235        assert_eq!(msg.tool_calls().len(), 2);
1236        // Only tool calls, no empty text
1237        assert_eq!(msg.content.len(), 2);
1238    }
1239
1240    // =========================================================================
1241    // OpenAI Format Conversion Tests
1242    // =========================================================================
1243
1244    #[test]
1245    fn test_to_openai_format_user_message() {
1246        let msg = Message::user("Hello, world!");
1247        let converted = msg.to_openai_format();
1248
1249        assert_eq!(converted["role"], "user");
1250        assert_eq!(converted["content"], "Hello, world!");
1251    }
1252
1253    #[test]
1254    fn test_to_openai_format_system_message() {
1255        let msg = Message::system("You are a helpful assistant.");
1256        let converted = msg.to_openai_format();
1257
1258        assert_eq!(converted["role"], "system");
1259        assert_eq!(converted["content"], "You are a helpful assistant.");
1260    }
1261
1262    #[test]
1263    fn test_to_openai_format_assistant_role_mapping() {
1264        // Internal "agent" role → "assistant"
1265        let msg = Message::assistant("Hi there!");
1266        let converted = msg.to_openai_format();
1267
1268        assert_eq!(converted["role"], "assistant");
1269        assert_eq!(converted["content"], "Hi there!");
1270    }
1271
1272    #[test]
1273    fn test_to_openai_format_assistant_with_tool_calls() {
1274        let tool_call = ToolCall {
1275            id: "call_123".to_string(),
1276            name: "get_weather".to_string(),
1277            arguments: serde_json::json!({"location": "Tokyo"}),
1278        };
1279        let msg = Message::assistant_with_tools("Let me check.", vec![tool_call]);
1280        let converted = msg.to_openai_format();
1281
1282        assert_eq!(converted["role"], "assistant");
1283        assert_eq!(converted["content"], "Let me check.");
1284
1285        let tool_calls = converted["tool_calls"].as_array().unwrap();
1286        assert_eq!(tool_calls.len(), 1);
1287        assert_eq!(tool_calls[0]["id"], "call_123");
1288        assert_eq!(tool_calls[0]["type"], "function");
1289        assert_eq!(tool_calls[0]["function"]["name"], "get_weather");
1290        assert_eq!(
1291            tool_calls[0]["function"]["arguments"],
1292            r#"{"location":"Tokyo"}"#
1293        );
1294    }
1295
1296    #[test]
1297    fn test_to_openai_format_assistant_tool_calls_only() {
1298        // Assistant message with only tool calls (no text)
1299        let tool_call = ToolCall {
1300            id: "call_abc".to_string(),
1301            name: "search".to_string(),
1302            arguments: serde_json::json!({"query": "rust"}),
1303        };
1304        let msg = Message::assistant_with_tools("", vec![tool_call]);
1305        let converted = msg.to_openai_format();
1306
1307        assert_eq!(converted["role"], "assistant");
1308        // No content field when text is empty
1309        assert!(converted.get("content").is_none());
1310        assert!(converted["tool_calls"].is_array());
1311    }
1312
1313    #[test]
1314    fn test_to_openai_format_tool_result_role_mapping() {
1315        // Internal "tool_result" role → "tool"
1316        let msg = Message::tool_result(
1317            "call_123",
1318            Some(serde_json::json!({"temperature": 72})),
1319            None,
1320        );
1321        let converted = msg.to_openai_format();
1322
1323        assert_eq!(converted["role"], "tool");
1324        assert_eq!(converted["tool_call_id"], "call_123");
1325        assert_eq!(converted["content"], r#"{"temperature":72}"#);
1326    }
1327
1328    #[test]
1329    fn test_to_openai_format_tool_result_error() {
1330        let msg = Message::tool_result("call_456", None, Some("API timeout".to_string()));
1331        let converted = msg.to_openai_format();
1332
1333        assert_eq!(converted["role"], "tool");
1334        assert_eq!(converted["tool_call_id"], "call_456");
1335        assert_eq!(converted["content"], "Error: API timeout");
1336    }
1337
1338    #[test]
1339    fn test_to_openai_format_full_conversation() {
1340        // Full conversation: user → assistant (tool call) → tool result → assistant
1341        let tool_call = ToolCall {
1342            id: "call_abc".to_string(),
1343            name: "search".to_string(),
1344            arguments: serde_json::json!({"query": "rust"}),
1345        };
1346
1347        let messages = [
1348            Message::user("Search for rust"),
1349            Message::assistant_with_tools("", vec![tool_call]),
1350            Message::tool_result(
1351                "call_abc",
1352                Some(serde_json::json!({"results": ["rust-lang.org"]})),
1353                None,
1354            ),
1355            Message::assistant("Here are the search results."),
1356        ];
1357        let converted: Vec<_> = messages.iter().map(|m| m.to_openai_format()).collect();
1358
1359        assert_eq!(converted.len(), 4);
1360        assert_eq!(converted[0]["role"], "user");
1361        assert_eq!(converted[1]["role"], "assistant");
1362        assert!(converted[1]["tool_calls"].is_array());
1363        assert_eq!(converted[2]["role"], "tool");
1364        assert_eq!(converted[2]["tool_call_id"], "call_abc");
1365        assert_eq!(converted[3]["role"], "assistant");
1366    }
1367
1368    // =========================================================================
1369    // ContentPart::to_openai_format Tests
1370    // =========================================================================
1371
1372    #[test]
1373    fn test_content_part_to_openai_format_text() {
1374        let part = ContentPart::text("Hello");
1375        let converted = part.to_openai_format().unwrap();
1376
1377        assert_eq!(converted["type"], "text");
1378        assert_eq!(converted["text"], "Hello");
1379    }
1380
1381    #[test]
1382    fn test_content_part_to_openai_format_image_url() {
1383        let part = ContentPart::image_url("https://example.com/img.png");
1384        let converted = part.to_openai_format().unwrap();
1385
1386        assert_eq!(converted["type"], "image_url");
1387        assert_eq!(converted["image_url"]["url"], "https://example.com/img.png");
1388    }
1389
1390    #[test]
1391    fn test_content_part_to_openai_format_image_base64() {
1392        let part = ContentPart::Image(ImageContentPart::from_base64("abc123", "image/jpeg"));
1393        let converted = part.to_openai_format().unwrap();
1394
1395        assert_eq!(converted["type"], "image_url");
1396        assert_eq!(
1397            converted["image_url"]["url"],
1398            "data:image/jpeg;base64,abc123"
1399        );
1400    }
1401
1402    #[test]
1403    fn test_content_part_to_openai_format_tool_call_returns_none() {
1404        // ToolCall parts are handled at message level, not content part level
1405        let part = ContentPart::tool_call("call_1", "search", serde_json::json!({}));
1406        assert!(part.to_openai_format().is_none());
1407    }
1408
1409    #[test]
1410    fn test_content_part_to_openai_format_tool_result_returns_none() {
1411        // ToolResult parts are handled at message level
1412        let part = ContentPart::tool_result("call_1", Some(serde_json::json!({})), None);
1413        assert!(part.to_openai_format().is_none());
1414    }
1415
1416    #[test]
1417    fn test_execution_phase_from_has_tool_calls() {
1418        assert_eq!(
1419            ExecutionPhase::from_has_tool_calls(true),
1420            ExecutionPhase::Commentary
1421        );
1422        assert_eq!(
1423            ExecutionPhase::from_has_tool_calls(false),
1424            ExecutionPhase::FinalAnswer
1425        );
1426    }
1427
1428    #[test]
1429    fn test_execution_phase_refine_streamed_hint_monotonic() {
1430        use ExecutionPhase::{Commentary, FinalAnswer};
1431        // None advances to the first observed value.
1432        assert_eq!(
1433            ExecutionPhase::refine_streamed_hint(None, Commentary),
1434            Some(Commentary)
1435        );
1436        assert_eq!(
1437            ExecutionPhase::refine_streamed_hint(None, FinalAnswer),
1438            Some(FinalAnswer)
1439        );
1440        // First classification wins: a later hint never flips it...
1441        assert_eq!(
1442            ExecutionPhase::refine_streamed_hint(Some(Commentary), FinalAnswer),
1443            Some(Commentary)
1444        );
1445        assert_eq!(
1446            ExecutionPhase::refine_streamed_hint(Some(FinalAnswer), Commentary),
1447            Some(FinalAnswer)
1448        );
1449        // ...and never reverts to None (the input is never None-valued, but a
1450        // repeated identical hint is a no-op).
1451        assert_eq!(
1452            ExecutionPhase::refine_streamed_hint(Some(Commentary), Commentary),
1453            Some(Commentary)
1454        );
1455    }
1456
1457    #[test]
1458    fn test_execution_phase_serde_roundtrip() {
1459        let commentary = ExecutionPhase::Commentary;
1460        let json = serde_json::to_string(&commentary).unwrap();
1461        assert_eq!(json, "\"commentary\"");
1462        let deserialized: ExecutionPhase = serde_json::from_str(&json).unwrap();
1463        assert_eq!(deserialized, ExecutionPhase::Commentary);
1464
1465        let final_answer = ExecutionPhase::FinalAnswer;
1466        let json = serde_json::to_string(&final_answer).unwrap();
1467        assert_eq!(json, "\"final_answer\"");
1468        let deserialized: ExecutionPhase = serde_json::from_str(&json).unwrap();
1469        assert_eq!(deserialized, ExecutionPhase::FinalAnswer);
1470    }
1471
1472    #[test]
1473    fn test_execution_phase_deserialize_legacy() {
1474        let legacy_in_progress: ExecutionPhase = serde_json::from_str("\"in_progress\"").unwrap();
1475        assert_eq!(legacy_in_progress, ExecutionPhase::Commentary);
1476
1477        let legacy_completed: ExecutionPhase = serde_json::from_str("\"completed\"").unwrap();
1478        assert_eq!(legacy_completed, ExecutionPhase::FinalAnswer);
1479    }
1480
1481    #[test]
1482    fn test_execution_phase_deserialize_unknown_fails() {
1483        let result = serde_json::from_str::<ExecutionPhase>("\"bogus\"");
1484        assert!(result.is_err());
1485    }
1486
1487    #[test]
1488    fn test_message_with_phase() {
1489        let msg = Message::assistant("Hello").with_phase(ExecutionPhase::Commentary);
1490        assert_eq!(msg.phase, Some(ExecutionPhase::Commentary));
1491    }
1492
1493    #[test]
1494    fn test_message_phase_skipped_when_none() {
1495        let msg = Message::assistant("Hello");
1496        let json = serde_json::to_value(&msg).unwrap();
1497        assert!(json.get("phase").is_none());
1498    }
1499
1500    #[test]
1501    fn test_message_phase_included_when_set() {
1502        let msg = Message::assistant("Hello").with_phase(ExecutionPhase::FinalAnswer);
1503        let json = serde_json::to_value(&msg).unwrap();
1504        assert_eq!(json.get("phase").unwrap(), "final_answer");
1505    }
1506
1507    #[test]
1508    fn test_resolve_hints_both_none() {
1509        let result = Controls::resolve_hints(None, None);
1510        assert!(result.is_empty());
1511    }
1512
1513    #[test]
1514    fn test_resolve_hints_session_only() {
1515        let mut session = std::collections::HashMap::new();
1516        session.insert("key1".into(), serde_json::json!("val1"));
1517        session.insert("key2".into(), serde_json::json!(42));
1518
1519        let result = Controls::resolve_hints(Some(&session), None);
1520        assert_eq!(result.len(), 2);
1521        assert_eq!(result["key1"], serde_json::json!("val1"));
1522        assert_eq!(result["key2"], serde_json::json!(42));
1523    }
1524
1525    #[test]
1526    fn test_resolve_hints_message_only() {
1527        let mut message = std::collections::HashMap::new();
1528        message.insert("key1".into(), serde_json::json!(true));
1529
1530        let result = Controls::resolve_hints(None, Some(&message));
1531        assert_eq!(result.len(), 1);
1532        assert_eq!(result["key1"], serde_json::json!(true));
1533    }
1534
1535    #[test]
1536    fn test_resolve_hints_message_overrides_session() {
1537        let mut session = std::collections::HashMap::new();
1538        session.insert("shared".into(), serde_json::json!("session_val"));
1539        session.insert("session_only".into(), serde_json::json!(1));
1540
1541        let mut message = std::collections::HashMap::new();
1542        message.insert("shared".into(), serde_json::json!("message_val"));
1543        message.insert("message_only".into(), serde_json::json!(2));
1544
1545        let result = Controls::resolve_hints(Some(&session), Some(&message));
1546        assert_eq!(result.len(), 3);
1547        assert_eq!(result["shared"], serde_json::json!("message_val"));
1548        assert_eq!(result["session_only"], serde_json::json!(1));
1549        assert_eq!(result["message_only"], serde_json::json!(2));
1550    }
1551
1552    #[test]
1553    fn test_controls_hints_serde_roundtrip() {
1554        let mut hints = std::collections::HashMap::new();
1555        hints.insert("setup_connection".into(), serde_json::json!(true));
1556        hints.insert("theme".into(), serde_json::json!("dark"));
1557
1558        let controls = Controls {
1559            hints: Some(hints),
1560            ..Default::default()
1561        };
1562
1563        let json = serde_json::to_value(&controls).unwrap();
1564        let deserialized: Controls = serde_json::from_value(json).unwrap();
1565        let h = deserialized.hints.unwrap();
1566        assert_eq!(h["setup_connection"], serde_json::json!(true));
1567        assert_eq!(h["theme"], serde_json::json!("dark"));
1568    }
1569
1570    #[test]
1571    fn tool_result_text_preserves_strings_without_json_escaping() {
1572        let value = serde_json::json!("{\n  \"count\": 1\n}");
1573        assert_eq!(
1574            ContentPart::tool_result_text(&value).as_text(),
1575            Some("{\n  \"count\": 1\n}")
1576        );
1577    }
1578
1579    #[test]
1580    fn tool_result_text_serializes_structured_values() {
1581        let value = serde_json::json!({"count": 1});
1582        assert_eq!(
1583            ContentPart::tool_result_text(&value).as_text(),
1584            Some("{\"count\":1}")
1585        );
1586    }
1587}