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)]
346#[serde(rename_all = "camelCase")]
347pub struct Usage {
348 #[serde(alias = "input_tokens")]
349 pub input_tokens: Option<u32>,
350 #[serde(alias = "output_tokens")]
351 pub output_tokens: Option<u32>,
352 #[serde(alias = "cache_creation_input_tokens")]
353 pub cache_creation_input_tokens: Option<u32>,
354 #[serde(alias = "cache_read_input_tokens")]
355 pub cache_read_input_tokens: Option<u32>,
356 #[serde(alias = "cache_creation")]
357 pub cache_creation: Option<CacheCreation>,
358 #[serde(alias = "service_tier")]
359 pub service_tier: Option<String>,
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
363#[serde(rename_all = "camelCase")]
364pub struct CacheCreation {
365 #[serde(alias = "ephemeral_5m_input_tokens")]
366 pub ephemeral_5m_input_tokens: Option<u32>,
367 #[serde(alias = "ephemeral_1h_input_tokens")]
368 pub ephemeral_1h_input_tokens: Option<u32>,
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct HistoryEntry {
373 pub display: String,
374
375 #[serde(rename = "pastedContents", default)]
376 pub pasted_contents: HashMap<String, Value>,
377
378 pub timestamp: i64,
379
380 #[serde(skip_serializing_if = "Option::is_none")]
381 pub project: Option<String>,
382
383 #[serde(rename = "sessionId", skip_serializing_if = "Option::is_none")]
384 pub session_id: Option<String>,
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize)]
388pub struct Conversation {
389 pub session_id: String,
390 pub project_path: Option<String>,
391 pub entries: Vec<ConversationEntry>,
392 pub started_at: Option<DateTime<Utc>>,
393 pub last_activity: Option<DateTime<Utc>>,
394 #[serde(default, skip_serializing_if = "Vec::is_empty")]
397 pub session_ids: Vec<String>,
398}
399
400impl Conversation {
401 pub fn new(session_id: String) -> Self {
402 Self {
403 session_id,
404 project_path: None,
405 entries: Vec::new(),
406 started_at: None,
407 last_activity: None,
408 session_ids: Vec::new(),
409 }
410 }
411
412 pub fn add_entry(&mut self, entry: ConversationEntry) {
413 if let Ok(timestamp) = entry.timestamp.parse::<DateTime<Utc>>() {
414 if self.started_at.is_none() || Some(timestamp) < self.started_at {
415 self.started_at = Some(timestamp);
416 }
417 if self.last_activity.is_none() || Some(timestamp) > self.last_activity {
418 self.last_activity = Some(timestamp);
419 }
420 }
421
422 if self.project_path.is_none() {
423 self.project_path = entry.cwd.clone();
424 }
425
426 self.entries.push(entry);
427 }
428
429 pub fn user_messages(&self) -> Vec<&ConversationEntry> {
430 self.entries
431 .iter()
432 .filter(|e| {
433 e.entry_type == "user"
434 && e.message
435 .as_ref()
436 .map(|m| m.role == MessageRole::User)
437 .unwrap_or(false)
438 })
439 .collect()
440 }
441
442 pub fn assistant_messages(&self) -> Vec<&ConversationEntry> {
443 self.entries
444 .iter()
445 .filter(|e| {
446 e.entry_type == "assistant"
447 && e.message
448 .as_ref()
449 .map(|m| m.role == MessageRole::Assistant)
450 .unwrap_or(false)
451 })
452 .collect()
453 }
454
455 pub fn tool_uses(&self) -> Vec<(&ConversationEntry, &ContentPart)> {
456 let mut results = Vec::new();
457
458 for entry in &self.entries {
459 if let Some(message) = &entry.message
460 && let Some(MessageContent::Parts(parts)) = &message.content
461 {
462 for part in parts {
463 if matches!(part, ContentPart::ToolUse { .. }) {
464 results.push((entry, part));
465 }
466 }
467 }
468 }
469
470 results
471 }
472
473 pub fn message_count(&self) -> usize {
474 self.entries.iter().filter(|e| e.message.is_some()).count()
475 }
476
477 pub fn duration(&self) -> Option<chrono::Duration> {
478 match (self.started_at, self.last_activity) {
479 (Some(start), Some(end)) => Some(end - start),
480 _ => None,
481 }
482 }
483
484 pub fn entries_since(&self, since_uuid: &str) -> Vec<ConversationEntry> {
488 match self.entries.iter().position(|e| e.uuid == since_uuid) {
489 Some(idx) => self.entries.iter().skip(idx + 1).cloned().collect(),
490 None => self.entries.clone(),
491 }
492 }
493
494 pub fn last_uuid(&self) -> Option<&str> {
496 self.entries.last().map(|e| e.uuid.as_str())
497 }
498
499 pub fn title(&self, max_len: usize) -> Option<String> {
501 self.first_user_text().map(|text| {
502 if text.chars().count() > max_len {
503 let truncated: String = text.chars().take(max_len).collect();
504 format!("{}...", truncated)
505 } else {
506 text
507 }
508 })
509 }
510
511 pub fn first_user_text(&self) -> Option<String> {
513 self.entries.iter().find_map(|e| {
514 e.message.as_ref().and_then(|msg| {
515 if msg.is_user() {
516 let text = msg.text();
517 if text.is_empty() { None } else { Some(text) }
518 } else {
519 None
520 }
521 })
522 })
523 }
524}
525
526#[derive(Debug, Clone, Serialize, Deserialize)]
527pub struct ConversationMetadata {
528 pub session_id: String,
529 pub project_path: String,
530 pub file_path: std::path::PathBuf,
531 pub message_count: usize,
532 pub started_at: Option<DateTime<Utc>>,
533 pub last_activity: Option<DateTime<Utc>>,
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539
540 fn create_test_conversation() -> Conversation {
541 let mut convo = Conversation::new("test-session".to_string());
542
543 let entries = vec![
544 r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
545 r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
546 r#"{"uuid":"uuid-3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":"How are you?"}}"#,
547 r#"{"uuid":"uuid-4","type":"assistant","timestamp":"2024-01-01T00:00:03Z","message":{"role":"assistant","content":"I'm good!"}}"#,
548 ];
549
550 for entry_json in entries {
551 let entry: ConversationEntry = serde_json::from_str(entry_json).unwrap();
552 convo.add_entry(entry);
553 }
554
555 convo
556 }
557
558 #[test]
559 fn test_entries_since_middle() {
560 let convo = create_test_conversation();
561
562 let since = convo.entries_since("uuid-2");
564
565 assert_eq!(since.len(), 2);
566 assert_eq!(since[0].uuid, "uuid-3");
567 assert_eq!(since[1].uuid, "uuid-4");
568 }
569
570 #[test]
571 fn test_entries_since_first() {
572 let convo = create_test_conversation();
573
574 let since = convo.entries_since("uuid-1");
576
577 assert_eq!(since.len(), 3);
578 assert_eq!(since[0].uuid, "uuid-2");
579 }
580
581 #[test]
582 fn test_entries_since_last() {
583 let convo = create_test_conversation();
584
585 let since = convo.entries_since("uuid-4");
587
588 assert!(since.is_empty());
589 }
590
591 #[test]
592 fn test_entries_since_unknown() {
593 let convo = create_test_conversation();
594
595 let since = convo.entries_since("unknown-uuid");
597
598 assert_eq!(since.len(), 4);
599 }
600
601 #[test]
602 fn test_last_uuid() {
603 let convo = create_test_conversation();
604
605 assert_eq!(convo.last_uuid(), Some("uuid-4"));
606 }
607
608 #[test]
609 fn test_last_uuid_empty() {
610 let convo = Conversation::new("empty-session".to_string());
611
612 assert_eq!(convo.last_uuid(), None);
613 }
614
615 #[test]
618 fn test_user_messages() {
619 let convo = create_test_conversation();
620 let users = convo.user_messages();
621 assert_eq!(users.len(), 2);
622 assert!(users.iter().all(|e| e.entry_type == "user"));
623 }
624
625 #[test]
626 fn test_assistant_messages() {
627 let convo = create_test_conversation();
628 let assistants = convo.assistant_messages();
629 assert_eq!(assistants.len(), 2);
630 assert!(assistants.iter().all(|e| e.entry_type == "assistant"));
631 }
632
633 #[test]
634 fn test_message_count() {
635 let convo = create_test_conversation();
636 assert_eq!(convo.message_count(), 4);
637 }
638
639 #[test]
640 fn test_duration() {
641 let convo = create_test_conversation();
642 let dur = convo.duration().unwrap();
643 assert_eq!(dur.num_seconds(), 3); }
645
646 #[test]
647 fn test_duration_empty_conversation() {
648 let convo = Conversation::new("empty".to_string());
649 assert!(convo.duration().is_none());
650 }
651
652 #[test]
653 fn test_add_entry_tracks_timestamps() {
654 let mut convo = Conversation::new("test".to_string());
655 let entry: ConversationEntry = serde_json::from_str(
656 r#"{"uuid":"u1","type":"user","timestamp":"2024-06-15T10:00:00Z","message":{"role":"user","content":"hi"}}"#
657 ).unwrap();
658 convo.add_entry(entry);
659
660 assert!(convo.started_at.is_some());
661 assert!(convo.last_activity.is_some());
662 assert_eq!(convo.started_at, convo.last_activity);
663 }
664
665 #[test]
666 fn test_add_entry_sets_project_path() {
667 let mut convo = Conversation::new("test".to_string());
668 let entry: ConversationEntry = serde_json::from_str(
669 r#"{"uuid":"u1","type":"user","timestamp":"2024-06-15T10:00:00Z","cwd":"/home/user/project","message":{"role":"user","content":"hi"}}"#
670 ).unwrap();
671 convo.add_entry(entry);
672 assert_eq!(convo.project_path.as_deref(), Some("/home/user/project"));
673 }
674
675 #[test]
676 fn test_tool_uses() {
677 let mut convo = Conversation::new("test".to_string());
678 let entry: ConversationEntry = serde_json::from_str(
679 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"}}]}}"#
680 ).unwrap();
681 convo.add_entry(entry);
682
683 let uses = convo.tool_uses();
684 assert_eq!(uses.len(), 1);
685 match uses[0].1 {
686 ContentPart::ToolUse { name, .. } => assert_eq!(name, "Read"),
687 _ => panic!("Expected ToolUse"),
688 }
689 }
690
691 #[test]
692 fn test_tool_uses_empty() {
693 let convo = create_test_conversation();
694 let uses = convo.tool_uses();
696 assert!(uses.is_empty());
697 }
698
699 #[test]
702 fn test_content_part_summary_text_short() {
703 let part = ContentPart::Text {
704 text: "Hello world".to_string(),
705 };
706 assert_eq!(part.summary(), "Hello world");
707 }
708
709 #[test]
710 fn test_content_part_summary_text_long() {
711 let long = "A".repeat(200);
712 let part = ContentPart::Text { text: long };
713 let summary = part.summary();
714 assert!(summary.ends_with("..."));
715 assert!(summary.chars().count() <= 100);
716 }
717
718 #[test]
719 fn test_content_part_summary_thinking() {
720 let part = ContentPart::Thinking {
721 thinking: "deep thought".to_string(),
722 signature: None,
723 };
724 assert_eq!(part.summary(), "[thinking]");
725 }
726
727 #[test]
728 fn test_content_part_summary_tool_use() {
729 let part = ContentPart::ToolUse {
730 id: "t1".to_string(),
731 name: "Write".to_string(),
732 input: serde_json::json!({}),
733 };
734 assert_eq!(part.summary(), "[tool_use: Write]");
735 }
736
737 #[test]
738 fn test_content_part_summary_tool_result_short() {
739 let part = ContentPart::ToolResult {
740 tool_use_id: "t1".to_string(),
741 content: ToolResultContent::Text("OK".to_string()),
742 is_error: false,
743 };
744 assert_eq!(part.summary(), "[result: OK]");
745 }
746
747 #[test]
748 fn test_content_part_summary_tool_result_error() {
749 let part = ContentPart::ToolResult {
750 tool_use_id: "t1".to_string(),
751 content: ToolResultContent::Text("fail".to_string()),
752 is_error: true,
753 };
754 assert_eq!(part.summary(), "[error: fail]");
755 }
756
757 #[test]
758 fn test_content_part_summary_tool_result_long() {
759 let long = "X".repeat(200);
760 let part = ContentPart::ToolResult {
761 tool_use_id: "t1".to_string(),
762 content: ToolResultContent::Text(long),
763 is_error: false,
764 };
765 let summary = part.summary();
766 assert!(summary.starts_with("[result:"));
767 assert!(summary.ends_with("...]"));
768 }
769
770 #[test]
771 fn test_content_part_summary_unknown() {
772 let part = ContentPart::Unknown;
773 assert_eq!(part.summary(), "[unknown]");
774 }
775
776 #[test]
779 fn test_tool_result_content_text_string() {
780 let c = ToolResultContent::Text("hello".to_string());
781 assert_eq!(c.text(), "hello");
782 }
783
784 #[test]
785 fn test_tool_result_content_text_parts() {
786 let c = ToolResultContent::Parts(vec![
787 ToolResultPart {
788 text: Some("line1".to_string()),
789 },
790 ToolResultPart { text: None },
791 ToolResultPart {
792 text: Some("line2".to_string()),
793 },
794 ]);
795 assert_eq!(c.text(), "line1\nline2");
796 }
797
798 #[test]
801 fn test_message_role_from_str() {
802 assert_eq!("user".parse::<MessageRole>().unwrap(), MessageRole::User);
803 assert_eq!(
804 "assistant".parse::<MessageRole>().unwrap(),
805 MessageRole::Assistant
806 );
807 assert_eq!(
808 "system".parse::<MessageRole>().unwrap(),
809 MessageRole::System
810 );
811 }
812
813 #[test]
814 fn test_message_role_from_str_case_insensitive() {
815 assert_eq!("USER".parse::<MessageRole>().unwrap(), MessageRole::User);
816 assert_eq!(
817 "Assistant".parse::<MessageRole>().unwrap(),
818 MessageRole::Assistant
819 );
820 }
821
822 #[test]
823 fn test_message_role_from_str_invalid() {
824 assert!("invalid".parse::<MessageRole>().is_err());
825 }
826
827 #[test]
830 fn test_message_text_from_string() {
831 let msg = Message {
832 role: MessageRole::User,
833 content: Some(MessageContent::Text("Hello world".to_string())),
834 model: None,
835 id: None,
836 message_type: None,
837 stop_reason: None,
838 stop_sequence: None,
839 usage: None,
840 };
841 assert_eq!(msg.text(), "Hello world");
842 }
843
844 #[test]
845 fn test_message_text_from_parts() {
846 let msg = Message {
847 role: MessageRole::Assistant,
848 content: Some(MessageContent::Parts(vec![
849 ContentPart::Text {
850 text: "First".to_string(),
851 },
852 ContentPart::Thinking {
853 thinking: "hmm".to_string(),
854 signature: None,
855 },
856 ContentPart::Text {
857 text: "Second".to_string(),
858 },
859 ])),
860 model: None,
861 id: None,
862 message_type: None,
863 stop_reason: None,
864 stop_sequence: None,
865 usage: None,
866 };
867 assert_eq!(msg.text(), "First\nSecond");
868 }
869
870 #[test]
871 fn test_message_text_none() {
872 let msg = Message {
873 role: MessageRole::User,
874 content: None,
875 model: None,
876 id: None,
877 message_type: None,
878 stop_reason: None,
879 stop_sequence: None,
880 usage: None,
881 };
882 assert_eq!(msg.text(), "");
883 }
884
885 #[test]
886 fn test_message_thinking() {
887 let msg = Message {
888 role: MessageRole::Assistant,
889 content: Some(MessageContent::Parts(vec![
890 ContentPart::Thinking {
891 thinking: "deep thought".to_string(),
892 signature: None,
893 },
894 ContentPart::Text {
895 text: "answer".to_string(),
896 },
897 ContentPart::Thinking {
898 thinking: "more thought".to_string(),
899 signature: None,
900 },
901 ])),
902 model: None,
903 id: None,
904 message_type: None,
905 stop_reason: None,
906 stop_sequence: None,
907 usage: None,
908 };
909 let thinking = msg.thinking().unwrap();
910 assert_eq!(thinking, vec!["deep thought", "more thought"]);
911 }
912
913 #[test]
914 fn test_message_thinking_none() {
915 let msg = Message {
916 role: MessageRole::User,
917 content: Some(MessageContent::Text("hi".to_string())),
918 model: None,
919 id: None,
920 message_type: None,
921 stop_reason: None,
922 stop_sequence: None,
923 usage: None,
924 };
925 assert!(msg.thinking().is_none());
926 }
927
928 #[test]
929 fn test_message_tool_uses() {
930 let msg = Message {
931 role: MessageRole::Assistant,
932 content: Some(MessageContent::Parts(vec![
933 ContentPart::ToolUse {
934 id: "t1".to_string(),
935 name: "Read".to_string(),
936 input: serde_json::json!({"file": "test.rs"}),
937 },
938 ContentPart::Text {
939 text: "checking".to_string(),
940 },
941 ContentPart::ToolUse {
942 id: "t2".to_string(),
943 name: "Write".to_string(),
944 input: serde_json::json!({}),
945 },
946 ])),
947 model: None,
948 id: None,
949 message_type: None,
950 stop_reason: None,
951 stop_sequence: None,
952 usage: None,
953 };
954 let uses = msg.tool_uses();
955 assert_eq!(uses.len(), 2);
956 assert_eq!(uses[0].name, "Read");
957 assert_eq!(uses[1].name, "Write");
958 }
959
960 #[test]
961 fn test_message_tool_results() {
962 let msg = Message {
963 role: MessageRole::User,
964 content: Some(MessageContent::Parts(vec![
965 ContentPart::ToolResult {
966 tool_use_id: "t1".to_string(),
967 content: ToolResultContent::Text("file contents".to_string()),
968 is_error: false,
969 },
970 ContentPart::ToolResult {
971 tool_use_id: "t2".to_string(),
972 content: ToolResultContent::Text("error msg".to_string()),
973 is_error: true,
974 },
975 ])),
976 model: None,
977 id: None,
978 message_type: None,
979 stop_reason: None,
980 stop_sequence: None,
981 usage: None,
982 };
983 let results = msg.tool_results();
984 assert_eq!(results.len(), 2);
985 assert_eq!(results[0].tool_use_id, "t1");
986 assert_eq!(results[0].content.text(), "file contents");
987 assert!(!results[0].is_error);
988 assert_eq!(results[1].tool_use_id, "t2");
989 assert!(results[1].is_error);
990 }
991
992 #[test]
993 fn test_message_tool_results_empty() {
994 let msg = Message {
995 role: MessageRole::User,
996 content: Some(MessageContent::Text("hello".to_string())),
997 model: None,
998 id: None,
999 message_type: None,
1000 stop_reason: None,
1001 stop_sequence: None,
1002 usage: None,
1003 };
1004 assert!(msg.tool_results().is_empty());
1005 }
1006
1007 #[test]
1008 fn test_message_role_checks() {
1009 let user_msg = Message {
1010 role: MessageRole::User,
1011 content: None,
1012 model: None,
1013 id: None,
1014 message_type: None,
1015 stop_reason: None,
1016 stop_sequence: None,
1017 usage: None,
1018 };
1019 assert!(user_msg.is_user());
1020 assert!(!user_msg.is_assistant());
1021 assert!(user_msg.is_role(MessageRole::User));
1022 }
1023
1024 #[test]
1027 fn test_entry_text() {
1028 let entry: ConversationEntry = serde_json::from_str(
1029 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello there"}}"#,
1030 )
1031 .unwrap();
1032 assert_eq!(entry.text(), "Hello there");
1033 }
1034
1035 #[test]
1036 fn test_entry_text_no_message() {
1037 let entry: ConversationEntry = serde_json::from_str(
1038 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z"}"#,
1039 )
1040 .unwrap();
1041 assert_eq!(entry.text(), "");
1042 }
1043
1044 #[test]
1045 fn test_entry_role() {
1046 let entry: ConversationEntry = serde_json::from_str(
1047 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hi"}}"#,
1048 )
1049 .unwrap();
1050 assert_eq!(entry.role(), Some(&MessageRole::User));
1051 }
1052
1053 #[test]
1054 fn test_entry_stop_reason() {
1055 let entry: ConversationEntry = serde_json::from_str(
1056 r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"done","stopReason":"end_turn"}}"#,
1057 )
1058 .unwrap();
1059 assert_eq!(entry.stop_reason(), Some("end_turn"));
1060 }
1061
1062 #[test]
1065 fn test_stop_reason_snake_case() {
1066 let entry: ConversationEntry = serde_json::from_str(
1067 r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"done","stop_reason":"end_turn","stop_sequence":null}}"#,
1068 )
1069 .unwrap();
1070 assert_eq!(entry.stop_reason(), Some("end_turn"));
1071 assert!(entry.message.as_ref().unwrap().stop_sequence.is_none());
1072 }
1073
1074 #[test]
1075 fn test_usage_snake_case() {
1076 let entry: ConversationEntry = serde_json::from_str(
1077 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"}}}"#,
1078 )
1079 .unwrap();
1080 let usage = entry.message.unwrap().usage.unwrap();
1081 assert_eq!(usage.input_tokens, Some(1200));
1082 assert_eq!(usage.output_tokens, Some(350));
1083 assert_eq!(usage.cache_creation_input_tokens, Some(100));
1084 assert_eq!(usage.cache_read_input_tokens, Some(500));
1085 assert_eq!(usage.service_tier.as_deref(), Some("standard"));
1086 }
1087
1088 #[test]
1089 fn test_cache_creation_snake_case() {
1090 let json = r#"{"ephemeral_5m_input_tokens":10,"ephemeral_1h_input_tokens":20}"#;
1091 let cc: CacheCreation = serde_json::from_str(json).unwrap();
1092 assert_eq!(cc.ephemeral_5m_input_tokens, Some(10));
1093 assert_eq!(cc.ephemeral_1h_input_tokens, Some(20));
1094 }
1095
1096 #[test]
1097 fn test_full_assistant_entry_snake_case() {
1098 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"}"#;
1100 let entry: ConversationEntry = serde_json::from_str(json).unwrap();
1101 let msg = entry.message.unwrap();
1102 assert_eq!(msg.stop_reason.as_deref(), Some("end_turn"));
1103 assert!(msg.stop_sequence.is_none());
1104 let usage = msg.usage.unwrap();
1105 assert_eq!(usage.input_tokens, Some(3));
1106 assert_eq!(usage.output_tokens, Some(4));
1107 assert_eq!(usage.cache_creation_input_tokens, Some(4561));
1108 assert_eq!(usage.cache_read_input_tokens, Some(17868));
1109 assert_eq!(usage.service_tier.as_deref(), Some("standard"));
1110 let cc = usage.cache_creation.unwrap();
1111 assert_eq!(cc.ephemeral_5m_input_tokens, Some(0));
1112 assert_eq!(cc.ephemeral_1h_input_tokens, Some(4561));
1113 }
1114
1115 #[test]
1116 fn test_entry_model() {
1117 let entry: ConversationEntry = serde_json::from_str(
1118 r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"hi","model":"claude-opus-4-6"}}"#,
1119 )
1120 .unwrap();
1121 assert_eq!(entry.model(), Some("claude-opus-4-6"));
1122 }
1123
1124 #[test]
1127 fn test_conversation_title() {
1128 let convo = create_test_conversation();
1129 let title = convo.title(4).unwrap();
1130 assert_eq!(title, "Hell...");
1131 }
1132
1133 #[test]
1134 fn test_conversation_title_short() {
1135 let convo = create_test_conversation();
1136 let title = convo.title(100).unwrap();
1137 assert_eq!(title, "Hello");
1138 }
1139
1140 #[test]
1141 fn test_conversation_first_user_text() {
1142 let convo = create_test_conversation();
1143 assert_eq!(convo.first_user_text(), Some("Hello".to_string()));
1144 }
1145
1146 #[test]
1147 fn test_conversation_title_empty() {
1148 let convo = Conversation::new("empty".to_string());
1149 assert!(convo.title(50).is_none());
1150 }
1151}