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