Skip to main content

everruns_core/
message.rs

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