1use crate::domain::ActionDisplay;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8#[serde(tag = "provider", rename_all = "snake_case")]
9pub enum ProviderContinuation {
10 Anthropic { signature: String },
12 MetaResponses { output: Vec<MetaResponseItem> },
14}
15
16impl ProviderContinuation {
17 pub fn anthropic_signature(&self) -> Option<&str> {
18 match self {
19 Self::Anthropic { signature } => Some(signature),
20 Self::MetaResponses { .. } => None,
21 }
22 }
23
24 pub fn meta_output(&self) -> Option<&[MetaResponseItem]> {
25 match self {
26 Self::MetaResponses { output } => Some(output),
27 Self::Anthropic { .. } => None,
28 }
29 }
30
31 pub fn retain_meta_function_calls(&mut self, mut keep: impl FnMut(&str) -> bool) {
32 if let Self::MetaResponses { output } = self {
33 output.retain(|item| item.function_call_id().is_none_or(&mut keep));
34 }
35 }
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43#[serde(tag = "kind", rename_all = "snake_case")]
44pub enum MetaResponseItem {
45 Reasoning {
46 item: serde_json::Value,
47 #[serde(with = "crate::utils::serde_base64::string")]
48 encrypted_content: String,
49 },
50 Other {
51 item: serde_json::Value,
52 },
53}
54
55impl MetaResponseItem {
56 pub fn from_wire(mut item: serde_json::Value) -> Self {
57 let is_reasoning =
58 item.get("type").and_then(serde_json::Value::as_str) == Some("reasoning");
59 if is_reasoning
60 && let Some(encrypted) = item
61 .as_object_mut()
62 .and_then(|object| object.remove("encrypted_content"))
63 .and_then(|value| value.as_str().map(str::to_string))
64 {
65 return Self::Reasoning {
66 item,
67 encrypted_content: encrypted,
68 };
69 }
70 Self::Other { item }
71 }
72
73 pub fn to_wire(&self) -> serde_json::Value {
74 match self {
75 Self::Reasoning {
76 item,
77 encrypted_content,
78 } => {
79 let mut item = item.clone();
80 if let Some(object) = item.as_object_mut() {
81 object.insert(
82 "encrypted_content".to_string(),
83 serde_json::Value::String(encrypted_content.clone()),
84 );
85 }
86 item
87 },
88 Self::Other { item } => item.clone(),
89 }
90 }
91
92 pub fn function_call_id(&self) -> Option<&str> {
93 let item = match self {
94 Self::Reasoning { item, .. } | Self::Other { item } => item,
95 };
96 (item.get("type").and_then(serde_json::Value::as_str) == Some("function_call"))
97 .then(|| item.get("call_id").and_then(serde_json::Value::as_str))
98 .flatten()
99 }
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct ChatMessage {
105 pub role: MessageRole,
106 pub content: String,
107 pub timestamp: chrono::DateTime<chrono::Local>,
108 #[serde(default)]
112 pub kind: ChatMessageKind,
113 #[serde(default)]
115 pub metadata: Option<serde_json::Value>,
116 #[serde(default)]
118 pub actions: Vec<ActionDisplay>,
119 #[serde(default)]
121 pub thinking: Option<String>,
122 #[serde(default)]
124 pub images: Option<Vec<String>>,
125 #[serde(default)]
131 pub image_numbers: Option<Vec<u64>>,
132 #[serde(default)]
134 pub tool_calls: Option<Vec<crate::models::tool_call::ToolCall>>,
135 #[serde(default)]
138 pub tool_call_id: Option<String>,
139 #[serde(default)]
142 pub tool_name: Option<String>,
143 #[serde(default)]
147 pub provider_continuation: Option<ProviderContinuation>,
148}
149
150impl ChatMessage {
151 pub fn user(content: impl Into<String>) -> Self {
153 Self::new(MessageRole::User, content.into())
154 }
155
156 pub fn assistant(content: impl Into<String>) -> Self {
158 Self::new(MessageRole::Assistant, content.into())
159 }
160
161 pub fn system(content: impl Into<String>) -> Self {
163 Self::new(MessageRole::System, content.into())
164 }
165
166 pub fn run_summary(content: impl Into<String>) -> Self {
170 let mut m = Self::new(MessageRole::System, content.into());
171 m.kind = ChatMessageKind::RunSummary;
172 m
173 }
174
175 pub fn tool(
177 tool_call_id: impl Into<String>,
178 tool_name: impl Into<String>,
179 content: impl Into<String>,
180 ) -> Self {
181 Self {
182 role: MessageRole::Tool,
183 content: content.into(),
184 timestamp: chrono::Local::now(),
185 kind: ChatMessageKind::Normal,
186 metadata: None,
187 actions: Vec::new(),
188 thinking: None,
189 images: None,
190 image_numbers: None,
191 tool_calls: None,
192 tool_call_id: Some(tool_call_id.into()),
193 tool_name: Some(tool_name.into()),
194 provider_continuation: None,
195 }
196 }
197
198 fn new(role: MessageRole, content: String) -> Self {
200 Self {
201 role,
202 content,
203 timestamp: chrono::Local::now(),
204 kind: ChatMessageKind::Normal,
205 metadata: None,
206 actions: Vec::new(),
207 thinking: None,
208 images: None,
209 image_numbers: None,
210 tool_calls: None,
211 tool_call_id: None,
212 tool_name: None,
213 provider_continuation: None,
214 }
215 }
216
217 pub fn with_images(mut self, images: Vec<String>) -> Self {
219 self.images = Some(images);
220 self
221 }
222
223 pub fn with_image_numbers(mut self, numbers: Vec<u64>) -> Self {
227 self.image_numbers = Some(numbers);
228 self
229 }
230
231 pub fn with_tool_calls(mut self, tool_calls: Vec<crate::models::tool_call::ToolCall>) -> Self {
233 self.tool_calls = if tool_calls.is_empty() {
234 None
235 } else {
236 Some(tool_calls)
237 };
238 self
239 }
240
241 pub fn with_provider_continuation(mut self, continuation: ProviderContinuation) -> Self {
243 self.provider_continuation = Some(continuation);
244 self
245 }
246
247 pub fn extract_thinking(text: &str) -> (Option<String>, String) {
258 let Some(thinking_start) = text.find("Thinking...") else {
259 return (None, text.to_string());
260 };
261 let content_start = thinking_start + "Thinking...".len();
262
263 if let Some(thinking_end) = text.find("...done thinking.") {
264 let thinking_text = text[content_start..thinking_end].trim().to_string();
265 let answer_start = thinking_end + "...done thinking.".len();
266 let answer_text = text[answer_start..].trim().to_string();
267 return (Some(thinking_text), answer_text);
268 }
269
270 let thinking_text = text[content_start..].trim().to_string();
272 (Some(thinking_text), String::new())
273 }
274}
275
276#[derive(Debug, Clone, PartialEq, Serialize)]
277pub enum MessageRole {
278 User,
279 Assistant,
280 System,
281 Tool,
283}
284
285impl<'de> Deserialize<'de> for MessageRole {
299 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
300 where
301 D: serde::Deserializer<'de>,
302 {
303 let raw = String::deserialize(deserializer)?;
304 Ok(match raw.as_str() {
305 "User" => MessageRole::User,
306 "Assistant" => MessageRole::Assistant,
307 "System" => MessageRole::System,
308 "Tool" => MessageRole::Tool,
309 other => {
310 tracing::warn!(
311 role = %other,
312 "unknown message role in saved conversation; treating as System (version skew?)"
313 );
314 MessageRole::System
315 },
316 })
317 }
318}
319
320#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
321#[serde(rename_all = "snake_case")]
322pub enum ChatMessageKind {
323 #[default]
324 Normal,
325 ContextCheckpoint,
326 RunSummary,
329 Continuation,
334 RecoveryNudge,
339 #[serde(other)]
345 Unknown,
346}
347
348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356#[serde(rename_all = "snake_case")]
357pub enum FinishReason {
358 Stop,
360 ToolUse,
362 Length,
364 ContentFilter,
366 Other(String),
368}
369
370#[derive(Debug, Clone)]
372pub struct ModelResponse {
373 pub content: String,
375 pub usage: Option<TokenUsage>,
377 pub model_name: String,
379 pub thinking: Option<String>,
381 pub tool_calls: Option<Vec<crate::models::tool_call::ToolCall>>,
383 pub stop_reason: Option<FinishReason>,
386 pub provider_continuation: Option<ProviderContinuation>,
388}
389
390#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
394#[serde(rename_all = "snake_case")]
395pub enum TokenUsageSource {
396 #[default]
397 Provider,
398 Estimate,
399}
400
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
415pub struct TokenUsage {
416 pub prompt_tokens: usize,
417 pub completion_tokens: usize,
418 #[serde(default)]
419 pub cached_input_tokens: usize,
420 #[serde(default)]
421 pub cache_creation_input_tokens: usize,
422 #[serde(default)]
423 pub reasoning_output_tokens: usize,
424 #[serde(default)]
425 pub source: TokenUsageSource,
426}
427
428impl TokenUsage {
429 pub fn provider(prompt_tokens: usize, completion_tokens: usize) -> Self {
430 Self {
431 prompt_tokens,
432 completion_tokens,
433 cached_input_tokens: 0,
434 cache_creation_input_tokens: 0,
435 reasoning_output_tokens: 0,
436 source: TokenUsageSource::Provider,
437 }
438 }
439
440 pub fn estimate(prompt_tokens: usize) -> Self {
441 Self {
442 prompt_tokens,
443 completion_tokens: 0,
444 cached_input_tokens: 0,
445 cache_creation_input_tokens: 0,
446 reasoning_output_tokens: 0,
447 source: TokenUsageSource::Estimate,
448 }
449 }
450
451 pub fn with_cached_input(mut self, cached_input_tokens: usize) -> Self {
452 self.cached_input_tokens = cached_input_tokens;
453 self
454 }
455
456 pub fn with_cache_creation(mut self, cache_creation_input_tokens: usize) -> Self {
457 self.cache_creation_input_tokens = cache_creation_input_tokens;
458 self
459 }
460
461 pub fn with_reasoning_output(mut self, reasoning_output_tokens: usize) -> Self {
462 self.reasoning_output_tokens = reasoning_output_tokens;
463 self
464 }
465
466 pub fn input_total_tokens(&self) -> usize {
467 self.prompt_tokens
468 .saturating_add(self.cached_input_tokens)
469 .saturating_add(self.cache_creation_input_tokens)
470 }
471
472 pub fn output_total_tokens(&self) -> usize {
473 self.completion_tokens
474 .saturating_add(self.reasoning_output_tokens)
475 }
476
477 pub fn total_tokens(&self) -> usize {
481 self.input_total_tokens()
482 .saturating_add(self.output_total_tokens())
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 #[test]
491 fn test_message_role_equality() {
492 let user1 = MessageRole::User;
493 let user2 = MessageRole::User;
494 let assistant = MessageRole::Assistant;
495
496 assert_eq!(user1, user2, "User roles should be equal");
497 assert_ne!(user1, assistant, "Different roles should not be equal");
498 }
499
500 #[test]
501 fn test_chat_message_constructors() {
502 let user = ChatMessage::user("Hello!");
503 assert_eq!(user.role, MessageRole::User);
504 assert_eq!(user.content, "Hello!");
505 assert!(user.tool_calls.is_none());
506
507 let assistant = ChatMessage::assistant("Hi there");
508 assert_eq!(assistant.role, MessageRole::Assistant);
509
510 let system = ChatMessage::system("You are helpful");
511 assert_eq!(system.role, MessageRole::System);
512
513 let tool = ChatMessage::tool("call_1", "read_file", "file contents");
514 assert_eq!(tool.role, MessageRole::Tool);
515 assert_eq!(tool.tool_call_id, Some("call_1".to_string()));
516 assert_eq!(tool.tool_name, Some("read_file".to_string()));
517 }
518
519 #[test]
520 fn test_chat_message_builders() {
521 let msg = ChatMessage::user("test").with_images(vec!["base64data".to_string()]);
522 assert_eq!(msg.images, Some(vec!["base64data".to_string()]));
523 }
524
525 #[test]
526 fn test_token_usage_structure() {
527 let usage = TokenUsage::provider(100, 50)
528 .with_cached_input(25)
529 .with_cache_creation(5)
530 .with_reasoning_output(10);
531
532 assert_eq!(usage.prompt_tokens, 100);
533 assert_eq!(usage.completion_tokens, 50);
534 assert_eq!(usage.cached_input_tokens, 25);
535 assert_eq!(usage.cache_creation_input_tokens, 5);
536 assert_eq!(usage.reasoning_output_tokens, 10);
537 assert_eq!(usage.input_total_tokens(), 130);
538 assert_eq!(usage.output_total_tokens(), 60);
539 assert_eq!(usage.total_tokens(), 190);
540 assert_eq!(usage.source, TokenUsageSource::Provider);
541 }
542
543 #[test]
546 fn extract_thinking_no_marker_returns_text_unchanged() {
547 let (thinking, answer) = ChatMessage::extract_thinking("just a plain answer");
548 assert_eq!(thinking, None);
549 assert_eq!(answer, "just a plain answer");
550 }
551
552 #[test]
553 fn extract_thinking_complete_block() {
554 let raw = "Thinking...\n reasoning here\n...done thinking.\n\nFinal answer";
555 let (thinking, answer) = ChatMessage::extract_thinking(raw);
556 assert_eq!(thinking.as_deref(), Some("reasoning here"));
557 assert_eq!(answer, "Final answer");
558 }
559
560 #[test]
561 fn provider_continuation_round_trips_through_serde() {
562 let msg = ChatMessage::assistant("Step 3 lives.").with_provider_continuation(
565 ProviderContinuation::Anthropic {
566 signature: "sig_abc123_encrypted_blob".to_string(),
567 },
568 );
569 let json = serde_json::to_string(&msg).expect("serialize");
570 let back: ChatMessage = serde_json::from_str(&json).expect("deserialize");
571 assert_eq!(
572 back.provider_continuation
573 .as_ref()
574 .and_then(ProviderContinuation::anthropic_signature),
575 Some("sig_abc123_encrypted_blob")
576 );
577 assert_eq!(back.content, "Step 3 lives.");
578 }
579
580 #[test]
581 fn provider_continuation_defaults_to_none() {
582 let pre_step3_json = r#"{
586 "role": "Assistant",
587 "content": "hello",
588 "timestamp": "2026-04-16T12:00:00-04:00"
589 }"#;
590 let msg: ChatMessage = serde_json::from_str(pre_step3_json).expect("backward compat");
591 assert!(msg.provider_continuation.is_none());
592 }
593
594 #[test]
595 fn meta_encrypted_continuation_survives_persistence_redaction_byte_exact() {
596 let original = "eyJopaque.reasoning.payload";
597 let message = ChatMessage::assistant("done").with_provider_continuation(
598 ProviderContinuation::MetaResponses {
599 output: vec![MetaResponseItem::from_wire(serde_json::json!({
600 "type": "reasoning",
601 "id": "rs_1",
602 "summary": [],
603 "encrypted_content": original,
604 }))],
605 },
606 );
607 let mut persisted = serde_json::to_value(message).unwrap();
608 crate::utils::redact_json(&mut persisted);
609 let restored: ChatMessage = serde_json::from_value(persisted).unwrap();
610 let output = restored
611 .provider_continuation
612 .as_ref()
613 .and_then(ProviderContinuation::meta_output)
614 .unwrap();
615 assert_eq!(output[0].to_wire()["encrypted_content"], original);
616 }
617
618 #[test]
619 fn unknown_message_role_deserializes_to_system() {
620 let role: MessageRole = serde_json::from_str("\"Developer\"").expect("tolerant");
624 assert_eq!(role, MessageRole::System);
625 assert_eq!(
627 serde_json::from_str::<MessageRole>("\"Tool\"").unwrap(),
628 MessageRole::Tool
629 );
630 }
631
632 #[test]
633 fn unknown_message_kind_deserializes_to_unknown() {
634 let kind: ChatMessageKind = serde_json::from_str("\"some_future_kind\"").expect("tolerant");
637 assert_eq!(kind, ChatMessageKind::Unknown);
638 assert_ne!(kind, ChatMessageKind::Normal);
639 }
640
641 #[test]
642 fn continuation_kinds_round_trip_through_serde() {
643 for kind in [
646 ChatMessageKind::Continuation,
647 ChatMessageKind::RecoveryNudge,
648 ] {
649 let json = serde_json::to_string(&kind).unwrap();
650 let back: ChatMessageKind = serde_json::from_str(&json).unwrap();
651 assert_eq!(back, kind);
652 }
653 assert_eq!(
655 serde_json::to_string(&ChatMessageKind::Continuation).unwrap(),
656 "\"continuation\""
657 );
658 assert_eq!(
659 serde_json::to_string(&ChatMessageKind::RecoveryNudge).unwrap(),
660 "\"recovery_nudge\""
661 );
662 }
663
664 #[test]
665 fn chat_message_with_unknown_role_round_trips() {
666 let json = r#"{
668 "role": "Developer",
669 "content": "hi",
670 "timestamp": "2026-04-16T12:00:00-04:00"
671 }"#;
672 let msg: ChatMessage = serde_json::from_str(json).expect("tolerant");
673 assert_eq!(msg.role, MessageRole::System);
674 assert_eq!(msg.content, "hi");
675 }
676
677 #[test]
678 fn extract_thinking_in_progress_no_end_marker() {
679 let raw = "Thinking...\n partial reasoning so far";
680 let (thinking, answer) = ChatMessage::extract_thinking(raw);
681 assert_eq!(thinking.as_deref(), Some("partial reasoning so far"));
682 assert_eq!(answer, "");
683 }
684
685 #[test]
686 fn test_model_response_creation() {
687 let usage = TokenUsage::provider(100, 50);
688
689 let response = ModelResponse {
690 content: "Hello, world!".to_string(),
691 usage: Some(usage),
692 model_name: "ollama/tinyllama".to_string(),
693 thinking: None,
694 tool_calls: None,
695 stop_reason: None,
696 provider_continuation: None,
697 };
698
699 assert_eq!(response.content, "Hello, world!");
700 assert!(response.usage.is_some());
701 assert_eq!(response.model_name, "ollama/tinyllama");
702 assert_eq!(response.usage.unwrap().total_tokens(), 150);
703 assert!(response.tool_calls.is_none());
704 }
705}