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