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