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