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