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 segment_ids: Vec<String>,
399 #[serde(default, skip_serializing_if = "Vec::is_empty")]
403 pub preamble: Vec<serde_json::Value>,
404}
405
406fn set_session_id_keys(value: &mut serde_json::Value, id: &str) {
409 match value {
410 serde_json::Value::Object(map) => {
411 for (key, child) in map.iter_mut() {
412 if key == crate::constants::SESSION_ID && child.is_string() {
413 *child = serde_json::Value::String(id.to_string());
414 } else {
415 set_session_id_keys(child, id);
416 }
417 }
418 }
419 serde_json::Value::Array(items) => {
420 for child in items {
421 set_session_id_keys(child, id);
422 }
423 }
424 _ => {}
425 }
426}
427
428impl Conversation {
429 pub fn new(session_id: String) -> Self {
430 Self {
431 session_id,
432 project_path: None,
433 entries: Vec::new(),
434 started_at: None,
435 last_activity: None,
436 segment_ids: Vec::new(),
437 preamble: Vec::new(),
438 }
439 }
440
441 pub fn add_entry(&mut self, entry: ConversationEntry) {
442 if let Ok(timestamp) = entry.timestamp.parse::<DateTime<Utc>>() {
443 if self.started_at.is_none() || Some(timestamp) < self.started_at {
444 self.started_at = Some(timestamp);
445 }
446 if self.last_activity.is_none() || Some(timestamp) > self.last_activity {
447 self.last_activity = Some(timestamp);
448 }
449 }
450
451 if self.project_path.is_none() {
452 self.project_path = entry.cwd.clone();
453 }
454
455 self.entries.push(entry);
456 }
457
458 pub fn rename_session(&mut self, id: &str) {
467 self.session_id = id.to_string();
468 for slot in self
469 .entries
470 .iter_mut()
471 .filter_map(|e| e.session_id.as_mut())
472 {
473 *slot = id.to_string();
474 }
475 for raw in &mut self.preamble {
476 set_session_id_keys(raw, id);
477 }
478 }
479
480 pub fn reroot(&mut self, dir: &str) {
484 self.project_path = Some(dir.to_string());
485 for slot in self.entries.iter_mut().filter_map(|e| e.cwd.as_mut()) {
486 *slot = dir.to_string();
487 }
488 for raw in &mut self.preamble {
489 if let Some(slot) = raw.get_mut(crate::constants::CWD).filter(|v| v.is_string()) {
490 *slot = serde_json::Value::String(dir.to_string());
491 }
492 }
493 }
494
495 pub fn user_messages(&self) -> Vec<&ConversationEntry> {
496 self.entries
497 .iter()
498 .filter(|e| {
499 e.entry_type == "user"
500 && e.message
501 .as_ref()
502 .map(|m| m.role == MessageRole::User)
503 .unwrap_or(false)
504 })
505 .collect()
506 }
507
508 pub fn assistant_messages(&self) -> Vec<&ConversationEntry> {
509 self.entries
510 .iter()
511 .filter(|e| {
512 e.entry_type == "assistant"
513 && e.message
514 .as_ref()
515 .map(|m| m.role == MessageRole::Assistant)
516 .unwrap_or(false)
517 })
518 .collect()
519 }
520
521 pub fn tool_uses(&self) -> Vec<(&ConversationEntry, &ContentPart)> {
522 let mut results = Vec::new();
523
524 for entry in &self.entries {
525 if let Some(message) = &entry.message
526 && let Some(MessageContent::Parts(parts)) = &message.content
527 {
528 for part in parts {
529 if matches!(part, ContentPart::ToolUse { .. }) {
530 results.push((entry, part));
531 }
532 }
533 }
534 }
535
536 results
537 }
538
539 pub fn message_count(&self) -> usize {
540 self.entries.iter().filter(|e| e.message.is_some()).count()
541 }
542
543 pub fn duration(&self) -> Option<chrono::Duration> {
544 match (self.started_at, self.last_activity) {
545 (Some(start), Some(end)) => Some(end - start),
546 _ => None,
547 }
548 }
549
550 pub fn entries_since(&self, since_uuid: &str) -> Vec<ConversationEntry> {
554 match self.entries.iter().position(|e| e.uuid == since_uuid) {
555 Some(idx) => self.entries.iter().skip(idx + 1).cloned().collect(),
556 None => self.entries.clone(),
557 }
558 }
559
560 pub fn last_uuid(&self) -> Option<&str> {
562 self.entries.last().map(|e| e.uuid.as_str())
563 }
564
565 pub fn title(&self, max_len: usize) -> Option<String> {
567 self.first_user_text().map(|text| {
568 if text.chars().count() > max_len {
569 let truncated: String = text.chars().take(max_len).collect();
570 format!("{}...", truncated)
571 } else {
572 text
573 }
574 })
575 }
576
577 pub fn first_user_text(&self) -> Option<String> {
579 self.entries.iter().find_map(|e| {
580 e.message.as_ref().and_then(|msg| {
581 if msg.is_user() {
582 let text = msg.text();
583 if text.is_empty() { None } else { Some(text) }
584 } else {
585 None
586 }
587 })
588 })
589 }
590}
591
592#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct ConversationMetadata {
594 pub session_id: String,
595 pub project_path: String,
597 pub file_path: std::path::PathBuf,
598 pub message_count: usize,
599 pub started_at: Option<DateTime<Utc>>,
600 pub last_activity: Option<DateTime<Utc>>,
601 #[serde(default, skip_serializing_if = "Option::is_none")]
603 pub first_user_message: Option<String>,
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609
610 fn entry(json: &str) -> ConversationEntry {
611 serde_json::from_str(json).unwrap()
612 }
613
614 #[test]
615 fn rename_session_sets_every_session_id_key() {
616 let mut convo = Conversation::new("old".to_string());
617 convo.preamble.push(serde_json::json!({
618 "type": "permission-mode", "permissionMode": "default", "sessionId": "old"
619 }));
620 convo.preamble.push(serde_json::json!({
621 "type": "worktree-state", "sessionId": "old",
622 "worktreeSession": {"sessionId": "old", "worktreePath": "/wt"}
623 }));
624 convo
625 .preamble
626 .push(serde_json::json!({"type": "odd", "sessionId": 7}));
627 convo.add_entry(entry(
628 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"old","message":{"role":"user","content":"hi"}}"#,
629 ));
630 convo.add_entry(entry(
631 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","sessionId":"old","message":{"role":"assistant","content":"yo"}}"#,
632 ));
633
634 convo.rename_session("new");
635
636 assert_eq!(convo.session_id, "new");
637 assert!(
638 convo
639 .entries
640 .iter()
641 .all(|e| e.session_id.as_deref() == Some("new"))
642 );
643 assert_eq!(convo.preamble[0]["sessionId"], "new");
644 assert_eq!(convo.preamble[1]["sessionId"], "new");
645 assert_eq!(convo.preamble[1]["worktreeSession"]["sessionId"], "new");
646 assert_eq!(convo.preamble[1]["worktreeSession"]["worktreePath"], "/wt");
647 assert_eq!(convo.preamble[2]["sessionId"], 7);
648 }
649
650 #[test]
651 fn reroot_sets_project_path_and_every_present_cwd() {
652 let mut convo = Conversation::new("s".to_string());
653 convo.preamble.push(serde_json::json!({
654 "type": "custom-title", "cwd": "/old", "customTitle": "x"
655 }));
656 convo
657 .preamble
658 .push(serde_json::json!({"type": "last-prompt", "lastPrompt": "hi"}));
659 convo.add_entry(entry(
660 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","cwd":"/old","message":{"role":"user","content":"hi"}}"#,
661 ));
662 convo.add_entry(entry(
663 r#"{"uuid":"u2","type":"user","timestamp":"2024-01-01T00:00:01Z","message":{"role":"user","content":"hi"}}"#,
664 ));
665 assert_eq!(convo.project_path.as_deref(), Some("/old"));
666
667 convo.reroot("/new");
668
669 assert_eq!(convo.project_path.as_deref(), Some("/new"));
670 assert_eq!(convo.entries[0].cwd.as_deref(), Some("/new"));
671 assert_eq!(convo.entries[1].cwd, None);
672 assert_eq!(convo.preamble[0]["cwd"], "/new");
673 assert!(convo.preamble[1].get("cwd").is_none());
674 }
675
676 fn create_test_conversation() -> Conversation {
677 let mut convo = Conversation::new("test-session".to_string());
678
679 let entries = vec![
680 r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
681 r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
682 r#"{"uuid":"uuid-3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":"How are you?"}}"#,
683 r#"{"uuid":"uuid-4","type":"assistant","timestamp":"2024-01-01T00:00:03Z","message":{"role":"assistant","content":"I'm good!"}}"#,
684 ];
685
686 for entry_json in entries {
687 let entry: ConversationEntry = serde_json::from_str(entry_json).unwrap();
688 convo.add_entry(entry);
689 }
690
691 convo
692 }
693
694 #[test]
695 fn test_entries_since_middle() {
696 let convo = create_test_conversation();
697
698 let since = convo.entries_since("uuid-2");
700
701 assert_eq!(since.len(), 2);
702 assert_eq!(since[0].uuid, "uuid-3");
703 assert_eq!(since[1].uuid, "uuid-4");
704 }
705
706 #[test]
707 fn test_entries_since_first() {
708 let convo = create_test_conversation();
709
710 let since = convo.entries_since("uuid-1");
712
713 assert_eq!(since.len(), 3);
714 assert_eq!(since[0].uuid, "uuid-2");
715 }
716
717 #[test]
718 fn test_entries_since_last() {
719 let convo = create_test_conversation();
720
721 let since = convo.entries_since("uuid-4");
723
724 assert!(since.is_empty());
725 }
726
727 #[test]
728 fn test_entries_since_unknown() {
729 let convo = create_test_conversation();
730
731 let since = convo.entries_since("unknown-uuid");
733
734 assert_eq!(since.len(), 4);
735 }
736
737 #[test]
738 fn test_last_uuid() {
739 let convo = create_test_conversation();
740
741 assert_eq!(convo.last_uuid(), Some("uuid-4"));
742 }
743
744 #[test]
745 fn test_last_uuid_empty() {
746 let convo = Conversation::new("empty-session".to_string());
747
748 assert_eq!(convo.last_uuid(), None);
749 }
750
751 #[test]
754 fn test_user_messages() {
755 let convo = create_test_conversation();
756 let users = convo.user_messages();
757 assert_eq!(users.len(), 2);
758 assert!(users.iter().all(|e| e.entry_type == "user"));
759 }
760
761 #[test]
762 fn test_assistant_messages() {
763 let convo = create_test_conversation();
764 let assistants = convo.assistant_messages();
765 assert_eq!(assistants.len(), 2);
766 assert!(assistants.iter().all(|e| e.entry_type == "assistant"));
767 }
768
769 #[test]
770 fn test_message_count() {
771 let convo = create_test_conversation();
772 assert_eq!(convo.message_count(), 4);
773 }
774
775 #[test]
776 fn test_duration() {
777 let convo = create_test_conversation();
778 let dur = convo.duration().unwrap();
779 assert_eq!(dur.num_seconds(), 3); }
781
782 #[test]
783 fn test_duration_empty_conversation() {
784 let convo = Conversation::new("empty".to_string());
785 assert!(convo.duration().is_none());
786 }
787
788 #[test]
789 fn test_add_entry_tracks_timestamps() {
790 let mut convo = Conversation::new("test".to_string());
791 let entry: ConversationEntry = serde_json::from_str(
792 r#"{"uuid":"u1","type":"user","timestamp":"2024-06-15T10:00:00Z","message":{"role":"user","content":"hi"}}"#
793 ).unwrap();
794 convo.add_entry(entry);
795
796 assert!(convo.started_at.is_some());
797 assert!(convo.last_activity.is_some());
798 assert_eq!(convo.started_at, convo.last_activity);
799 }
800
801 #[test]
802 fn test_add_entry_sets_project_path() {
803 let mut convo = Conversation::new("test".to_string());
804 let entry: ConversationEntry = serde_json::from_str(
805 r#"{"uuid":"u1","type":"user","timestamp":"2024-06-15T10:00:00Z","cwd":"/home/user/project","message":{"role":"user","content":"hi"}}"#
806 ).unwrap();
807 convo.add_entry(entry);
808 assert_eq!(convo.project_path.as_deref(), Some("/home/user/project"));
809 }
810
811 #[test]
812 fn test_tool_uses() {
813 let mut convo = Conversation::new("test".to_string());
814 let entry: ConversationEntry = serde_json::from_str(
815 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"}}]}}"#
816 ).unwrap();
817 convo.add_entry(entry);
818
819 let uses = convo.tool_uses();
820 assert_eq!(uses.len(), 1);
821 match uses[0].1 {
822 ContentPart::ToolUse { name, .. } => assert_eq!(name, "Read"),
823 _ => panic!("Expected ToolUse"),
824 }
825 }
826
827 #[test]
828 fn test_tool_uses_empty() {
829 let convo = create_test_conversation();
830 let uses = convo.tool_uses();
832 assert!(uses.is_empty());
833 }
834
835 #[test]
838 fn test_content_part_summary_text_short() {
839 let part = ContentPart::Text {
840 text: "Hello world".to_string(),
841 };
842 assert_eq!(part.summary(), "Hello world");
843 }
844
845 #[test]
846 fn test_content_part_summary_text_long() {
847 let long = "A".repeat(200);
848 let part = ContentPart::Text { text: long };
849 let summary = part.summary();
850 assert!(summary.ends_with("..."));
851 assert!(summary.chars().count() <= 100);
852 }
853
854 #[test]
855 fn test_content_part_summary_thinking() {
856 let part = ContentPart::Thinking {
857 thinking: "deep thought".to_string(),
858 signature: None,
859 };
860 assert_eq!(part.summary(), "[thinking]");
861 }
862
863 #[test]
864 fn test_thinking_none_signature_serializes_absent() {
865 let part = ContentPart::Thinking {
866 thinking: "deep thought".to_string(),
867 signature: None,
868 };
869 let json = serde_json::to_value(&part).unwrap();
870 assert!(
871 json.get("signature").is_none(),
872 "None signature must serialize as an absent key, not null: the \
873 API 400s on `\"signature\": null` but tolerates absence"
874 );
875
876 let signed = ContentPart::Thinking {
877 thinking: "deep thought".to_string(),
878 signature: Some("sig123".to_string()),
879 };
880 let json = serde_json::to_value(&signed).unwrap();
881 assert_eq!(json.get("signature").unwrap(), "sig123");
882 }
883
884 #[test]
885 fn test_content_part_summary_tool_use() {
886 let part = ContentPart::ToolUse {
887 id: "t1".to_string(),
888 name: "Write".to_string(),
889 input: serde_json::json!({}),
890 };
891 assert_eq!(part.summary(), "[tool_use: Write]");
892 }
893
894 #[test]
895 fn test_content_part_summary_tool_result_short() {
896 let part = ContentPart::ToolResult {
897 tool_use_id: "t1".to_string(),
898 content: ToolResultContent::Text("OK".to_string()),
899 is_error: false,
900 };
901 assert_eq!(part.summary(), "[result: OK]");
902 }
903
904 #[test]
905 fn test_content_part_summary_tool_result_error() {
906 let part = ContentPart::ToolResult {
907 tool_use_id: "t1".to_string(),
908 content: ToolResultContent::Text("fail".to_string()),
909 is_error: true,
910 };
911 assert_eq!(part.summary(), "[error: fail]");
912 }
913
914 #[test]
915 fn test_content_part_summary_tool_result_long() {
916 let long = "X".repeat(200);
917 let part = ContentPart::ToolResult {
918 tool_use_id: "t1".to_string(),
919 content: ToolResultContent::Text(long),
920 is_error: false,
921 };
922 let summary = part.summary();
923 assert!(summary.starts_with("[result:"));
924 assert!(summary.ends_with("...]"));
925 }
926
927 #[test]
928 fn test_content_part_summary_unknown() {
929 let part = ContentPart::Unknown;
930 assert_eq!(part.summary(), "[unknown]");
931 }
932
933 #[test]
936 fn test_tool_result_content_text_string() {
937 let c = ToolResultContent::Text("hello".to_string());
938 assert_eq!(c.text(), "hello");
939 }
940
941 #[test]
942 fn test_tool_result_content_text_parts() {
943 let c = ToolResultContent::Parts(vec![
944 ToolResultPart {
945 text: Some("line1".to_string()),
946 },
947 ToolResultPart { text: None },
948 ToolResultPart {
949 text: Some("line2".to_string()),
950 },
951 ]);
952 assert_eq!(c.text(), "line1\nline2");
953 }
954
955 #[test]
958 fn test_message_role_from_str() {
959 assert_eq!("user".parse::<MessageRole>().unwrap(), MessageRole::User);
960 assert_eq!(
961 "assistant".parse::<MessageRole>().unwrap(),
962 MessageRole::Assistant
963 );
964 assert_eq!(
965 "system".parse::<MessageRole>().unwrap(),
966 MessageRole::System
967 );
968 }
969
970 #[test]
971 fn test_message_role_from_str_case_insensitive() {
972 assert_eq!("USER".parse::<MessageRole>().unwrap(), MessageRole::User);
973 assert_eq!(
974 "Assistant".parse::<MessageRole>().unwrap(),
975 MessageRole::Assistant
976 );
977 }
978
979 #[test]
980 fn test_message_role_from_str_invalid() {
981 assert!("invalid".parse::<MessageRole>().is_err());
982 }
983
984 #[test]
987 fn test_message_text_from_string() {
988 let msg = Message {
989 role: MessageRole::User,
990 content: Some(MessageContent::Text("Hello world".to_string())),
991 model: None,
992 id: None,
993 message_type: None,
994 stop_reason: None,
995 stop_sequence: None,
996 usage: None,
997 };
998 assert_eq!(msg.text(), "Hello world");
999 }
1000
1001 #[test]
1002 fn test_message_text_from_parts() {
1003 let msg = Message {
1004 role: MessageRole::Assistant,
1005 content: Some(MessageContent::Parts(vec![
1006 ContentPart::Text {
1007 text: "First".to_string(),
1008 },
1009 ContentPart::Thinking {
1010 thinking: "hmm".to_string(),
1011 signature: None,
1012 },
1013 ContentPart::Text {
1014 text: "Second".to_string(),
1015 },
1016 ])),
1017 model: None,
1018 id: None,
1019 message_type: None,
1020 stop_reason: None,
1021 stop_sequence: None,
1022 usage: None,
1023 };
1024 assert_eq!(msg.text(), "First\nSecond");
1025 }
1026
1027 #[test]
1028 fn test_message_text_none() {
1029 let msg = Message {
1030 role: MessageRole::User,
1031 content: None,
1032 model: None,
1033 id: None,
1034 message_type: None,
1035 stop_reason: None,
1036 stop_sequence: None,
1037 usage: None,
1038 };
1039 assert_eq!(msg.text(), "");
1040 }
1041
1042 #[test]
1043 fn test_message_thinking() {
1044 let msg = Message {
1045 role: MessageRole::Assistant,
1046 content: Some(MessageContent::Parts(vec![
1047 ContentPart::Thinking {
1048 thinking: "deep thought".to_string(),
1049 signature: None,
1050 },
1051 ContentPart::Text {
1052 text: "answer".to_string(),
1053 },
1054 ContentPart::Thinking {
1055 thinking: "more thought".to_string(),
1056 signature: None,
1057 },
1058 ])),
1059 model: None,
1060 id: None,
1061 message_type: None,
1062 stop_reason: None,
1063 stop_sequence: None,
1064 usage: None,
1065 };
1066 let thinking = msg.thinking().unwrap();
1067 assert_eq!(thinking, vec!["deep thought", "more thought"]);
1068 }
1069
1070 #[test]
1071 fn test_message_thinking_none() {
1072 let msg = Message {
1073 role: MessageRole::User,
1074 content: Some(MessageContent::Text("hi".to_string())),
1075 model: None,
1076 id: None,
1077 message_type: None,
1078 stop_reason: None,
1079 stop_sequence: None,
1080 usage: None,
1081 };
1082 assert!(msg.thinking().is_none());
1083 }
1084
1085 #[test]
1086 fn test_message_tool_uses() {
1087 let msg = Message {
1088 role: MessageRole::Assistant,
1089 content: Some(MessageContent::Parts(vec![
1090 ContentPart::ToolUse {
1091 id: "t1".to_string(),
1092 name: "Read".to_string(),
1093 input: serde_json::json!({"file": "test.rs"}),
1094 },
1095 ContentPart::Text {
1096 text: "checking".to_string(),
1097 },
1098 ContentPart::ToolUse {
1099 id: "t2".to_string(),
1100 name: "Write".to_string(),
1101 input: serde_json::json!({}),
1102 },
1103 ])),
1104 model: None,
1105 id: None,
1106 message_type: None,
1107 stop_reason: None,
1108 stop_sequence: None,
1109 usage: None,
1110 };
1111 let uses = msg.tool_uses();
1112 assert_eq!(uses.len(), 2);
1113 assert_eq!(uses[0].name, "Read");
1114 assert_eq!(uses[1].name, "Write");
1115 }
1116
1117 #[test]
1118 fn test_message_tool_results() {
1119 let msg = Message {
1120 role: MessageRole::User,
1121 content: Some(MessageContent::Parts(vec![
1122 ContentPart::ToolResult {
1123 tool_use_id: "t1".to_string(),
1124 content: ToolResultContent::Text("file contents".to_string()),
1125 is_error: false,
1126 },
1127 ContentPart::ToolResult {
1128 tool_use_id: "t2".to_string(),
1129 content: ToolResultContent::Text("error msg".to_string()),
1130 is_error: true,
1131 },
1132 ])),
1133 model: None,
1134 id: None,
1135 message_type: None,
1136 stop_reason: None,
1137 stop_sequence: None,
1138 usage: None,
1139 };
1140 let results = msg.tool_results();
1141 assert_eq!(results.len(), 2);
1142 assert_eq!(results[0].tool_use_id, "t1");
1143 assert_eq!(results[0].content.text(), "file contents");
1144 assert!(!results[0].is_error);
1145 assert_eq!(results[1].tool_use_id, "t2");
1146 assert!(results[1].is_error);
1147 }
1148
1149 #[test]
1150 fn test_message_tool_results_empty() {
1151 let msg = Message {
1152 role: MessageRole::User,
1153 content: Some(MessageContent::Text("hello".to_string())),
1154 model: None,
1155 id: None,
1156 message_type: None,
1157 stop_reason: None,
1158 stop_sequence: None,
1159 usage: None,
1160 };
1161 assert!(msg.tool_results().is_empty());
1162 }
1163
1164 #[test]
1165 fn test_message_role_checks() {
1166 let user_msg = Message {
1167 role: MessageRole::User,
1168 content: None,
1169 model: None,
1170 id: None,
1171 message_type: None,
1172 stop_reason: None,
1173 stop_sequence: None,
1174 usage: None,
1175 };
1176 assert!(user_msg.is_user());
1177 assert!(!user_msg.is_assistant());
1178 assert!(user_msg.is_role(MessageRole::User));
1179 }
1180
1181 #[test]
1184 fn test_entry_text() {
1185 let entry: ConversationEntry = serde_json::from_str(
1186 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello there"}}"#,
1187 )
1188 .unwrap();
1189 assert_eq!(entry.text(), "Hello there");
1190 }
1191
1192 #[test]
1193 fn test_entry_text_no_message() {
1194 let entry: ConversationEntry = serde_json::from_str(
1195 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z"}"#,
1196 )
1197 .unwrap();
1198 assert_eq!(entry.text(), "");
1199 }
1200
1201 #[test]
1202 fn test_entry_role() {
1203 let entry: ConversationEntry = serde_json::from_str(
1204 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hi"}}"#,
1205 )
1206 .unwrap();
1207 assert_eq!(entry.role(), Some(&MessageRole::User));
1208 }
1209
1210 #[test]
1211 fn test_entry_stop_reason() {
1212 let entry: ConversationEntry = serde_json::from_str(
1213 r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"done","stopReason":"end_turn"}}"#,
1214 )
1215 .unwrap();
1216 assert_eq!(entry.stop_reason(), Some("end_turn"));
1217 }
1218
1219 #[test]
1222 fn test_stop_reason_snake_case() {
1223 let entry: ConversationEntry = serde_json::from_str(
1224 r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"done","stop_reason":"end_turn","stop_sequence":null}}"#,
1225 )
1226 .unwrap();
1227 assert_eq!(entry.stop_reason(), Some("end_turn"));
1228 assert!(entry.message.as_ref().unwrap().stop_sequence.is_none());
1229 }
1230
1231 #[test]
1232 fn test_usage_snake_case() {
1233 let entry: ConversationEntry = serde_json::from_str(
1234 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"}}}"#,
1235 )
1236 .unwrap();
1237 let usage = entry.message.unwrap().usage.unwrap();
1238 assert_eq!(usage.input_tokens, Some(1200));
1239 assert_eq!(usage.output_tokens, Some(350));
1240 assert_eq!(usage.cache_creation_input_tokens, Some(100));
1241 assert_eq!(usage.cache_read_input_tokens, Some(500));
1242 assert_eq!(usage.service_tier.as_deref(), Some("standard"));
1243 }
1244
1245 #[test]
1246 fn test_cache_creation_snake_case() {
1247 let json = r#"{"ephemeral_5m_input_tokens":10,"ephemeral_1h_input_tokens":20}"#;
1248 let cc: CacheCreation = serde_json::from_str(json).unwrap();
1249 assert_eq!(cc.ephemeral_5m_input_tokens, Some(10));
1250 assert_eq!(cc.ephemeral_1h_input_tokens, Some(20));
1251 }
1252
1253 #[test]
1254 fn test_full_assistant_entry_snake_case() {
1255 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"}"#;
1257 let entry: ConversationEntry = serde_json::from_str(json).unwrap();
1258 let msg = entry.message.unwrap();
1259 assert_eq!(msg.stop_reason.as_deref(), Some("end_turn"));
1260 assert!(msg.stop_sequence.is_none());
1261 let usage = msg.usage.unwrap();
1262 assert_eq!(usage.input_tokens, Some(3));
1263 assert_eq!(usage.output_tokens, Some(4));
1264 assert_eq!(usage.cache_creation_input_tokens, Some(4561));
1265 assert_eq!(usage.cache_read_input_tokens, Some(17868));
1266 assert_eq!(usage.service_tier.as_deref(), Some("standard"));
1267 let cc = usage.cache_creation.unwrap();
1268 assert_eq!(cc.ephemeral_5m_input_tokens, Some(0));
1269 assert_eq!(cc.ephemeral_1h_input_tokens, Some(4561));
1270 }
1271
1272 #[test]
1273 fn test_entry_model() {
1274 let entry: ConversationEntry = serde_json::from_str(
1275 r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"hi","model":"claude-opus-4-6"}}"#,
1276 )
1277 .unwrap();
1278 assert_eq!(entry.model(), Some("claude-opus-4-6"));
1279 }
1280
1281 #[test]
1284 fn test_conversation_title() {
1285 let convo = create_test_conversation();
1286 let title = convo.title(4).unwrap();
1287 assert_eq!(title, "Hell...");
1288 }
1289
1290 #[test]
1291 fn test_conversation_title_short() {
1292 let convo = create_test_conversation();
1293 let title = convo.title(100).unwrap();
1294 assert_eq!(title, "Hello");
1295 }
1296
1297 #[test]
1298 fn test_conversation_first_user_text() {
1299 let convo = create_test_conversation();
1300 assert_eq!(convo.first_user_text(), Some("Hello".to_string()));
1301 }
1302
1303 #[test]
1304 fn test_conversation_title_empty() {
1305 let convo = Conversation::new("empty".to_string());
1306 assert!(convo.title(50).is_none());
1307 }
1308}