1use crate::domain::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)]
14 pub kind: ChatMessageKind,
15 #[serde(default)]
17 pub metadata: Option<serde_json::Value>,
18 #[serde(default)]
20 pub actions: Vec<ActionDisplay>,
21 #[serde(default)]
23 pub thinking: Option<String>,
24 #[serde(default)]
26 pub images: Option<Vec<String>>,
27 #[serde(default)]
29 pub tool_calls: Option<Vec<crate::models::tool_call::ToolCall>>,
30 #[serde(default)]
33 pub tool_call_id: Option<String>,
34 #[serde(default)]
37 pub tool_name: Option<String>,
38 #[serde(default)]
44 pub thinking_signature: Option<String>,
45}
46
47impl ChatMessage {
48 pub fn user(content: impl Into<String>) -> Self {
50 Self::new(MessageRole::User, content.into())
51 }
52
53 pub fn assistant(content: impl Into<String>) -> Self {
55 Self::new(MessageRole::Assistant, content.into())
56 }
57
58 pub fn system(content: impl Into<String>) -> Self {
60 Self::new(MessageRole::System, content.into())
61 }
62
63 pub fn run_summary(content: impl Into<String>) -> Self {
67 let mut m = Self::new(MessageRole::System, content.into());
68 m.kind = ChatMessageKind::RunSummary;
69 m
70 }
71
72 pub fn tool(
74 tool_call_id: impl Into<String>,
75 tool_name: impl Into<String>,
76 content: impl Into<String>,
77 ) -> Self {
78 Self {
79 role: MessageRole::Tool,
80 content: content.into(),
81 timestamp: chrono::Local::now(),
82 kind: ChatMessageKind::Normal,
83 metadata: None,
84 actions: Vec::new(),
85 thinking: None,
86 images: None,
87 tool_calls: None,
88 tool_call_id: Some(tool_call_id.into()),
89 tool_name: Some(tool_name.into()),
90 thinking_signature: None,
91 }
92 }
93
94 fn new(role: MessageRole, content: String) -> Self {
96 Self {
97 role,
98 content,
99 timestamp: chrono::Local::now(),
100 kind: ChatMessageKind::Normal,
101 metadata: None,
102 actions: Vec::new(),
103 thinking: None,
104 images: None,
105 tool_calls: None,
106 tool_call_id: None,
107 tool_name: None,
108 thinking_signature: None,
109 }
110 }
111
112 pub fn with_images(mut self, images: Vec<String>) -> Self {
114 self.images = Some(images);
115 self
116 }
117
118 pub fn with_tool_calls(mut self, tool_calls: Vec<crate::models::tool_call::ToolCall>) -> Self {
120 self.tool_calls = if tool_calls.is_empty() {
121 None
122 } else {
123 Some(tool_calls)
124 };
125 self
126 }
127
128 pub fn with_thinking_signature(mut self, signature: impl Into<String>) -> Self {
132 self.thinking_signature = Some(signature.into());
133 self
134 }
135
136 pub fn extract_thinking(text: &str) -> (Option<String>, String) {
147 let Some(thinking_start) = text.find("Thinking...") else {
148 return (None, text.to_string());
149 };
150 let content_start = thinking_start + "Thinking...".len();
151
152 if let Some(thinking_end) = text.find("...done thinking.") {
153 let thinking_text = text[content_start..thinking_end].trim().to_string();
154 let answer_start = thinking_end + "...done thinking.".len();
155 let answer_text = text[answer_start..].trim().to_string();
156 return (Some(thinking_text), answer_text);
157 }
158
159 let thinking_text = text[content_start..].trim().to_string();
161 (Some(thinking_text), String::new())
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Serialize)]
166pub enum MessageRole {
167 User,
168 Assistant,
169 System,
170 Tool,
172}
173
174impl<'de> Deserialize<'de> for MessageRole {
188 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
189 where
190 D: serde::Deserializer<'de>,
191 {
192 let raw = String::deserialize(deserializer)?;
193 Ok(match raw.as_str() {
194 "User" => MessageRole::User,
195 "Assistant" => MessageRole::Assistant,
196 "System" => MessageRole::System,
197 "Tool" => MessageRole::Tool,
198 other => {
199 tracing::warn!(
200 role = %other,
201 "unknown message role in saved conversation; treating as System (version skew?)"
202 );
203 MessageRole::System
204 },
205 })
206 }
207}
208
209#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename_all = "snake_case")]
211pub enum ChatMessageKind {
212 #[default]
213 Normal,
214 ContextCheckpoint,
215 RunSummary,
218 #[serde(other)]
224 Unknown,
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(rename_all = "snake_case")]
236pub enum FinishReason {
237 Stop,
239 ToolUse,
241 Length,
243 ContentFilter,
245 Other(String),
247}
248
249#[derive(Debug, Clone)]
251pub struct ModelResponse {
252 pub content: String,
254 pub usage: Option<TokenUsage>,
256 pub model_name: String,
258 pub thinking: Option<String>,
260 pub tool_calls: Option<Vec<crate::models::tool_call::ToolCall>>,
262 pub stop_reason: Option<FinishReason>,
265 pub thinking_signature: Option<String>,
272}
273
274#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(rename_all = "snake_case")]
279pub enum TokenUsageSource {
280 #[default]
281 Provider,
282 Estimate,
283}
284
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291pub struct TokenUsage {
292 pub prompt_tokens: usize,
293 pub completion_tokens: usize,
294 pub total_tokens: usize,
295 #[serde(default)]
296 pub cached_input_tokens: usize,
297 #[serde(default)]
298 pub cache_creation_input_tokens: usize,
299 #[serde(default)]
300 pub reasoning_output_tokens: usize,
301 #[serde(default)]
302 pub source: TokenUsageSource,
303}
304
305impl TokenUsage {
306 pub fn provider(prompt_tokens: usize, completion_tokens: usize, total_tokens: usize) -> Self {
307 Self {
308 prompt_tokens,
309 completion_tokens,
310 total_tokens,
311 cached_input_tokens: 0,
312 cache_creation_input_tokens: 0,
313 reasoning_output_tokens: 0,
314 source: TokenUsageSource::Provider,
315 }
316 }
317
318 pub fn estimate(prompt_tokens: usize) -> Self {
319 Self {
320 prompt_tokens,
321 completion_tokens: 0,
322 total_tokens: prompt_tokens,
323 cached_input_tokens: 0,
324 cache_creation_input_tokens: 0,
325 reasoning_output_tokens: 0,
326 source: TokenUsageSource::Estimate,
327 }
328 }
329
330 pub fn with_cached_input(mut self, cached_input_tokens: usize) -> Self {
331 self.cached_input_tokens = cached_input_tokens;
332 self
333 }
334
335 pub fn with_cache_creation(mut self, cache_creation_input_tokens: usize) -> Self {
336 self.cache_creation_input_tokens = cache_creation_input_tokens;
337 self
338 }
339
340 pub fn with_reasoning_output(mut self, reasoning_output_tokens: usize) -> Self {
341 self.reasoning_output_tokens = reasoning_output_tokens;
342 self
343 }
344
345 pub fn input_total_tokens(&self) -> usize {
346 self.prompt_tokens
347 .saturating_add(self.cached_input_tokens)
348 .saturating_add(self.cache_creation_input_tokens)
349 }
350
351 pub fn output_total_tokens(&self) -> usize {
352 self.completion_tokens
353 .saturating_add(self.reasoning_output_tokens)
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 fn test_message_role_equality() {
363 let user1 = MessageRole::User;
364 let user2 = MessageRole::User;
365 let assistant = MessageRole::Assistant;
366
367 assert_eq!(user1, user2, "User roles should be equal");
368 assert_ne!(user1, assistant, "Different roles should not be equal");
369 }
370
371 #[test]
372 fn test_chat_message_constructors() {
373 let user = ChatMessage::user("Hello!");
374 assert_eq!(user.role, MessageRole::User);
375 assert_eq!(user.content, "Hello!");
376 assert!(user.tool_calls.is_none());
377
378 let assistant = ChatMessage::assistant("Hi there");
379 assert_eq!(assistant.role, MessageRole::Assistant);
380
381 let system = ChatMessage::system("You are helpful");
382 assert_eq!(system.role, MessageRole::System);
383
384 let tool = ChatMessage::tool("call_1", "read_file", "file contents");
385 assert_eq!(tool.role, MessageRole::Tool);
386 assert_eq!(tool.tool_call_id, Some("call_1".to_string()));
387 assert_eq!(tool.tool_name, Some("read_file".to_string()));
388 }
389
390 #[test]
391 fn test_chat_message_builders() {
392 let msg = ChatMessage::user("test").with_images(vec!["base64data".to_string()]);
393 assert_eq!(msg.images, Some(vec!["base64data".to_string()]));
394 }
395
396 #[test]
397 fn test_token_usage_structure() {
398 let usage = TokenUsage::provider(100, 50, 150)
399 .with_cached_input(25)
400 .with_reasoning_output(10);
401
402 assert_eq!(usage.prompt_tokens, 100);
403 assert_eq!(usage.completion_tokens, 50);
404 assert_eq!(usage.total_tokens, 150);
405 assert_eq!(usage.cached_input_tokens, 25);
406 assert_eq!(usage.reasoning_output_tokens, 10);
407 assert_eq!(usage.source, TokenUsageSource::Provider);
408 }
409
410 #[test]
413 fn extract_thinking_no_marker_returns_text_unchanged() {
414 let (thinking, answer) = ChatMessage::extract_thinking("just a plain answer");
415 assert_eq!(thinking, None);
416 assert_eq!(answer, "just a plain answer");
417 }
418
419 #[test]
420 fn extract_thinking_complete_block() {
421 let raw = "Thinking...\n reasoning here\n...done thinking.\n\nFinal answer";
422 let (thinking, answer) = ChatMessage::extract_thinking(raw);
423 assert_eq!(thinking.as_deref(), Some("reasoning here"));
424 assert_eq!(answer, "Final answer");
425 }
426
427 #[test]
428 fn thinking_signature_round_trips_through_serde() {
429 let msg = ChatMessage::assistant("Step 3 lives.")
432 .with_thinking_signature("sig_abc123_encrypted_blob");
433 let json = serde_json::to_string(&msg).expect("serialize");
434 let back: ChatMessage = serde_json::from_str(&json).expect("deserialize");
435 assert_eq!(
436 back.thinking_signature.as_deref(),
437 Some("sig_abc123_encrypted_blob")
438 );
439 assert_eq!(back.content, "Step 3 lives.");
440 }
441
442 #[test]
443 fn thinking_signature_defaults_to_none() {
444 let pre_step3_json = r#"{
448 "role": "Assistant",
449 "content": "hello",
450 "timestamp": "2026-04-16T12:00:00-04:00"
451 }"#;
452 let msg: ChatMessage = serde_json::from_str(pre_step3_json).expect("backward compat");
453 assert!(msg.thinking_signature.is_none());
454 }
455
456 #[test]
457 fn unknown_message_role_deserializes_to_system() {
458 let role: MessageRole = serde_json::from_str("\"Developer\"").expect("tolerant");
462 assert_eq!(role, MessageRole::System);
463 assert_eq!(
465 serde_json::from_str::<MessageRole>("\"Tool\"").unwrap(),
466 MessageRole::Tool
467 );
468 }
469
470 #[test]
471 fn unknown_message_kind_deserializes_to_unknown() {
472 let kind: ChatMessageKind = serde_json::from_str("\"some_future_kind\"").expect("tolerant");
475 assert_eq!(kind, ChatMessageKind::Unknown);
476 assert_ne!(kind, ChatMessageKind::Normal);
477 }
478
479 #[test]
480 fn chat_message_with_unknown_role_round_trips() {
481 let json = r#"{
483 "role": "Developer",
484 "content": "hi",
485 "timestamp": "2026-04-16T12:00:00-04:00"
486 }"#;
487 let msg: ChatMessage = serde_json::from_str(json).expect("tolerant");
488 assert_eq!(msg.role, MessageRole::System);
489 assert_eq!(msg.content, "hi");
490 }
491
492 #[test]
493 fn extract_thinking_in_progress_no_end_marker() {
494 let raw = "Thinking...\n partial reasoning so far";
495 let (thinking, answer) = ChatMessage::extract_thinking(raw);
496 assert_eq!(thinking.as_deref(), Some("partial reasoning so far"));
497 assert_eq!(answer, "");
498 }
499
500 #[test]
501 fn test_model_response_creation() {
502 let usage = TokenUsage::provider(100, 50, 150);
503
504 let response = ModelResponse {
505 content: "Hello, world!".to_string(),
506 usage: Some(usage),
507 model_name: "ollama/tinyllama".to_string(),
508 thinking: None,
509 tool_calls: None,
510 stop_reason: None,
511 thinking_signature: None,
512 };
513
514 assert_eq!(response.content, "Hello, world!");
515 assert!(response.usage.is_some());
516 assert_eq!(response.model_name, "ollama/tinyllama");
517 assert_eq!(response.usage.unwrap().total_tokens, 150);
518 assert!(response.tool_calls.is_none());
519 }
520}