1use crate::agents::ActionDisplay;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct ChatMessage {
7 pub role: MessageRole,
8 pub content: String,
9 pub timestamp: chrono::DateTime<chrono::Local>,
10 #[serde(default)]
12 pub actions: Vec<ActionDisplay>,
13 #[serde(default)]
15 pub thinking: Option<String>,
16 #[serde(default)]
18 pub images: Option<Vec<String>>,
19 #[serde(default)]
21 pub tool_calls: Option<Vec<crate::models::tool_call::ToolCall>>,
22 #[serde(default)]
25 pub tool_call_id: Option<String>,
26 #[serde(default)]
29 pub tool_name: Option<String>,
30 #[serde(default)]
36 pub thinking_signature: Option<String>,
37}
38
39impl ChatMessage {
40 pub fn user(content: impl Into<String>) -> Self {
42 Self::new(MessageRole::User, content.into())
43 }
44
45 pub fn assistant(content: impl Into<String>) -> Self {
47 Self::new(MessageRole::Assistant, content.into())
48 }
49
50 pub fn system(content: impl Into<String>) -> Self {
52 Self::new(MessageRole::System, content.into())
53 }
54
55 pub fn tool(
57 tool_call_id: impl Into<String>,
58 tool_name: impl Into<String>,
59 content: impl Into<String>,
60 ) -> Self {
61 Self {
62 role: MessageRole::Tool,
63 content: content.into(),
64 timestamp: chrono::Local::now(),
65 actions: Vec::new(),
66 thinking: None,
67 images: None,
68 tool_calls: None,
69 tool_call_id: Some(tool_call_id.into()),
70 tool_name: Some(tool_name.into()),
71 thinking_signature: None,
72 }
73 }
74
75 fn new(role: MessageRole, content: String) -> Self {
77 Self {
78 role,
79 content,
80 timestamp: chrono::Local::now(),
81 actions: Vec::new(),
82 thinking: None,
83 images: None,
84 tool_calls: None,
85 tool_call_id: None,
86 tool_name: None,
87 thinking_signature: None,
88 }
89 }
90
91 pub fn with_images(mut self, images: Vec<String>) -> Self {
93 self.images = Some(images);
94 self
95 }
96
97 pub fn with_tool_calls(mut self, tool_calls: Vec<crate::models::tool_call::ToolCall>) -> Self {
99 self.tool_calls = if tool_calls.is_empty() {
100 None
101 } else {
102 Some(tool_calls)
103 };
104 self
105 }
106
107 pub fn with_thinking_signature(mut self, signature: impl Into<String>) -> Self {
111 self.thinking_signature = Some(signature.into());
112 self
113 }
114
115 pub fn extract_thinking(text: &str) -> (Option<String>, String) {
126 let Some(thinking_start) = text.find("Thinking...") else {
127 return (None, text.to_string());
128 };
129 let content_start = thinking_start + "Thinking...".len();
130
131 if let Some(thinking_end) = text.find("...done thinking.") {
132 let thinking_text = text[content_start..thinking_end].trim().to_string();
133 let answer_start = thinking_end + "...done thinking.".len();
134 let answer_text = text[answer_start..].trim().to_string();
135 return (Some(thinking_text), answer_text);
136 }
137
138 let thinking_text = text[content_start..].trim().to_string();
140 (Some(thinking_text), String::new())
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
145pub enum MessageRole {
146 User,
147 Assistant,
148 System,
149 Tool,
151}
152
153#[derive(Debug, Clone)]
155pub struct ModelResponse {
156 pub content: String,
158 pub usage: Option<TokenUsage>,
160 pub model_name: String,
162 pub thinking: Option<String>,
164 pub tool_calls: Option<Vec<crate::models::tool_call::ToolCall>>,
166 pub thinking_signature: Option<String>,
173}
174
175#[derive(Debug, Clone)]
177pub struct TokenUsage {
178 pub prompt_tokens: usize,
179 pub completion_tokens: usize,
180 pub total_tokens: usize,
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn test_message_role_equality() {
189 let user1 = MessageRole::User;
190 let user2 = MessageRole::User;
191 let assistant = MessageRole::Assistant;
192
193 assert_eq!(user1, user2, "User roles should be equal");
194 assert_ne!(user1, assistant, "Different roles should not be equal");
195 }
196
197 #[test]
198 fn test_chat_message_constructors() {
199 let user = ChatMessage::user("Hello!");
200 assert_eq!(user.role, MessageRole::User);
201 assert_eq!(user.content, "Hello!");
202 assert!(user.tool_calls.is_none());
203
204 let assistant = ChatMessage::assistant("Hi there");
205 assert_eq!(assistant.role, MessageRole::Assistant);
206
207 let system = ChatMessage::system("You are helpful");
208 assert_eq!(system.role, MessageRole::System);
209
210 let tool = ChatMessage::tool("call_1", "read_file", "file contents");
211 assert_eq!(tool.role, MessageRole::Tool);
212 assert_eq!(tool.tool_call_id, Some("call_1".to_string()));
213 assert_eq!(tool.tool_name, Some("read_file".to_string()));
214 }
215
216 #[test]
217 fn test_chat_message_builders() {
218 let msg = ChatMessage::user("test").with_images(vec!["base64data".to_string()]);
219 assert_eq!(msg.images, Some(vec!["base64data".to_string()]));
220 }
221
222 #[test]
223 fn test_token_usage_structure() {
224 let usage = TokenUsage {
225 prompt_tokens: 100,
226 completion_tokens: 50,
227 total_tokens: 150,
228 };
229
230 assert_eq!(usage.prompt_tokens, 100);
231 assert_eq!(usage.completion_tokens, 50);
232 assert_eq!(usage.total_tokens, 150);
233 }
234
235 #[test]
238 fn extract_thinking_no_marker_returns_text_unchanged() {
239 let (thinking, answer) = ChatMessage::extract_thinking("just a plain answer");
240 assert_eq!(thinking, None);
241 assert_eq!(answer, "just a plain answer");
242 }
243
244 #[test]
245 fn extract_thinking_complete_block() {
246 let raw = "Thinking...\n reasoning here\n...done thinking.\n\nFinal answer";
247 let (thinking, answer) = ChatMessage::extract_thinking(raw);
248 assert_eq!(thinking.as_deref(), Some("reasoning here"));
249 assert_eq!(answer, "Final answer");
250 }
251
252 #[test]
253 fn thinking_signature_round_trips_through_serde() {
254 let msg = ChatMessage::assistant("Step 3 lives.")
257 .with_thinking_signature("sig_abc123_encrypted_blob");
258 let json = serde_json::to_string(&msg).expect("serialize");
259 let back: ChatMessage = serde_json::from_str(&json).expect("deserialize");
260 assert_eq!(
261 back.thinking_signature.as_deref(),
262 Some("sig_abc123_encrypted_blob")
263 );
264 assert_eq!(back.content, "Step 3 lives.");
265 }
266
267 #[test]
268 fn thinking_signature_defaults_to_none() {
269 let pre_step3_json = r#"{
273 "role": "Assistant",
274 "content": "hello",
275 "timestamp": "2026-04-16T12:00:00-04:00"
276 }"#;
277 let msg: ChatMessage = serde_json::from_str(pre_step3_json).expect("backward compat");
278 assert!(msg.thinking_signature.is_none());
279 }
280
281 #[test]
282 fn extract_thinking_in_progress_no_end_marker() {
283 let raw = "Thinking...\n partial reasoning so far";
284 let (thinking, answer) = ChatMessage::extract_thinking(raw);
285 assert_eq!(thinking.as_deref(), Some("partial reasoning so far"));
286 assert_eq!(answer, "");
287 }
288
289 #[test]
290 fn test_model_response_creation() {
291 let usage = TokenUsage {
292 prompt_tokens: 100,
293 completion_tokens: 50,
294 total_tokens: 150,
295 };
296
297 let response = ModelResponse {
298 content: "Hello, world!".to_string(),
299 usage: Some(usage),
300 model_name: "ollama/tinyllama".to_string(),
301 thinking: None,
302 tool_calls: None,
303 thinking_signature: None,
304 };
305
306 assert_eq!(response.content, "Hello, world!");
307 assert!(response.usage.is_some());
308 assert_eq!(response.model_name, "ollama/tinyllama");
309 assert_eq!(response.usage.unwrap().total_tokens, 150);
310 assert!(response.tool_calls.is_none());
311 }
312}