1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(rename_all = "camelCase")]
8pub struct ConversationEntry {
9 #[serde(skip_serializing_if = "Option::is_none")]
10 pub parent_uuid: Option<String>,
11
12 #[serde(default)]
13 pub is_sidechain: bool,
14
15 #[serde(rename = "type")]
16 pub entry_type: String,
17
18 #[serde(default)]
19 pub uuid: String,
20
21 #[serde(default)]
22 pub timestamp: String,
23
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub session_id: Option<String>,
26
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub cwd: Option<String>,
29
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub git_branch: Option<String>,
32
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub version: Option<String>,
35
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub message: Option<Message>,
38
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub user_type: Option<String>,
41
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub request_id: Option<String>,
44
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub tool_use_result: Option<Value>,
47
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub snapshot: Option<Value>,
50
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub message_id: Option<String>,
53
54 #[serde(flatten)]
55 pub extra: HashMap<String, Value>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub struct Message {
61 pub role: MessageRole,
62
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub content: Option<MessageContent>,
65
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub model: Option<String>,
68
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub id: Option<String>,
71
72 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
73 pub message_type: Option<String>,
74
75 #[serde(skip_serializing_if = "Option::is_none", alias = "stop_reason")]
76 pub stop_reason: Option<String>,
77
78 #[serde(skip_serializing_if = "Option::is_none", alias = "stop_sequence")]
79 pub stop_sequence: Option<String>,
80
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub usage: Option<Usage>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86#[serde(untagged)]
87pub enum MessageContent {
88 Text(String),
89 Parts(Vec<ContentPart>),
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(tag = "type", rename_all = "snake_case")]
94pub enum ContentPart {
95 Text {
96 text: String,
97 },
98 Thinking {
99 thinking: String,
100 #[serde(default)]
101 signature: Option<String>,
102 },
103 ToolUse {
104 id: String,
105 name: String,
106 input: Value,
107 },
108 ToolResult {
109 tool_use_id: String,
110 content: ToolResultContent,
111 #[serde(default)]
112 is_error: bool,
113 },
114 #[serde(other)]
116 Unknown,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120#[serde(untagged)]
121pub enum ToolResultContent {
122 Text(String),
123 Parts(Vec<ToolResultPart>),
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ToolResultPart {
128 #[serde(default)]
129 pub text: Option<String>,
130}
131
132impl ToolResultContent {
133 pub fn text(&self) -> String {
134 match self {
135 ToolResultContent::Text(s) => s.clone(),
136 ToolResultContent::Parts(parts) => parts
137 .iter()
138 .filter_map(|p| p.text.as_deref())
139 .collect::<Vec<_>>()
140 .join("\n"),
141 }
142 }
143}
144
145#[derive(Debug)]
147pub struct ToolUseRef<'a> {
148 pub id: &'a str,
149 pub name: &'a str,
150 pub input: &'a Value,
151}
152
153#[derive(Debug)]
155pub struct ToolResultRef<'a> {
156 pub tool_use_id: &'a str,
157 pub content: &'a ToolResultContent,
158 pub is_error: bool,
159}
160
161impl Message {
162 pub fn text(&self) -> String {
166 match &self.content {
167 Some(MessageContent::Text(t)) => t.clone(),
168 Some(MessageContent::Parts(parts)) => parts
169 .iter()
170 .filter_map(|p| match p {
171 ContentPart::Text { text } => Some(text.as_str()),
172 _ => None,
173 })
174 .collect::<Vec<_>>()
175 .join("\n"),
176 None => String::new(),
177 }
178 }
179
180 pub fn thinking(&self) -> Option<Vec<&str>> {
184 let parts = match &self.content {
185 Some(MessageContent::Parts(parts)) => parts,
186 _ => return None,
187 };
188 let thinking: Vec<&str> = parts
189 .iter()
190 .filter_map(|p| match p {
191 ContentPart::Thinking { thinking, .. } => Some(thinking.as_str()),
192 _ => None,
193 })
194 .collect();
195 if thinking.is_empty() {
196 None
197 } else {
198 Some(thinking)
199 }
200 }
201
202 pub fn tool_uses(&self) -> Vec<ToolUseRef<'_>> {
204 let parts = match &self.content {
205 Some(MessageContent::Parts(parts)) => parts,
206 _ => return Vec::new(),
207 };
208 parts
209 .iter()
210 .filter_map(|p| match p {
211 ContentPart::ToolUse { id, name, input } => Some(ToolUseRef { id, name, input }),
212 _ => None,
213 })
214 .collect()
215 }
216
217 pub fn tool_results(&self) -> Vec<ToolResultRef<'_>> {
219 let parts = match &self.content {
220 Some(MessageContent::Parts(parts)) => parts,
221 _ => return Vec::new(),
222 };
223 parts
224 .iter()
225 .filter_map(|p| match p {
226 ContentPart::ToolResult {
227 tool_use_id,
228 content,
229 is_error,
230 } => Some(ToolResultRef {
231 tool_use_id,
232 content,
233 is_error: *is_error,
234 }),
235 _ => None,
236 })
237 .collect()
238 }
239
240 pub fn is_role(&self, role: MessageRole) -> bool {
242 self.role == role
243 }
244
245 pub fn is_user(&self) -> bool {
247 self.role == MessageRole::User
248 }
249
250 pub fn is_assistant(&self) -> bool {
252 self.role == MessageRole::Assistant
253 }
254}
255
256impl ConversationEntry {
257 pub fn role(&self) -> Option<&MessageRole> {
259 self.message.as_ref().map(|m| &m.role)
260 }
261
262 pub fn text(&self) -> String {
266 self.message.as_ref().map(|m| m.text()).unwrap_or_default()
267 }
268
269 pub fn thinking(&self) -> Option<Vec<&str>> {
271 self.message.as_ref().and_then(|m| m.thinking())
272 }
273
274 pub fn tool_uses(&self) -> Vec<ToolUseRef<'_>> {
276 self.message
277 .as_ref()
278 .map(|m| m.tool_uses())
279 .unwrap_or_default()
280 }
281
282 pub fn stop_reason(&self) -> Option<&str> {
284 self.message.as_ref().and_then(|m| m.stop_reason.as_deref())
285 }
286
287 pub fn model(&self) -> Option<&str> {
289 self.message.as_ref().and_then(|m| m.model.as_deref())
290 }
291}
292
293impl ContentPart {
294 pub fn summary(&self) -> String {
296 match self {
297 ContentPart::Text { text } => {
298 if text.chars().count() > 100 {
299 let truncated: String = text.chars().take(97).collect();
300 format!("{}...", truncated)
301 } else {
302 text.clone()
303 }
304 }
305 ContentPart::Thinking { .. } => "[thinking]".to_string(),
306 ContentPart::ToolUse { name, .. } => format!("[tool_use: {}]", name),
307 ContentPart::ToolResult {
308 is_error, content, ..
309 } => {
310 let text = content.text();
311 let prefix = if *is_error { "error" } else { "result" };
312 if text.chars().count() > 80 {
313 let truncated: String = text.chars().take(77).collect();
314 format!("[{}: {}...]", prefix, truncated)
315 } else {
316 format!("[{}: {}]", prefix, text)
317 }
318 }
319 ContentPart::Unknown => "[unknown]".to_string(),
320 }
321 }
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Copy)]
325#[serde(rename_all = "lowercase")]
326pub enum MessageRole {
327 User,
328 Assistant,
329 System,
330}
331
332impl std::str::FromStr for MessageRole {
333 type Err = String;
334
335 fn from_str(s: &str) -> Result<Self, Self::Err> {
336 match s.to_lowercase().as_str() {
337 "user" => Ok(MessageRole::User),
338 "assistant" => Ok(MessageRole::Assistant),
339 "system" => Ok(MessageRole::System),
340 _ => Err(format!("Invalid message role: {}", s)),
341 }
342 }
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize)]
351#[serde(rename_all = "snake_case")]
352pub struct Usage {
353 pub input_tokens: Option<u32>,
354 pub output_tokens: Option<u32>,
355 pub cache_creation_input_tokens: Option<u32>,
356 pub cache_read_input_tokens: Option<u32>,
357 pub cache_creation: Option<CacheCreation>,
358 pub service_tier: Option<String>,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
362#[serde(rename_all = "snake_case")]
363pub struct CacheCreation {
364 pub ephemeral_5m_input_tokens: Option<u32>,
365 pub ephemeral_1h_input_tokens: Option<u32>,
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct HistoryEntry {
370 pub display: String,
371
372 #[serde(rename = "pastedContents", default)]
373 pub pasted_contents: HashMap<String, Value>,
374
375 pub timestamp: i64,
376
377 #[serde(skip_serializing_if = "Option::is_none")]
378 pub project: Option<String>,
379
380 #[serde(rename = "sessionId", skip_serializing_if = "Option::is_none")]
381 pub session_id: Option<String>,
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct Conversation {
386 pub session_id: String,
387 pub project_path: Option<String>,
388 pub entries: Vec<ConversationEntry>,
389 pub started_at: Option<DateTime<Utc>>,
390 pub last_activity: Option<DateTime<Utc>>,
391 #[serde(default, skip_serializing_if = "Vec::is_empty")]
394 pub session_ids: Vec<String>,
395 #[serde(default, skip_serializing_if = "Vec::is_empty")]
399 pub preamble: Vec<serde_json::Value>,
400}
401
402impl Conversation {
403 pub fn new(session_id: String) -> Self {
404 Self {
405 session_id,
406 project_path: None,
407 entries: Vec::new(),
408 started_at: None,
409 last_activity: None,
410 session_ids: Vec::new(),
411 preamble: Vec::new(),
412 }
413 }
414
415 pub fn add_entry(&mut self, entry: ConversationEntry) {
416 if let Ok(timestamp) = entry.timestamp.parse::<DateTime<Utc>>() {
417 if self.started_at.is_none() || Some(timestamp) < self.started_at {
418 self.started_at = Some(timestamp);
419 }
420 if self.last_activity.is_none() || Some(timestamp) > self.last_activity {
421 self.last_activity = Some(timestamp);
422 }
423 }
424
425 if self.project_path.is_none() {
426 self.project_path = entry.cwd.clone();
427 }
428
429 self.entries.push(entry);
430 }
431
432 pub fn user_messages(&self) -> Vec<&ConversationEntry> {
433 self.entries
434 .iter()
435 .filter(|e| {
436 e.entry_type == "user"
437 && e.message
438 .as_ref()
439 .map(|m| m.role == MessageRole::User)
440 .unwrap_or(false)
441 })
442 .collect()
443 }
444
445 pub fn assistant_messages(&self) -> Vec<&ConversationEntry> {
446 self.entries
447 .iter()
448 .filter(|e| {
449 e.entry_type == "assistant"
450 && e.message
451 .as_ref()
452 .map(|m| m.role == MessageRole::Assistant)
453 .unwrap_or(false)
454 })
455 .collect()
456 }
457
458 pub fn tool_uses(&self) -> Vec<(&ConversationEntry, &ContentPart)> {
459 let mut results = Vec::new();
460
461 for entry in &self.entries {
462 if let Some(message) = &entry.message
463 && let Some(MessageContent::Parts(parts)) = &message.content
464 {
465 for part in parts {
466 if matches!(part, ContentPart::ToolUse { .. }) {
467 results.push((entry, part));
468 }
469 }
470 }
471 }
472
473 results
474 }
475
476 pub fn message_count(&self) -> usize {
477 self.entries.iter().filter(|e| e.message.is_some()).count()
478 }
479
480 pub fn duration(&self) -> Option<chrono::Duration> {
481 match (self.started_at, self.last_activity) {
482 (Some(start), Some(end)) => Some(end - start),
483 _ => None,
484 }
485 }
486
487 pub fn entries_since(&self, since_uuid: &str) -> Vec<ConversationEntry> {
491 match self.entries.iter().position(|e| e.uuid == since_uuid) {
492 Some(idx) => self.entries.iter().skip(idx + 1).cloned().collect(),
493 None => self.entries.clone(),
494 }
495 }
496
497 pub fn last_uuid(&self) -> Option<&str> {
499 self.entries.last().map(|e| e.uuid.as_str())
500 }
501
502 pub fn title(&self, max_len: usize) -> Option<String> {
504 self.first_user_text().map(|text| {
505 if text.chars().count() > max_len {
506 let truncated: String = text.chars().take(max_len).collect();
507 format!("{}...", truncated)
508 } else {
509 text
510 }
511 })
512 }
513
514 pub fn first_user_text(&self) -> Option<String> {
516 self.entries.iter().find_map(|e| {
517 e.message.as_ref().and_then(|msg| {
518 if msg.is_user() {
519 let text = msg.text();
520 if text.is_empty() { None } else { Some(text) }
521 } else {
522 None
523 }
524 })
525 })
526 }
527}
528
529#[derive(Debug, Clone, Serialize, Deserialize)]
530pub struct ConversationMetadata {
531 pub session_id: String,
532 pub project_path: String,
533 pub file_path: std::path::PathBuf,
534 pub message_count: usize,
535 pub started_at: Option<DateTime<Utc>>,
536 pub last_activity: Option<DateTime<Utc>>,
537 #[serde(default, skip_serializing_if = "Option::is_none")]
542 pub first_user_message: Option<String>,
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548
549 fn create_test_conversation() -> Conversation {
550 let mut convo = Conversation::new("test-session".to_string());
551
552 let entries = vec![
553 r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
554 r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
555 r#"{"uuid":"uuid-3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":"How are you?"}}"#,
556 r#"{"uuid":"uuid-4","type":"assistant","timestamp":"2024-01-01T00:00:03Z","message":{"role":"assistant","content":"I'm good!"}}"#,
557 ];
558
559 for entry_json in entries {
560 let entry: ConversationEntry = serde_json::from_str(entry_json).unwrap();
561 convo.add_entry(entry);
562 }
563
564 convo
565 }
566
567 #[test]
568 fn test_entries_since_middle() {
569 let convo = create_test_conversation();
570
571 let since = convo.entries_since("uuid-2");
573
574 assert_eq!(since.len(), 2);
575 assert_eq!(since[0].uuid, "uuid-3");
576 assert_eq!(since[1].uuid, "uuid-4");
577 }
578
579 #[test]
580 fn test_entries_since_first() {
581 let convo = create_test_conversation();
582
583 let since = convo.entries_since("uuid-1");
585
586 assert_eq!(since.len(), 3);
587 assert_eq!(since[0].uuid, "uuid-2");
588 }
589
590 #[test]
591 fn test_entries_since_last() {
592 let convo = create_test_conversation();
593
594 let since = convo.entries_since("uuid-4");
596
597 assert!(since.is_empty());
598 }
599
600 #[test]
601 fn test_entries_since_unknown() {
602 let convo = create_test_conversation();
603
604 let since = convo.entries_since("unknown-uuid");
606
607 assert_eq!(since.len(), 4);
608 }
609
610 #[test]
611 fn test_last_uuid() {
612 let convo = create_test_conversation();
613
614 assert_eq!(convo.last_uuid(), Some("uuid-4"));
615 }
616
617 #[test]
618 fn test_last_uuid_empty() {
619 let convo = Conversation::new("empty-session".to_string());
620
621 assert_eq!(convo.last_uuid(), None);
622 }
623
624 #[test]
627 fn test_user_messages() {
628 let convo = create_test_conversation();
629 let users = convo.user_messages();
630 assert_eq!(users.len(), 2);
631 assert!(users.iter().all(|e| e.entry_type == "user"));
632 }
633
634 #[test]
635 fn test_assistant_messages() {
636 let convo = create_test_conversation();
637 let assistants = convo.assistant_messages();
638 assert_eq!(assistants.len(), 2);
639 assert!(assistants.iter().all(|e| e.entry_type == "assistant"));
640 }
641
642 #[test]
643 fn test_message_count() {
644 let convo = create_test_conversation();
645 assert_eq!(convo.message_count(), 4);
646 }
647
648 #[test]
649 fn test_duration() {
650 let convo = create_test_conversation();
651 let dur = convo.duration().unwrap();
652 assert_eq!(dur.num_seconds(), 3); }
654
655 #[test]
656 fn test_duration_empty_conversation() {
657 let convo = Conversation::new("empty".to_string());
658 assert!(convo.duration().is_none());
659 }
660
661 #[test]
662 fn test_add_entry_tracks_timestamps() {
663 let mut convo = Conversation::new("test".to_string());
664 let entry: ConversationEntry = serde_json::from_str(
665 r#"{"uuid":"u1","type":"user","timestamp":"2024-06-15T10:00:00Z","message":{"role":"user","content":"hi"}}"#
666 ).unwrap();
667 convo.add_entry(entry);
668
669 assert!(convo.started_at.is_some());
670 assert!(convo.last_activity.is_some());
671 assert_eq!(convo.started_at, convo.last_activity);
672 }
673
674 #[test]
675 fn test_add_entry_sets_project_path() {
676 let mut convo = Conversation::new("test".to_string());
677 let entry: ConversationEntry = serde_json::from_str(
678 r#"{"uuid":"u1","type":"user","timestamp":"2024-06-15T10:00:00Z","cwd":"/home/user/project","message":{"role":"user","content":"hi"}}"#
679 ).unwrap();
680 convo.add_entry(entry);
681 assert_eq!(convo.project_path.as_deref(), Some("/home/user/project"));
682 }
683
684 #[test]
685 fn test_tool_uses() {
686 let mut convo = Conversation::new("test".to_string());
687 let entry: ConversationEntry = serde_json::from_str(
688 r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"/test"}}]}}"#
689 ).unwrap();
690 convo.add_entry(entry);
691
692 let uses = convo.tool_uses();
693 assert_eq!(uses.len(), 1);
694 match uses[0].1 {
695 ContentPart::ToolUse { name, .. } => assert_eq!(name, "Read"),
696 _ => panic!("Expected ToolUse"),
697 }
698 }
699
700 #[test]
701 fn test_tool_uses_empty() {
702 let convo = create_test_conversation();
703 let uses = convo.tool_uses();
705 assert!(uses.is_empty());
706 }
707
708 #[test]
711 fn test_content_part_summary_text_short() {
712 let part = ContentPart::Text {
713 text: "Hello world".to_string(),
714 };
715 assert_eq!(part.summary(), "Hello world");
716 }
717
718 #[test]
719 fn test_content_part_summary_text_long() {
720 let long = "A".repeat(200);
721 let part = ContentPart::Text { text: long };
722 let summary = part.summary();
723 assert!(summary.ends_with("..."));
724 assert!(summary.chars().count() <= 100);
725 }
726
727 #[test]
728 fn test_content_part_summary_thinking() {
729 let part = ContentPart::Thinking {
730 thinking: "deep thought".to_string(),
731 signature: None,
732 };
733 assert_eq!(part.summary(), "[thinking]");
734 }
735
736 #[test]
737 fn test_content_part_summary_tool_use() {
738 let part = ContentPart::ToolUse {
739 id: "t1".to_string(),
740 name: "Write".to_string(),
741 input: serde_json::json!({}),
742 };
743 assert_eq!(part.summary(), "[tool_use: Write]");
744 }
745
746 #[test]
747 fn test_content_part_summary_tool_result_short() {
748 let part = ContentPart::ToolResult {
749 tool_use_id: "t1".to_string(),
750 content: ToolResultContent::Text("OK".to_string()),
751 is_error: false,
752 };
753 assert_eq!(part.summary(), "[result: OK]");
754 }
755
756 #[test]
757 fn test_content_part_summary_tool_result_error() {
758 let part = ContentPart::ToolResult {
759 tool_use_id: "t1".to_string(),
760 content: ToolResultContent::Text("fail".to_string()),
761 is_error: true,
762 };
763 assert_eq!(part.summary(), "[error: fail]");
764 }
765
766 #[test]
767 fn test_content_part_summary_tool_result_long() {
768 let long = "X".repeat(200);
769 let part = ContentPart::ToolResult {
770 tool_use_id: "t1".to_string(),
771 content: ToolResultContent::Text(long),
772 is_error: false,
773 };
774 let summary = part.summary();
775 assert!(summary.starts_with("[result:"));
776 assert!(summary.ends_with("...]"));
777 }
778
779 #[test]
780 fn test_content_part_summary_unknown() {
781 let part = ContentPart::Unknown;
782 assert_eq!(part.summary(), "[unknown]");
783 }
784
785 #[test]
788 fn test_tool_result_content_text_string() {
789 let c = ToolResultContent::Text("hello".to_string());
790 assert_eq!(c.text(), "hello");
791 }
792
793 #[test]
794 fn test_tool_result_content_text_parts() {
795 let c = ToolResultContent::Parts(vec![
796 ToolResultPart {
797 text: Some("line1".to_string()),
798 },
799 ToolResultPart { text: None },
800 ToolResultPart {
801 text: Some("line2".to_string()),
802 },
803 ]);
804 assert_eq!(c.text(), "line1\nline2");
805 }
806
807 #[test]
810 fn test_message_role_from_str() {
811 assert_eq!("user".parse::<MessageRole>().unwrap(), MessageRole::User);
812 assert_eq!(
813 "assistant".parse::<MessageRole>().unwrap(),
814 MessageRole::Assistant
815 );
816 assert_eq!(
817 "system".parse::<MessageRole>().unwrap(),
818 MessageRole::System
819 );
820 }
821
822 #[test]
823 fn test_message_role_from_str_case_insensitive() {
824 assert_eq!("USER".parse::<MessageRole>().unwrap(), MessageRole::User);
825 assert_eq!(
826 "Assistant".parse::<MessageRole>().unwrap(),
827 MessageRole::Assistant
828 );
829 }
830
831 #[test]
832 fn test_message_role_from_str_invalid() {
833 assert!("invalid".parse::<MessageRole>().is_err());
834 }
835
836 #[test]
839 fn test_message_text_from_string() {
840 let msg = Message {
841 role: MessageRole::User,
842 content: Some(MessageContent::Text("Hello world".to_string())),
843 model: None,
844 id: None,
845 message_type: None,
846 stop_reason: None,
847 stop_sequence: None,
848 usage: None,
849 };
850 assert_eq!(msg.text(), "Hello world");
851 }
852
853 #[test]
854 fn test_message_text_from_parts() {
855 let msg = Message {
856 role: MessageRole::Assistant,
857 content: Some(MessageContent::Parts(vec![
858 ContentPart::Text {
859 text: "First".to_string(),
860 },
861 ContentPart::Thinking {
862 thinking: "hmm".to_string(),
863 signature: None,
864 },
865 ContentPart::Text {
866 text: "Second".to_string(),
867 },
868 ])),
869 model: None,
870 id: None,
871 message_type: None,
872 stop_reason: None,
873 stop_sequence: None,
874 usage: None,
875 };
876 assert_eq!(msg.text(), "First\nSecond");
877 }
878
879 #[test]
880 fn test_message_text_none() {
881 let msg = Message {
882 role: MessageRole::User,
883 content: None,
884 model: None,
885 id: None,
886 message_type: None,
887 stop_reason: None,
888 stop_sequence: None,
889 usage: None,
890 };
891 assert_eq!(msg.text(), "");
892 }
893
894 #[test]
895 fn test_message_thinking() {
896 let msg = Message {
897 role: MessageRole::Assistant,
898 content: Some(MessageContent::Parts(vec![
899 ContentPart::Thinking {
900 thinking: "deep thought".to_string(),
901 signature: None,
902 },
903 ContentPart::Text {
904 text: "answer".to_string(),
905 },
906 ContentPart::Thinking {
907 thinking: "more thought".to_string(),
908 signature: None,
909 },
910 ])),
911 model: None,
912 id: None,
913 message_type: None,
914 stop_reason: None,
915 stop_sequence: None,
916 usage: None,
917 };
918 let thinking = msg.thinking().unwrap();
919 assert_eq!(thinking, vec!["deep thought", "more thought"]);
920 }
921
922 #[test]
923 fn test_message_thinking_none() {
924 let msg = Message {
925 role: MessageRole::User,
926 content: Some(MessageContent::Text("hi".to_string())),
927 model: None,
928 id: None,
929 message_type: None,
930 stop_reason: None,
931 stop_sequence: None,
932 usage: None,
933 };
934 assert!(msg.thinking().is_none());
935 }
936
937 #[test]
938 fn test_message_tool_uses() {
939 let msg = Message {
940 role: MessageRole::Assistant,
941 content: Some(MessageContent::Parts(vec![
942 ContentPart::ToolUse {
943 id: "t1".to_string(),
944 name: "Read".to_string(),
945 input: serde_json::json!({"file": "test.rs"}),
946 },
947 ContentPart::Text {
948 text: "checking".to_string(),
949 },
950 ContentPart::ToolUse {
951 id: "t2".to_string(),
952 name: "Write".to_string(),
953 input: serde_json::json!({}),
954 },
955 ])),
956 model: None,
957 id: None,
958 message_type: None,
959 stop_reason: None,
960 stop_sequence: None,
961 usage: None,
962 };
963 let uses = msg.tool_uses();
964 assert_eq!(uses.len(), 2);
965 assert_eq!(uses[0].name, "Read");
966 assert_eq!(uses[1].name, "Write");
967 }
968
969 #[test]
970 fn test_message_tool_results() {
971 let msg = Message {
972 role: MessageRole::User,
973 content: Some(MessageContent::Parts(vec![
974 ContentPart::ToolResult {
975 tool_use_id: "t1".to_string(),
976 content: ToolResultContent::Text("file contents".to_string()),
977 is_error: false,
978 },
979 ContentPart::ToolResult {
980 tool_use_id: "t2".to_string(),
981 content: ToolResultContent::Text("error msg".to_string()),
982 is_error: true,
983 },
984 ])),
985 model: None,
986 id: None,
987 message_type: None,
988 stop_reason: None,
989 stop_sequence: None,
990 usage: None,
991 };
992 let results = msg.tool_results();
993 assert_eq!(results.len(), 2);
994 assert_eq!(results[0].tool_use_id, "t1");
995 assert_eq!(results[0].content.text(), "file contents");
996 assert!(!results[0].is_error);
997 assert_eq!(results[1].tool_use_id, "t2");
998 assert!(results[1].is_error);
999 }
1000
1001 #[test]
1002 fn test_message_tool_results_empty() {
1003 let msg = Message {
1004 role: MessageRole::User,
1005 content: Some(MessageContent::Text("hello".to_string())),
1006 model: None,
1007 id: None,
1008 message_type: None,
1009 stop_reason: None,
1010 stop_sequence: None,
1011 usage: None,
1012 };
1013 assert!(msg.tool_results().is_empty());
1014 }
1015
1016 #[test]
1017 fn test_message_role_checks() {
1018 let user_msg = Message {
1019 role: MessageRole::User,
1020 content: None,
1021 model: None,
1022 id: None,
1023 message_type: None,
1024 stop_reason: None,
1025 stop_sequence: None,
1026 usage: None,
1027 };
1028 assert!(user_msg.is_user());
1029 assert!(!user_msg.is_assistant());
1030 assert!(user_msg.is_role(MessageRole::User));
1031 }
1032
1033 #[test]
1036 fn test_entry_text() {
1037 let entry: ConversationEntry = serde_json::from_str(
1038 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello there"}}"#,
1039 )
1040 .unwrap();
1041 assert_eq!(entry.text(), "Hello there");
1042 }
1043
1044 #[test]
1045 fn test_entry_text_no_message() {
1046 let entry: ConversationEntry = serde_json::from_str(
1047 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z"}"#,
1048 )
1049 .unwrap();
1050 assert_eq!(entry.text(), "");
1051 }
1052
1053 #[test]
1054 fn test_entry_role() {
1055 let entry: ConversationEntry = serde_json::from_str(
1056 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hi"}}"#,
1057 )
1058 .unwrap();
1059 assert_eq!(entry.role(), Some(&MessageRole::User));
1060 }
1061
1062 #[test]
1063 fn test_entry_stop_reason() {
1064 let entry: ConversationEntry = serde_json::from_str(
1065 r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"done","stopReason":"end_turn"}}"#,
1066 )
1067 .unwrap();
1068 assert_eq!(entry.stop_reason(), Some("end_turn"));
1069 }
1070
1071 #[test]
1074 fn test_stop_reason_snake_case() {
1075 let entry: ConversationEntry = serde_json::from_str(
1076 r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"done","stop_reason":"end_turn","stop_sequence":null}}"#,
1077 )
1078 .unwrap();
1079 assert_eq!(entry.stop_reason(), Some("end_turn"));
1080 assert!(entry.message.as_ref().unwrap().stop_sequence.is_none());
1081 }
1082
1083 #[test]
1084 fn test_usage_snake_case() {
1085 let entry: ConversationEntry = serde_json::from_str(
1086 r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"hi","usage":{"input_tokens":1200,"output_tokens":350,"cache_creation_input_tokens":100,"cache_read_input_tokens":500,"service_tier":"standard"}}}"#,
1087 )
1088 .unwrap();
1089 let usage = entry.message.unwrap().usage.unwrap();
1090 assert_eq!(usage.input_tokens, Some(1200));
1091 assert_eq!(usage.output_tokens, Some(350));
1092 assert_eq!(usage.cache_creation_input_tokens, Some(100));
1093 assert_eq!(usage.cache_read_input_tokens, Some(500));
1094 assert_eq!(usage.service_tier.as_deref(), Some("standard"));
1095 }
1096
1097 #[test]
1098 fn test_cache_creation_snake_case() {
1099 let json = r#"{"ephemeral_5m_input_tokens":10,"ephemeral_1h_input_tokens":20}"#;
1100 let cc: CacheCreation = serde_json::from_str(json).unwrap();
1101 assert_eq!(cc.ephemeral_5m_input_tokens, Some(10));
1102 assert_eq!(cc.ephemeral_1h_input_tokens, Some(20));
1103 }
1104
1105 #[test]
1106 fn test_full_assistant_entry_snake_case() {
1107 let json = r#"{"parentUuid":"abc","isSidechain":false,"userType":"external","cwd":"/project","sessionId":"sess-1","version":"2.1.37","message":{"model":"claude-opus-4-6","id":"msg_123","type":"message","role":"assistant","content":[{"type":"text","text":"Done."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":4561,"cache_read_input_tokens":17868,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4561},"output_tokens":4,"service_tier":"standard"}},"requestId":"req_123","type":"assistant","uuid":"u1","timestamp":"2024-01-01T00:00:00Z"}"#;
1109 let entry: ConversationEntry = serde_json::from_str(json).unwrap();
1110 let msg = entry.message.unwrap();
1111 assert_eq!(msg.stop_reason.as_deref(), Some("end_turn"));
1112 assert!(msg.stop_sequence.is_none());
1113 let usage = msg.usage.unwrap();
1114 assert_eq!(usage.input_tokens, Some(3));
1115 assert_eq!(usage.output_tokens, Some(4));
1116 assert_eq!(usage.cache_creation_input_tokens, Some(4561));
1117 assert_eq!(usage.cache_read_input_tokens, Some(17868));
1118 assert_eq!(usage.service_tier.as_deref(), Some("standard"));
1119 let cc = usage.cache_creation.unwrap();
1120 assert_eq!(cc.ephemeral_5m_input_tokens, Some(0));
1121 assert_eq!(cc.ephemeral_1h_input_tokens, Some(4561));
1122 }
1123
1124 #[test]
1125 fn test_entry_model() {
1126 let entry: ConversationEntry = serde_json::from_str(
1127 r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"hi","model":"claude-opus-4-6"}}"#,
1128 )
1129 .unwrap();
1130 assert_eq!(entry.model(), Some("claude-opus-4-6"));
1131 }
1132
1133 #[test]
1136 fn test_conversation_title() {
1137 let convo = create_test_conversation();
1138 let title = convo.title(4).unwrap();
1139 assert_eq!(title, "Hell...");
1140 }
1141
1142 #[test]
1143 fn test_conversation_title_short() {
1144 let convo = create_test_conversation();
1145 let title = convo.title(100).unwrap();
1146 assert_eq!(title, "Hello");
1147 }
1148
1149 #[test]
1150 fn test_conversation_first_user_text() {
1151 let convo = create_test_conversation();
1152 assert_eq!(convo.first_user_text(), Some("Hello".to_string()));
1153 }
1154
1155 #[test]
1156 fn test_conversation_title_empty() {
1157 let convo = Conversation::new("empty".to_string());
1158 assert!(convo.title(50).is_none());
1159 }
1160}