Skip to main content

steer_core/app/conversation/
message.rs

1//! Message types for conversation representation.
2//!
3//! This module contains the core message types used throughout the application:
4//! - `Message` - The main message struct with metadata
5//! - `MessageData` - Role-specific content (User, Assistant, Tool)
6//! - Content types for each role
7
8use serde::{Deserialize, Serialize};
9use std::time::{SystemTime, UNIX_EPOCH};
10use steer_tools::ToolCall;
11pub use steer_tools::result::ToolResult;
12use strum_macros::Display;
13
14/// Role in the conversation
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Copy, Display)]
16pub enum Role {
17    User,
18    Assistant,
19    Tool,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23#[serde(tag = "source", rename_all = "snake_case")]
24pub enum ImageSource {
25    SessionFile { relative_path: String },
26    DataUrl { data_url: String },
27    Url { url: String },
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31pub struct ImageContent {
32    pub mime_type: String,
33    pub source: ImageSource,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub width: Option<u32>,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub height: Option<u32>,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub bytes: Option<u64>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub sha256: Option<String>,
42}
43
44/// Content that can be sent by a user
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46#[serde(tag = "type", rename_all = "snake_case")]
47pub enum UserContent {
48    Text {
49        text: String,
50    },
51    Image {
52        image: ImageContent,
53    },
54    CommandExecution {
55        command: String,
56        stdout: String,
57        stderr: String,
58        exit_code: i32,
59    },
60}
61
62impl UserContent {
63    pub fn format_command_execution_as_xml(
64        command: &str,
65        stdout: &str,
66        stderr: &str,
67        exit_code: i32,
68    ) -> String {
69        format!(
70            r"<executed_command>
71    <command>{command}</command>
72    <stdout>{stdout}</stdout>
73    <stderr>{stderr}</stderr>
74    <exit_code>{exit_code}</exit_code>
75</executed_command>"
76        )
77    }
78}
79
80/// Different types of thought content from AI models
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
82#[serde(tag = "thought_type")]
83pub enum ThoughtContent {
84    /// Simple thought content (e.g., from Gemini)
85    #[serde(rename = "simple")]
86    Simple { text: String },
87    /// Claude-style thinking with signature
88    #[serde(rename = "signed")]
89    Signed { text: String, signature: String },
90    /// Claude-style redacted thinking
91    #[serde(rename = "redacted")]
92    Redacted { data: String },
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
96#[serde(transparent)]
97pub struct ThoughtSignature(String);
98
99impl ThoughtSignature {
100    pub fn new(value: impl Into<String>) -> Self {
101        Self(value.into())
102    }
103
104    pub fn as_str(&self) -> &str {
105        &self.0
106    }
107}
108
109impl ThoughtContent {
110    /// Extract displayable text from any thought type
111    pub fn display_text(&self) -> String {
112        match self {
113            ThoughtContent::Simple { text } => text.clone(),
114            ThoughtContent::Signed { text, .. } => text.clone(),
115            ThoughtContent::Redacted { .. } => "[Redacted Thinking]".to_string(),
116        }
117    }
118}
119
120/// Content that can be sent by an assistant
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
122#[serde(tag = "type", rename_all = "snake_case")]
123pub enum AssistantContent {
124    Text {
125        text: String,
126    },
127    Image {
128        image: ImageContent,
129    },
130    ToolCall {
131        tool_call: ToolCall,
132        #[serde(default, skip_serializing_if = "Option::is_none")]
133        thought_signature: Option<ThoughtSignature>,
134    },
135    Thought {
136        thought: ThoughtContent,
137    },
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct Message {
142    pub timestamp: u64,
143    pub id: String,
144    pub parent_message_id: Option<String>,
145    pub data: MessageData,
146}
147
148/// A message in the conversation, with role-specific content
149#[derive(Debug, Clone, Serialize, Deserialize)]
150#[serde(tag = "role", rename_all = "lowercase")]
151pub enum MessageData {
152    User {
153        content: Vec<UserContent>,
154    },
155    Assistant {
156        content: Vec<AssistantContent>,
157    },
158    Tool {
159        tool_use_id: String,
160        result: ToolResult,
161    },
162}
163
164impl Message {
165    pub fn role(&self) -> Role {
166        match &self.data {
167            MessageData::User { .. } => Role::User,
168            MessageData::Assistant { .. } => Role::Assistant,
169            MessageData::Tool { .. } => Role::Tool,
170        }
171    }
172
173    pub fn id(&self) -> &str {
174        &self.id
175    }
176
177    pub fn timestamp(&self) -> u64 {
178        self.timestamp
179    }
180
181    pub fn parent_message_id(&self) -> Option<&str> {
182        self.parent_message_id.as_deref()
183    }
184
185    /// Helper to get current timestamp
186    pub fn current_timestamp() -> u64 {
187        SystemTime::now()
188            .duration_since(UNIX_EPOCH)
189            .unwrap_or_default()
190            .as_secs()
191    }
192
193    /// Helper to generate unique IDs
194    pub fn generate_id(prefix: &str, _timestamp: u64) -> String {
195        use uuid::Uuid;
196        format!("{}_{}", prefix, Uuid::now_v7())
197    }
198
199    /// Extract text content from the message
200    pub fn extract_text(&self) -> String {
201        match &self.data {
202            MessageData::User { content } => content
203                .iter()
204                .map(|c| match c {
205                    UserContent::Text { text } => text.clone(),
206                    UserContent::Image { .. } => "[Image]".to_string(),
207                    UserContent::CommandExecution { stdout, .. } => stdout.clone(),
208                })
209                .collect::<Vec<_>>()
210                .join("\n"),
211            MessageData::Assistant { content } => content
212                .iter()
213                .map(|c| match c {
214                    AssistantContent::Text { text } => text.clone(),
215                    AssistantContent::Image { .. } => "[Image]".to_string(),
216                    AssistantContent::ToolCall { .. } | AssistantContent::Thought { .. } => {
217                        String::new()
218                    }
219                })
220                .filter(|line| !line.is_empty())
221                .collect::<Vec<_>>()
222                .join("\n"),
223            MessageData::Tool { result, .. } => result.llm_format(),
224        }
225    }
226
227    /// Get a string representation of the message content
228    pub fn content_string(&self) -> String {
229        match &self.data {
230            MessageData::User { content } => content
231                .iter()
232                .map(|c| match c {
233                    UserContent::Text { text } => text.clone(),
234                    UserContent::Image { image } => {
235                        format!("[Image: {}]", image.mime_type)
236                    }
237                    UserContent::CommandExecution {
238                        command,
239                        stdout,
240                        stderr,
241                        exit_code,
242                    } => {
243                        let mut output = format!("$ {command}\n{stdout}");
244                        if *exit_code != 0 {
245                            output.push_str(&format!("\nExit code: {exit_code}"));
246                        }
247                        if !stderr.is_empty() {
248                            output.push_str(&format!("\nError: {stderr}"));
249                        }
250                        output
251                    }
252                })
253                .collect::<Vec<_>>()
254                .join("\n"),
255            MessageData::Assistant { content } => content
256                .iter()
257                .map(|c| match c {
258                    AssistantContent::Text { text } => text.clone(),
259                    AssistantContent::Image { image } => {
260                        format!("[Image: {}]", image.mime_type)
261                    }
262                    AssistantContent::ToolCall { tool_call, .. } => {
263                        format!("[Tool Call: {}]", tool_call.name)
264                    }
265                    AssistantContent::Thought { thought } => {
266                        format!("[Thought: {}]", thought.display_text())
267                    }
268                })
269                .collect::<Vec<_>>()
270                .join("\n"),
271            MessageData::Tool { result, .. } => {
272                // This is a simplified representation. The TUI will have a more detailed view.
273                let result_type = match result {
274                    ToolResult::Search(_) => "Search Result",
275                    ToolResult::FileList(_) => "File List",
276                    ToolResult::FileContent(_) => "File Content",
277                    ToolResult::Edit(_) => "Edit Result",
278                    ToolResult::Bash(_) => "Bash Result",
279                    ToolResult::Glob(_) => "Glob Result",
280                    ToolResult::TodoRead(_) => "Todo List",
281                    ToolResult::TodoWrite(_) => "Todo Update",
282                    ToolResult::Fetch(_) => "Fetch Result",
283                    ToolResult::Agent(_) => "Agent Result",
284                    ToolResult::External(_) => "External Tool Result",
285                    ToolResult::Error(_) => "Error",
286                };
287                format!("[Tool Result: {result_type}]")
288            }
289        }
290    }
291}