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