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 ContextMarker,
345 #[serde(other)]
351 Unknown,
352}
353
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367pub enum MessageAudience {
368 Conversation,
370 ModelDirected,
374}
375
376impl ChatMessageKind {
377 pub fn audience(self) -> MessageAudience {
384 match self {
385 ChatMessageKind::RecoveryNudge | ChatMessageKind::ContextMarker => {
388 MessageAudience::ModelDirected
389 },
390 ChatMessageKind::Normal
391 | ChatMessageKind::ContextCheckpoint
392 | ChatMessageKind::RunSummary
393 | ChatMessageKind::Continuation
394 | ChatMessageKind::Unknown => MessageAudience::Conversation,
395 }
396 }
397}
398
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
407#[serde(rename_all = "snake_case")]
408pub enum FinishReason {
409 Stop,
411 ToolUse,
413 Length,
415 ContentFilter,
417 Other(String),
419}
420
421#[derive(Debug, Clone)]
423pub struct ModelResponse {
424 pub content: String,
426 pub usage: Option<TokenUsage>,
428 pub model_name: String,
430 pub thinking: Option<String>,
432 pub tool_calls: Option<Vec<crate::models::tool_call::ToolCall>>,
434 pub stop_reason: Option<FinishReason>,
437 pub provider_continuation: Option<ProviderContinuation>,
439}
440
441#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
445#[serde(rename_all = "snake_case")]
446pub enum TokenUsageSource {
447 #[default]
448 Provider,
449 Estimate,
450}
451
452#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
466pub struct TokenUsage {
467 pub prompt_tokens: usize,
468 pub completion_tokens: usize,
469 #[serde(default)]
470 pub cached_input_tokens: usize,
471 #[serde(default)]
472 pub cache_creation_input_tokens: usize,
473 #[serde(default)]
474 pub reasoning_output_tokens: usize,
475 #[serde(default)]
476 pub source: TokenUsageSource,
477}
478
479impl TokenUsage {
480 pub fn provider(prompt_tokens: usize, completion_tokens: usize) -> Self {
481 Self {
482 prompt_tokens,
483 completion_tokens,
484 cached_input_tokens: 0,
485 cache_creation_input_tokens: 0,
486 reasoning_output_tokens: 0,
487 source: TokenUsageSource::Provider,
488 }
489 }
490
491 pub fn estimate(prompt_tokens: usize) -> Self {
492 Self {
493 prompt_tokens,
494 completion_tokens: 0,
495 cached_input_tokens: 0,
496 cache_creation_input_tokens: 0,
497 reasoning_output_tokens: 0,
498 source: TokenUsageSource::Estimate,
499 }
500 }
501
502 pub fn with_cached_input(mut self, cached_input_tokens: usize) -> Self {
503 self.cached_input_tokens = cached_input_tokens;
504 self
505 }
506
507 pub fn with_cache_creation(mut self, cache_creation_input_tokens: usize) -> Self {
508 self.cache_creation_input_tokens = cache_creation_input_tokens;
509 self
510 }
511
512 pub fn with_reasoning_output(mut self, reasoning_output_tokens: usize) -> Self {
513 self.reasoning_output_tokens = reasoning_output_tokens;
514 self
515 }
516
517 pub fn input_total_tokens(&self) -> usize {
518 self.prompt_tokens
519 .saturating_add(self.cached_input_tokens)
520 .saturating_add(self.cache_creation_input_tokens)
521 }
522
523 pub fn output_total_tokens(&self) -> usize {
524 self.completion_tokens
525 .saturating_add(self.reasoning_output_tokens)
526 }
527
528 pub fn total_tokens(&self) -> usize {
532 self.input_total_tokens()
533 .saturating_add(self.output_total_tokens())
534 }
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 #[test]
542 fn test_message_role_equality() {
543 let user1 = MessageRole::User;
544 let user2 = MessageRole::User;
545 let assistant = MessageRole::Assistant;
546
547 assert_eq!(user1, user2, "User roles should be equal");
548 assert_ne!(user1, assistant, "Different roles should not be equal");
549 }
550
551 #[test]
552 fn test_chat_message_constructors() {
553 let user = ChatMessage::user("Hello!");
554 assert_eq!(user.role, MessageRole::User);
555 assert_eq!(user.content, "Hello!");
556 assert!(user.tool_calls.is_none());
557
558 let assistant = ChatMessage::assistant("Hi there");
559 assert_eq!(assistant.role, MessageRole::Assistant);
560
561 let system = ChatMessage::system("You are helpful");
562 assert_eq!(system.role, MessageRole::System);
563
564 let tool = ChatMessage::tool("call_1", "read_file", "file contents");
565 assert_eq!(tool.role, MessageRole::Tool);
566 assert_eq!(tool.tool_call_id, Some("call_1".to_string()));
567 assert_eq!(tool.tool_name, Some("read_file".to_string()));
568 }
569
570 #[test]
571 fn test_chat_message_builders() {
572 let msg = ChatMessage::user("test").with_images(vec!["base64data".to_string()]);
573 assert_eq!(msg.images, Some(vec!["base64data".to_string()]));
574 }
575
576 #[test]
577 fn test_token_usage_structure() {
578 let usage = TokenUsage::provider(100, 50)
579 .with_cached_input(25)
580 .with_cache_creation(5)
581 .with_reasoning_output(10);
582
583 assert_eq!(usage.prompt_tokens, 100);
584 assert_eq!(usage.completion_tokens, 50);
585 assert_eq!(usage.cached_input_tokens, 25);
586 assert_eq!(usage.cache_creation_input_tokens, 5);
587 assert_eq!(usage.reasoning_output_tokens, 10);
588 assert_eq!(usage.input_total_tokens(), 130);
589 assert_eq!(usage.output_total_tokens(), 60);
590 assert_eq!(usage.total_tokens(), 190);
591 assert_eq!(usage.source, TokenUsageSource::Provider);
592 }
593
594 #[test]
597 fn extract_thinking_no_marker_returns_text_unchanged() {
598 let (thinking, answer) = ChatMessage::extract_thinking("just a plain answer");
599 assert_eq!(thinking, None);
600 assert_eq!(answer, "just a plain answer");
601 }
602
603 #[test]
604 fn extract_thinking_complete_block() {
605 let raw = "Thinking...\n reasoning here\n...done thinking.\n\nFinal answer";
606 let (thinking, answer) = ChatMessage::extract_thinking(raw);
607 assert_eq!(thinking.as_deref(), Some("reasoning here"));
608 assert_eq!(answer, "Final answer");
609 }
610
611 #[test]
612 fn provider_continuation_round_trips_through_serde() {
613 let msg = ChatMessage::assistant("Step 3 lives.").with_provider_continuation(
616 ProviderContinuation::Anthropic {
617 signature: "sig_abc123_encrypted_blob".to_string(),
618 },
619 );
620 let json = serde_json::to_string(&msg).expect("serialize");
621 let back: ChatMessage = serde_json::from_str(&json).expect("deserialize");
622 assert_eq!(
623 back.provider_continuation
624 .as_ref()
625 .and_then(ProviderContinuation::anthropic_signature),
626 Some("sig_abc123_encrypted_blob")
627 );
628 assert_eq!(back.content, "Step 3 lives.");
629 }
630
631 #[test]
632 fn provider_continuation_defaults_to_none() {
633 let pre_step3_json = r#"{
637 "role": "Assistant",
638 "content": "hello",
639 "timestamp": "2026-04-16T12:00:00-04:00"
640 }"#;
641 let msg: ChatMessage = serde_json::from_str(pre_step3_json).expect("backward compat");
642 assert!(msg.provider_continuation.is_none());
643 }
644
645 #[test]
646 fn meta_encrypted_continuation_survives_persistence_redaction_byte_exact() {
647 let original = "eyJopaque.reasoning.payload";
648 let message = ChatMessage::assistant("done").with_provider_continuation(
649 ProviderContinuation::MetaResponses {
650 output: vec![MetaResponseItem::from_wire(serde_json::json!({
651 "type": "reasoning",
652 "id": "rs_1",
653 "summary": [],
654 "encrypted_content": original,
655 }))],
656 },
657 );
658 let mut persisted = serde_json::to_value(message).unwrap();
659 crate::utils::redact_json(&mut persisted);
660 let restored: ChatMessage = serde_json::from_value(persisted).unwrap();
661 let output = restored
662 .provider_continuation
663 .as_ref()
664 .and_then(ProviderContinuation::meta_output)
665 .unwrap();
666 assert_eq!(output[0].to_wire()["encrypted_content"], original);
667 }
668
669 #[test]
670 fn unknown_message_role_deserializes_to_system() {
671 let role: MessageRole = serde_json::from_str("\"Developer\"").expect("tolerant");
675 assert_eq!(role, MessageRole::System);
676 assert_eq!(
678 serde_json::from_str::<MessageRole>("\"Tool\"").unwrap(),
679 MessageRole::Tool
680 );
681 }
682
683 #[test]
684 fn unknown_message_kind_deserializes_to_unknown() {
685 let kind: ChatMessageKind = serde_json::from_str("\"some_future_kind\"").expect("tolerant");
688 assert_eq!(kind, ChatMessageKind::Unknown);
689 assert_ne!(kind, ChatMessageKind::Normal);
690 }
691
692 #[test]
693 fn continuation_kinds_round_trip_through_serde() {
694 for kind in [
697 ChatMessageKind::Continuation,
698 ChatMessageKind::RecoveryNudge,
699 ] {
700 let json = serde_json::to_string(&kind).unwrap();
701 let back: ChatMessageKind = serde_json::from_str(&json).unwrap();
702 assert_eq!(back, kind);
703 }
704 assert_eq!(
706 serde_json::to_string(&ChatMessageKind::Continuation).unwrap(),
707 "\"continuation\""
708 );
709 assert_eq!(
710 serde_json::to_string(&ChatMessageKind::RecoveryNudge).unwrap(),
711 "\"recovery_nudge\""
712 );
713 }
714
715 #[test]
716 fn chat_message_with_unknown_role_round_trips() {
717 let json = r#"{
719 "role": "Developer",
720 "content": "hi",
721 "timestamp": "2026-04-16T12:00:00-04:00"
722 }"#;
723 let msg: ChatMessage = serde_json::from_str(json).expect("tolerant");
724 assert_eq!(msg.role, MessageRole::System);
725 assert_eq!(msg.content, "hi");
726 }
727
728 #[test]
729 fn extract_thinking_in_progress_no_end_marker() {
730 let raw = "Thinking...\n partial reasoning so far";
731 let (thinking, answer) = ChatMessage::extract_thinking(raw);
732 assert_eq!(thinking.as_deref(), Some("partial reasoning so far"));
733 assert_eq!(answer, "");
734 }
735
736 #[test]
737 fn test_model_response_creation() {
738 let usage = TokenUsage::provider(100, 50);
739
740 let response = ModelResponse {
741 content: "Hello, world!".to_string(),
742 usage: Some(usage),
743 model_name: "ollama/tinyllama".to_string(),
744 thinking: None,
745 tool_calls: None,
746 stop_reason: None,
747 provider_continuation: None,
748 };
749
750 assert_eq!(response.content, "Hello, world!");
751 assert!(response.usage.is_some());
752 assert_eq!(response.model_name, "ollama/tinyllama");
753 assert_eq!(response.usage.unwrap().total_tokens(), 150);
754 assert!(response.tool_calls.is_none());
755 }
756}