use crate::domain::ActionDisplay;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: MessageRole,
pub content: String,
pub timestamp: chrono::DateTime<chrono::Local>,
#[serde(default)]
pub kind: ChatMessageKind,
#[serde(default)]
pub metadata: Option<serde_json::Value>,
#[serde(default)]
pub actions: Vec<ActionDisplay>,
#[serde(default)]
pub thinking: Option<String>,
#[serde(default)]
pub images: Option<Vec<String>>,
#[serde(default)]
pub tool_calls: Option<Vec<crate::models::tool_call::ToolCall>>,
#[serde(default)]
pub tool_call_id: Option<String>,
#[serde(default)]
pub tool_name: Option<String>,
#[serde(default)]
pub thinking_signature: Option<String>,
}
impl ChatMessage {
pub fn user(content: impl Into<String>) -> Self {
Self::new(MessageRole::User, content.into())
}
pub fn assistant(content: impl Into<String>) -> Self {
Self::new(MessageRole::Assistant, content.into())
}
pub fn system(content: impl Into<String>) -> Self {
Self::new(MessageRole::System, content.into())
}
pub fn tool(
tool_call_id: impl Into<String>,
tool_name: impl Into<String>,
content: impl Into<String>,
) -> Self {
Self {
role: MessageRole::Tool,
content: content.into(),
timestamp: chrono::Local::now(),
kind: ChatMessageKind::Normal,
metadata: None,
actions: Vec::new(),
thinking: None,
images: None,
tool_calls: None,
tool_call_id: Some(tool_call_id.into()),
tool_name: Some(tool_name.into()),
thinking_signature: None,
}
}
fn new(role: MessageRole, content: String) -> Self {
Self {
role,
content,
timestamp: chrono::Local::now(),
kind: ChatMessageKind::Normal,
metadata: None,
actions: Vec::new(),
thinking: None,
images: None,
tool_calls: None,
tool_call_id: None,
tool_name: None,
thinking_signature: None,
}
}
pub fn with_images(mut self, images: Vec<String>) -> Self {
self.images = Some(images);
self
}
pub fn with_tool_calls(mut self, tool_calls: Vec<crate::models::tool_call::ToolCall>) -> Self {
self.tool_calls = if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
};
self
}
pub fn with_thinking_signature(mut self, signature: impl Into<String>) -> Self {
self.thinking_signature = Some(signature.into());
self
}
pub fn extract_thinking(text: &str) -> (Option<String>, String) {
let Some(thinking_start) = text.find("Thinking...") else {
return (None, text.to_string());
};
let content_start = thinking_start + "Thinking...".len();
if let Some(thinking_end) = text.find("...done thinking.") {
let thinking_text = text[content_start..thinking_end].trim().to_string();
let answer_start = thinking_end + "...done thinking.".len();
let answer_text = text[answer_start..].trim().to_string();
return (Some(thinking_text), answer_text);
}
let thinking_text = text[content_start..].trim().to_string();
(Some(thinking_text), String::new())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum MessageRole {
User,
Assistant,
System,
Tool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChatMessageKind {
#[default]
Normal,
ContextCheckpoint,
}
#[derive(Debug, Clone)]
pub struct ModelResponse {
pub content: String,
pub usage: Option<TokenUsage>,
pub model_name: String,
pub thinking: Option<String>,
pub tool_calls: Option<Vec<crate::models::tool_call::ToolCall>>,
pub thinking_signature: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TokenUsageSource {
#[default]
Provider,
Estimate,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenUsage {
pub prompt_tokens: usize,
pub completion_tokens: usize,
pub total_tokens: usize,
#[serde(default)]
pub cached_input_tokens: usize,
#[serde(default)]
pub cache_creation_input_tokens: usize,
#[serde(default)]
pub reasoning_output_tokens: usize,
#[serde(default)]
pub source: TokenUsageSource,
}
impl TokenUsage {
pub fn provider(prompt_tokens: usize, completion_tokens: usize, total_tokens: usize) -> Self {
Self {
prompt_tokens,
completion_tokens,
total_tokens,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
reasoning_output_tokens: 0,
source: TokenUsageSource::Provider,
}
}
pub fn estimate(prompt_tokens: usize) -> Self {
Self {
prompt_tokens,
completion_tokens: 0,
total_tokens: prompt_tokens,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
reasoning_output_tokens: 0,
source: TokenUsageSource::Estimate,
}
}
pub fn with_cached_input(mut self, cached_input_tokens: usize) -> Self {
self.cached_input_tokens = cached_input_tokens;
self
}
pub fn with_cache_creation(mut self, cache_creation_input_tokens: usize) -> Self {
self.cache_creation_input_tokens = cache_creation_input_tokens;
self
}
pub fn with_reasoning_output(mut self, reasoning_output_tokens: usize) -> Self {
self.reasoning_output_tokens = reasoning_output_tokens;
self
}
pub fn input_total_tokens(&self) -> usize {
self.prompt_tokens
.saturating_add(self.cached_input_tokens)
.saturating_add(self.cache_creation_input_tokens)
}
pub fn output_total_tokens(&self) -> usize {
self.completion_tokens
.saturating_add(self.reasoning_output_tokens)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_message_role_equality() {
let user1 = MessageRole::User;
let user2 = MessageRole::User;
let assistant = MessageRole::Assistant;
assert_eq!(user1, user2, "User roles should be equal");
assert_ne!(user1, assistant, "Different roles should not be equal");
}
#[test]
fn test_chat_message_constructors() {
let user = ChatMessage::user("Hello!");
assert_eq!(user.role, MessageRole::User);
assert_eq!(user.content, "Hello!");
assert!(user.tool_calls.is_none());
let assistant = ChatMessage::assistant("Hi there");
assert_eq!(assistant.role, MessageRole::Assistant);
let system = ChatMessage::system("You are helpful");
assert_eq!(system.role, MessageRole::System);
let tool = ChatMessage::tool("call_1", "read_file", "file contents");
assert_eq!(tool.role, MessageRole::Tool);
assert_eq!(tool.tool_call_id, Some("call_1".to_string()));
assert_eq!(tool.tool_name, Some("read_file".to_string()));
}
#[test]
fn test_chat_message_builders() {
let msg = ChatMessage::user("test").with_images(vec!["base64data".to_string()]);
assert_eq!(msg.images, Some(vec!["base64data".to_string()]));
}
#[test]
fn test_token_usage_structure() {
let usage = TokenUsage::provider(100, 50, 150)
.with_cached_input(25)
.with_reasoning_output(10);
assert_eq!(usage.prompt_tokens, 100);
assert_eq!(usage.completion_tokens, 50);
assert_eq!(usage.total_tokens, 150);
assert_eq!(usage.cached_input_tokens, 25);
assert_eq!(usage.reasoning_output_tokens, 10);
assert_eq!(usage.source, TokenUsageSource::Provider);
}
#[test]
fn extract_thinking_no_marker_returns_text_unchanged() {
let (thinking, answer) = ChatMessage::extract_thinking("just a plain answer");
assert_eq!(thinking, None);
assert_eq!(answer, "just a plain answer");
}
#[test]
fn extract_thinking_complete_block() {
let raw = "Thinking...\n reasoning here\n...done thinking.\n\nFinal answer";
let (thinking, answer) = ChatMessage::extract_thinking(raw);
assert_eq!(thinking.as_deref(), Some("reasoning here"));
assert_eq!(answer, "Final answer");
}
#[test]
fn thinking_signature_round_trips_through_serde() {
let msg = ChatMessage::assistant("Step 3 lives.")
.with_thinking_signature("sig_abc123_encrypted_blob");
let json = serde_json::to_string(&msg).expect("serialize");
let back: ChatMessage = serde_json::from_str(&json).expect("deserialize");
assert_eq!(
back.thinking_signature.as_deref(),
Some("sig_abc123_encrypted_blob")
);
assert_eq!(back.content, "Step 3 lives.");
}
#[test]
fn thinking_signature_defaults_to_none() {
let pre_step3_json = r#"{
"role": "Assistant",
"content": "hello",
"timestamp": "2026-04-16T12:00:00-04:00"
}"#;
let msg: ChatMessage = serde_json::from_str(pre_step3_json).expect("backward compat");
assert!(msg.thinking_signature.is_none());
}
#[test]
fn extract_thinking_in_progress_no_end_marker() {
let raw = "Thinking...\n partial reasoning so far";
let (thinking, answer) = ChatMessage::extract_thinking(raw);
assert_eq!(thinking.as_deref(), Some("partial reasoning so far"));
assert_eq!(answer, "");
}
#[test]
fn test_model_response_creation() {
let usage = TokenUsage::provider(100, 50, 150);
let response = ModelResponse {
content: "Hello, world!".to_string(),
usage: Some(usage),
model_name: "ollama/tinyllama".to_string(),
thinking: None,
tool_calls: None,
thinking_signature: None,
};
assert_eq!(response.content, "Hello, world!");
assert!(response.usage.is_some());
assert_eq!(response.model_name, "ollama/tinyllama");
assert_eq!(response.usage.unwrap().total_tokens, 150);
assert!(response.tool_calls.is_none());
}
}