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