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
153impl Message {
154 pub fn text(&self) -> String {
158 match &self.content {
159 Some(MessageContent::Text(t)) => t.clone(),
160 Some(MessageContent::Parts(parts)) => parts
161 .iter()
162 .filter_map(|p| match p {
163 ContentPart::Text { text } => Some(text.as_str()),
164 _ => None,
165 })
166 .collect::<Vec<_>>()
167 .join("\n"),
168 None => String::new(),
169 }
170 }
171
172 pub fn thinking(&self) -> Option<Vec<&str>> {
176 let parts = match &self.content {
177 Some(MessageContent::Parts(parts)) => parts,
178 _ => return None,
179 };
180 let thinking: Vec<&str> = parts
181 .iter()
182 .filter_map(|p| match p {
183 ContentPart::Thinking { thinking, .. } => Some(thinking.as_str()),
184 _ => None,
185 })
186 .collect();
187 if thinking.is_empty() {
188 None
189 } else {
190 Some(thinking)
191 }
192 }
193
194 pub fn tool_uses(&self) -> Vec<ToolUseRef<'_>> {
196 let parts = match &self.content {
197 Some(MessageContent::Parts(parts)) => parts,
198 _ => return Vec::new(),
199 };
200 parts
201 .iter()
202 .filter_map(|p| match p {
203 ContentPart::ToolUse { id, name, input } => Some(ToolUseRef { id, name, input }),
204 _ => None,
205 })
206 .collect()
207 }
208
209 pub fn is_role(&self, role: MessageRole) -> bool {
211 self.role == role
212 }
213
214 pub fn is_user(&self) -> bool {
216 self.role == MessageRole::User
217 }
218
219 pub fn is_assistant(&self) -> bool {
221 self.role == MessageRole::Assistant
222 }
223}
224
225impl ConversationEntry {
226 pub fn role(&self) -> Option<&MessageRole> {
228 self.message.as_ref().map(|m| &m.role)
229 }
230
231 pub fn text(&self) -> String {
235 self.message.as_ref().map(|m| m.text()).unwrap_or_default()
236 }
237
238 pub fn thinking(&self) -> Option<Vec<&str>> {
240 self.message.as_ref().and_then(|m| m.thinking())
241 }
242
243 pub fn tool_uses(&self) -> Vec<ToolUseRef<'_>> {
245 self.message
246 .as_ref()
247 .map(|m| m.tool_uses())
248 .unwrap_or_default()
249 }
250
251 pub fn stop_reason(&self) -> Option<&str> {
253 self.message.as_ref().and_then(|m| m.stop_reason.as_deref())
254 }
255
256 pub fn model(&self) -> Option<&str> {
258 self.message.as_ref().and_then(|m| m.model.as_deref())
259 }
260}
261
262impl ContentPart {
263 pub fn summary(&self) -> String {
265 match self {
266 ContentPart::Text { text } => {
267 if text.chars().count() > 100 {
268 let truncated: String = text.chars().take(97).collect();
269 format!("{}...", truncated)
270 } else {
271 text.clone()
272 }
273 }
274 ContentPart::Thinking { .. } => "[thinking]".to_string(),
275 ContentPart::ToolUse { name, .. } => format!("[tool_use: {}]", name),
276 ContentPart::ToolResult {
277 is_error, content, ..
278 } => {
279 let text = content.text();
280 let prefix = if *is_error { "error" } else { "result" };
281 if text.chars().count() > 80 {
282 let truncated: String = text.chars().take(77).collect();
283 format!("[{}: {}...]", prefix, truncated)
284 } else {
285 format!("[{}: {}]", prefix, text)
286 }
287 }
288 ContentPart::Unknown => "[unknown]".to_string(),
289 }
290 }
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Copy)]
294#[serde(rename_all = "lowercase")]
295pub enum MessageRole {
296 User,
297 Assistant,
298 System,
299}
300
301impl std::str::FromStr for MessageRole {
302 type Err = String;
303
304 fn from_str(s: &str) -> Result<Self, Self::Err> {
305 match s.to_lowercase().as_str() {
306 "user" => Ok(MessageRole::User),
307 "assistant" => Ok(MessageRole::Assistant),
308 "system" => Ok(MessageRole::System),
309 _ => Err(format!("Invalid message role: {}", s)),
310 }
311 }
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
315#[serde(rename_all = "camelCase")]
316pub struct Usage {
317 #[serde(alias = "input_tokens")]
318 pub input_tokens: Option<u32>,
319 #[serde(alias = "output_tokens")]
320 pub output_tokens: Option<u32>,
321 #[serde(alias = "cache_creation_input_tokens")]
322 pub cache_creation_input_tokens: Option<u32>,
323 #[serde(alias = "cache_read_input_tokens")]
324 pub cache_read_input_tokens: Option<u32>,
325 #[serde(alias = "cache_creation")]
326 pub cache_creation: Option<CacheCreation>,
327 #[serde(alias = "service_tier")]
328 pub service_tier: Option<String>,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize)]
332#[serde(rename_all = "camelCase")]
333pub struct CacheCreation {
334 #[serde(alias = "ephemeral_5m_input_tokens")]
335 pub ephemeral_5m_input_tokens: Option<u32>,
336 #[serde(alias = "ephemeral_1h_input_tokens")]
337 pub ephemeral_1h_input_tokens: Option<u32>,
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct HistoryEntry {
342 pub display: String,
343
344 #[serde(rename = "pastedContents", default)]
345 pub pasted_contents: HashMap<String, Value>,
346
347 pub timestamp: i64,
348
349 #[serde(skip_serializing_if = "Option::is_none")]
350 pub project: Option<String>,
351
352 #[serde(rename = "sessionId", skip_serializing_if = "Option::is_none")]
353 pub session_id: Option<String>,
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct Conversation {
358 pub session_id: String,
359 pub project_path: Option<String>,
360 pub entries: Vec<ConversationEntry>,
361 pub started_at: Option<DateTime<Utc>>,
362 pub last_activity: Option<DateTime<Utc>>,
363}
364
365impl Conversation {
366 pub fn new(session_id: String) -> Self {
367 Self {
368 session_id,
369 project_path: None,
370 entries: Vec::new(),
371 started_at: None,
372 last_activity: None,
373 }
374 }
375
376 pub fn add_entry(&mut self, entry: ConversationEntry) {
377 if let Ok(timestamp) = entry.timestamp.parse::<DateTime<Utc>>() {
378 if self.started_at.is_none() || Some(timestamp) < self.started_at {
379 self.started_at = Some(timestamp);
380 }
381 if self.last_activity.is_none() || Some(timestamp) > self.last_activity {
382 self.last_activity = Some(timestamp);
383 }
384 }
385
386 if self.project_path.is_none() {
387 self.project_path = entry.cwd.clone();
388 }
389
390 self.entries.push(entry);
391 }
392
393 pub fn user_messages(&self) -> Vec<&ConversationEntry> {
394 self.entries
395 .iter()
396 .filter(|e| {
397 e.entry_type == "user"
398 && e.message
399 .as_ref()
400 .map(|m| m.role == MessageRole::User)
401 .unwrap_or(false)
402 })
403 .collect()
404 }
405
406 pub fn assistant_messages(&self) -> Vec<&ConversationEntry> {
407 self.entries
408 .iter()
409 .filter(|e| {
410 e.entry_type == "assistant"
411 && e.message
412 .as_ref()
413 .map(|m| m.role == MessageRole::Assistant)
414 .unwrap_or(false)
415 })
416 .collect()
417 }
418
419 pub fn tool_uses(&self) -> Vec<(&ConversationEntry, &ContentPart)> {
420 let mut results = Vec::new();
421
422 for entry in &self.entries {
423 if let Some(message) = &entry.message
424 && let Some(MessageContent::Parts(parts)) = &message.content
425 {
426 for part in parts {
427 if matches!(part, ContentPart::ToolUse { .. }) {
428 results.push((entry, part));
429 }
430 }
431 }
432 }
433
434 results
435 }
436
437 pub fn message_count(&self) -> usize {
438 self.entries.iter().filter(|e| e.message.is_some()).count()
439 }
440
441 pub fn duration(&self) -> Option<chrono::Duration> {
442 match (self.started_at, self.last_activity) {
443 (Some(start), Some(end)) => Some(end - start),
444 _ => None,
445 }
446 }
447
448 pub fn entries_since(&self, since_uuid: &str) -> Vec<ConversationEntry> {
452 match self.entries.iter().position(|e| e.uuid == since_uuid) {
453 Some(idx) => self.entries.iter().skip(idx + 1).cloned().collect(),
454 None => self.entries.clone(),
455 }
456 }
457
458 pub fn last_uuid(&self) -> Option<&str> {
460 self.entries.last().map(|e| e.uuid.as_str())
461 }
462
463 pub fn title(&self, max_len: usize) -> Option<String> {
465 self.first_user_text().map(|text| {
466 if text.chars().count() > max_len {
467 let truncated: String = text.chars().take(max_len).collect();
468 format!("{}...", truncated)
469 } else {
470 text
471 }
472 })
473 }
474
475 pub fn first_user_text(&self) -> Option<String> {
477 self.entries.iter().find_map(|e| {
478 e.message.as_ref().and_then(|msg| {
479 if msg.is_user() {
480 let text = msg.text();
481 if text.is_empty() { None } else { Some(text) }
482 } else {
483 None
484 }
485 })
486 })
487 }
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize)]
491pub struct ConversationMetadata {
492 pub session_id: String,
493 pub project_path: String,
494 pub file_path: std::path::PathBuf,
495 pub message_count: usize,
496 pub started_at: Option<DateTime<Utc>>,
497 pub last_activity: Option<DateTime<Utc>>,
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503
504 fn create_test_conversation() -> Conversation {
505 let mut convo = Conversation::new("test-session".to_string());
506
507 let entries = vec![
508 r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
509 r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
510 r#"{"uuid":"uuid-3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":"How are you?"}}"#,
511 r#"{"uuid":"uuid-4","type":"assistant","timestamp":"2024-01-01T00:00:03Z","message":{"role":"assistant","content":"I'm good!"}}"#,
512 ];
513
514 for entry_json in entries {
515 let entry: ConversationEntry = serde_json::from_str(entry_json).unwrap();
516 convo.add_entry(entry);
517 }
518
519 convo
520 }
521
522 #[test]
523 fn test_entries_since_middle() {
524 let convo = create_test_conversation();
525
526 let since = convo.entries_since("uuid-2");
528
529 assert_eq!(since.len(), 2);
530 assert_eq!(since[0].uuid, "uuid-3");
531 assert_eq!(since[1].uuid, "uuid-4");
532 }
533
534 #[test]
535 fn test_entries_since_first() {
536 let convo = create_test_conversation();
537
538 let since = convo.entries_since("uuid-1");
540
541 assert_eq!(since.len(), 3);
542 assert_eq!(since[0].uuid, "uuid-2");
543 }
544
545 #[test]
546 fn test_entries_since_last() {
547 let convo = create_test_conversation();
548
549 let since = convo.entries_since("uuid-4");
551
552 assert!(since.is_empty());
553 }
554
555 #[test]
556 fn test_entries_since_unknown() {
557 let convo = create_test_conversation();
558
559 let since = convo.entries_since("unknown-uuid");
561
562 assert_eq!(since.len(), 4);
563 }
564
565 #[test]
566 fn test_last_uuid() {
567 let convo = create_test_conversation();
568
569 assert_eq!(convo.last_uuid(), Some("uuid-4"));
570 }
571
572 #[test]
573 fn test_last_uuid_empty() {
574 let convo = Conversation::new("empty-session".to_string());
575
576 assert_eq!(convo.last_uuid(), None);
577 }
578
579 #[test]
582 fn test_user_messages() {
583 let convo = create_test_conversation();
584 let users = convo.user_messages();
585 assert_eq!(users.len(), 2);
586 assert!(users.iter().all(|e| e.entry_type == "user"));
587 }
588
589 #[test]
590 fn test_assistant_messages() {
591 let convo = create_test_conversation();
592 let assistants = convo.assistant_messages();
593 assert_eq!(assistants.len(), 2);
594 assert!(assistants.iter().all(|e| e.entry_type == "assistant"));
595 }
596
597 #[test]
598 fn test_message_count() {
599 let convo = create_test_conversation();
600 assert_eq!(convo.message_count(), 4);
601 }
602
603 #[test]
604 fn test_duration() {
605 let convo = create_test_conversation();
606 let dur = convo.duration().unwrap();
607 assert_eq!(dur.num_seconds(), 3); }
609
610 #[test]
611 fn test_duration_empty_conversation() {
612 let convo = Conversation::new("empty".to_string());
613 assert!(convo.duration().is_none());
614 }
615
616 #[test]
617 fn test_add_entry_tracks_timestamps() {
618 let mut convo = Conversation::new("test".to_string());
619 let entry: ConversationEntry = serde_json::from_str(
620 r#"{"uuid":"u1","type":"user","timestamp":"2024-06-15T10:00:00Z","message":{"role":"user","content":"hi"}}"#
621 ).unwrap();
622 convo.add_entry(entry);
623
624 assert!(convo.started_at.is_some());
625 assert!(convo.last_activity.is_some());
626 assert_eq!(convo.started_at, convo.last_activity);
627 }
628
629 #[test]
630 fn test_add_entry_sets_project_path() {
631 let mut convo = Conversation::new("test".to_string());
632 let entry: ConversationEntry = serde_json::from_str(
633 r#"{"uuid":"u1","type":"user","timestamp":"2024-06-15T10:00:00Z","cwd":"/home/user/project","message":{"role":"user","content":"hi"}}"#
634 ).unwrap();
635 convo.add_entry(entry);
636 assert_eq!(convo.project_path.as_deref(), Some("/home/user/project"));
637 }
638
639 #[test]
640 fn test_tool_uses() {
641 let mut convo = Conversation::new("test".to_string());
642 let entry: ConversationEntry = serde_json::from_str(
643 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"}}]}}"#
644 ).unwrap();
645 convo.add_entry(entry);
646
647 let uses = convo.tool_uses();
648 assert_eq!(uses.len(), 1);
649 match uses[0].1 {
650 ContentPart::ToolUse { name, .. } => assert_eq!(name, "Read"),
651 _ => panic!("Expected ToolUse"),
652 }
653 }
654
655 #[test]
656 fn test_tool_uses_empty() {
657 let convo = create_test_conversation();
658 let uses = convo.tool_uses();
660 assert!(uses.is_empty());
661 }
662
663 #[test]
666 fn test_content_part_summary_text_short() {
667 let part = ContentPart::Text {
668 text: "Hello world".to_string(),
669 };
670 assert_eq!(part.summary(), "Hello world");
671 }
672
673 #[test]
674 fn test_content_part_summary_text_long() {
675 let long = "A".repeat(200);
676 let part = ContentPart::Text { text: long };
677 let summary = part.summary();
678 assert!(summary.ends_with("..."));
679 assert!(summary.chars().count() <= 100);
680 }
681
682 #[test]
683 fn test_content_part_summary_thinking() {
684 let part = ContentPart::Thinking {
685 thinking: "deep thought".to_string(),
686 signature: None,
687 };
688 assert_eq!(part.summary(), "[thinking]");
689 }
690
691 #[test]
692 fn test_content_part_summary_tool_use() {
693 let part = ContentPart::ToolUse {
694 id: "t1".to_string(),
695 name: "Write".to_string(),
696 input: serde_json::json!({}),
697 };
698 assert_eq!(part.summary(), "[tool_use: Write]");
699 }
700
701 #[test]
702 fn test_content_part_summary_tool_result_short() {
703 let part = ContentPart::ToolResult {
704 tool_use_id: "t1".to_string(),
705 content: ToolResultContent::Text("OK".to_string()),
706 is_error: false,
707 };
708 assert_eq!(part.summary(), "[result: OK]");
709 }
710
711 #[test]
712 fn test_content_part_summary_tool_result_error() {
713 let part = ContentPart::ToolResult {
714 tool_use_id: "t1".to_string(),
715 content: ToolResultContent::Text("fail".to_string()),
716 is_error: true,
717 };
718 assert_eq!(part.summary(), "[error: fail]");
719 }
720
721 #[test]
722 fn test_content_part_summary_tool_result_long() {
723 let long = "X".repeat(200);
724 let part = ContentPart::ToolResult {
725 tool_use_id: "t1".to_string(),
726 content: ToolResultContent::Text(long),
727 is_error: false,
728 };
729 let summary = part.summary();
730 assert!(summary.starts_with("[result:"));
731 assert!(summary.ends_with("...]"));
732 }
733
734 #[test]
735 fn test_content_part_summary_unknown() {
736 let part = ContentPart::Unknown;
737 assert_eq!(part.summary(), "[unknown]");
738 }
739
740 #[test]
743 fn test_tool_result_content_text_string() {
744 let c = ToolResultContent::Text("hello".to_string());
745 assert_eq!(c.text(), "hello");
746 }
747
748 #[test]
749 fn test_tool_result_content_text_parts() {
750 let c = ToolResultContent::Parts(vec![
751 ToolResultPart {
752 text: Some("line1".to_string()),
753 },
754 ToolResultPart { text: None },
755 ToolResultPart {
756 text: Some("line2".to_string()),
757 },
758 ]);
759 assert_eq!(c.text(), "line1\nline2");
760 }
761
762 #[test]
765 fn test_message_role_from_str() {
766 assert_eq!("user".parse::<MessageRole>().unwrap(), MessageRole::User);
767 assert_eq!(
768 "assistant".parse::<MessageRole>().unwrap(),
769 MessageRole::Assistant
770 );
771 assert_eq!(
772 "system".parse::<MessageRole>().unwrap(),
773 MessageRole::System
774 );
775 }
776
777 #[test]
778 fn test_message_role_from_str_case_insensitive() {
779 assert_eq!("USER".parse::<MessageRole>().unwrap(), MessageRole::User);
780 assert_eq!(
781 "Assistant".parse::<MessageRole>().unwrap(),
782 MessageRole::Assistant
783 );
784 }
785
786 #[test]
787 fn test_message_role_from_str_invalid() {
788 assert!("invalid".parse::<MessageRole>().is_err());
789 }
790
791 #[test]
794 fn test_message_text_from_string() {
795 let msg = Message {
796 role: MessageRole::User,
797 content: Some(MessageContent::Text("Hello world".to_string())),
798 model: None,
799 id: None,
800 message_type: None,
801 stop_reason: None,
802 stop_sequence: None,
803 usage: None,
804 };
805 assert_eq!(msg.text(), "Hello world");
806 }
807
808 #[test]
809 fn test_message_text_from_parts() {
810 let msg = Message {
811 role: MessageRole::Assistant,
812 content: Some(MessageContent::Parts(vec![
813 ContentPart::Text {
814 text: "First".to_string(),
815 },
816 ContentPart::Thinking {
817 thinking: "hmm".to_string(),
818 signature: None,
819 },
820 ContentPart::Text {
821 text: "Second".to_string(),
822 },
823 ])),
824 model: None,
825 id: None,
826 message_type: None,
827 stop_reason: None,
828 stop_sequence: None,
829 usage: None,
830 };
831 assert_eq!(msg.text(), "First\nSecond");
832 }
833
834 #[test]
835 fn test_message_text_none() {
836 let msg = Message {
837 role: MessageRole::User,
838 content: None,
839 model: None,
840 id: None,
841 message_type: None,
842 stop_reason: None,
843 stop_sequence: None,
844 usage: None,
845 };
846 assert_eq!(msg.text(), "");
847 }
848
849 #[test]
850 fn test_message_thinking() {
851 let msg = Message {
852 role: MessageRole::Assistant,
853 content: Some(MessageContent::Parts(vec![
854 ContentPart::Thinking {
855 thinking: "deep thought".to_string(),
856 signature: None,
857 },
858 ContentPart::Text {
859 text: "answer".to_string(),
860 },
861 ContentPart::Thinking {
862 thinking: "more thought".to_string(),
863 signature: None,
864 },
865 ])),
866 model: None,
867 id: None,
868 message_type: None,
869 stop_reason: None,
870 stop_sequence: None,
871 usage: None,
872 };
873 let thinking = msg.thinking().unwrap();
874 assert_eq!(thinking, vec!["deep thought", "more thought"]);
875 }
876
877 #[test]
878 fn test_message_thinking_none() {
879 let msg = Message {
880 role: MessageRole::User,
881 content: Some(MessageContent::Text("hi".to_string())),
882 model: None,
883 id: None,
884 message_type: None,
885 stop_reason: None,
886 stop_sequence: None,
887 usage: None,
888 };
889 assert!(msg.thinking().is_none());
890 }
891
892 #[test]
893 fn test_message_tool_uses() {
894 let msg = Message {
895 role: MessageRole::Assistant,
896 content: Some(MessageContent::Parts(vec![
897 ContentPart::ToolUse {
898 id: "t1".to_string(),
899 name: "Read".to_string(),
900 input: serde_json::json!({"file": "test.rs"}),
901 },
902 ContentPart::Text {
903 text: "checking".to_string(),
904 },
905 ContentPart::ToolUse {
906 id: "t2".to_string(),
907 name: "Write".to_string(),
908 input: serde_json::json!({}),
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 uses = msg.tool_uses();
919 assert_eq!(uses.len(), 2);
920 assert_eq!(uses[0].name, "Read");
921 assert_eq!(uses[1].name, "Write");
922 }
923
924 #[test]
925 fn test_message_role_checks() {
926 let user_msg = Message {
927 role: MessageRole::User,
928 content: None,
929 model: None,
930 id: None,
931 message_type: None,
932 stop_reason: None,
933 stop_sequence: None,
934 usage: None,
935 };
936 assert!(user_msg.is_user());
937 assert!(!user_msg.is_assistant());
938 assert!(user_msg.is_role(MessageRole::User));
939 }
940
941 #[test]
944 fn test_entry_text() {
945 let entry: ConversationEntry = serde_json::from_str(
946 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello there"}}"#,
947 )
948 .unwrap();
949 assert_eq!(entry.text(), "Hello there");
950 }
951
952 #[test]
953 fn test_entry_text_no_message() {
954 let entry: ConversationEntry = serde_json::from_str(
955 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z"}"#,
956 )
957 .unwrap();
958 assert_eq!(entry.text(), "");
959 }
960
961 #[test]
962 fn test_entry_role() {
963 let entry: ConversationEntry = serde_json::from_str(
964 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hi"}}"#,
965 )
966 .unwrap();
967 assert_eq!(entry.role(), Some(&MessageRole::User));
968 }
969
970 #[test]
971 fn test_entry_stop_reason() {
972 let entry: ConversationEntry = serde_json::from_str(
973 r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"done","stopReason":"end_turn"}}"#,
974 )
975 .unwrap();
976 assert_eq!(entry.stop_reason(), Some("end_turn"));
977 }
978
979 #[test]
982 fn test_stop_reason_snake_case() {
983 let entry: ConversationEntry = serde_json::from_str(
984 r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"done","stop_reason":"end_turn","stop_sequence":null}}"#,
985 )
986 .unwrap();
987 assert_eq!(entry.stop_reason(), Some("end_turn"));
988 assert!(entry.message.as_ref().unwrap().stop_sequence.is_none());
989 }
990
991 #[test]
992 fn test_usage_snake_case() {
993 let entry: ConversationEntry = serde_json::from_str(
994 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"}}}"#,
995 )
996 .unwrap();
997 let usage = entry.message.unwrap().usage.unwrap();
998 assert_eq!(usage.input_tokens, Some(1200));
999 assert_eq!(usage.output_tokens, Some(350));
1000 assert_eq!(usage.cache_creation_input_tokens, Some(100));
1001 assert_eq!(usage.cache_read_input_tokens, Some(500));
1002 assert_eq!(usage.service_tier.as_deref(), Some("standard"));
1003 }
1004
1005 #[test]
1006 fn test_cache_creation_snake_case() {
1007 let json = r#"{"ephemeral_5m_input_tokens":10,"ephemeral_1h_input_tokens":20}"#;
1008 let cc: CacheCreation = serde_json::from_str(json).unwrap();
1009 assert_eq!(cc.ephemeral_5m_input_tokens, Some(10));
1010 assert_eq!(cc.ephemeral_1h_input_tokens, Some(20));
1011 }
1012
1013 #[test]
1014 fn test_full_assistant_entry_snake_case() {
1015 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"}"#;
1017 let entry: ConversationEntry = serde_json::from_str(json).unwrap();
1018 let msg = entry.message.unwrap();
1019 assert_eq!(msg.stop_reason.as_deref(), Some("end_turn"));
1020 assert!(msg.stop_sequence.is_none());
1021 let usage = msg.usage.unwrap();
1022 assert_eq!(usage.input_tokens, Some(3));
1023 assert_eq!(usage.output_tokens, Some(4));
1024 assert_eq!(usage.cache_creation_input_tokens, Some(4561));
1025 assert_eq!(usage.cache_read_input_tokens, Some(17868));
1026 assert_eq!(usage.service_tier.as_deref(), Some("standard"));
1027 let cc = usage.cache_creation.unwrap();
1028 assert_eq!(cc.ephemeral_5m_input_tokens, Some(0));
1029 assert_eq!(cc.ephemeral_1h_input_tokens, Some(4561));
1030 }
1031
1032 #[test]
1033 fn test_entry_model() {
1034 let entry: ConversationEntry = serde_json::from_str(
1035 r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"hi","model":"claude-opus-4-6"}}"#,
1036 )
1037 .unwrap();
1038 assert_eq!(entry.model(), Some("claude-opus-4-6"));
1039 }
1040
1041 #[test]
1044 fn test_conversation_title() {
1045 let convo = create_test_conversation();
1046 let title = convo.title(4).unwrap();
1047 assert_eq!(title, "Hell...");
1048 }
1049
1050 #[test]
1051 fn test_conversation_title_short() {
1052 let convo = create_test_conversation();
1053 let title = convo.title(100).unwrap();
1054 assert_eq!(title, "Hello");
1055 }
1056
1057 #[test]
1058 fn test_conversation_first_user_text() {
1059 let convo = create_test_conversation();
1060 assert_eq!(convo.first_user_text(), Some("Hello".to_string()));
1061 }
1062
1063 #[test]
1064 fn test_conversation_title_empty() {
1065 let convo = Conversation::new("empty".to_string());
1066 assert!(convo.title(50).is_none());
1067 }
1068}