1#![doc = include_str!("../README.md")]
2
3pub mod derive;
4pub mod extract;
5pub mod project;
6
7pub use derive::{DeriveConfig, derive_path, file_write_diff, unified_diff};
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::PathBuf;
13
14#[derive(Debug, thiserror::Error)]
18pub enum ConvoError {
19 #[error("I/O error: {0}")]
20 Io(#[from] std::io::Error),
21
22 #[error("JSON error: {0}")]
23 Json(#[from] serde_json::Error),
24
25 #[error("provider error: {0}")]
26 Provider(String),
27
28 #[error("{0}")]
29 Other(#[from] Box<dyn std::error::Error + Send + Sync>),
30}
31
32pub type Result<T> = std::result::Result<T, ConvoError>;
33
34#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
38pub enum Role {
39 User,
40 Assistant,
41 System,
42 Other(String),
44}
45
46impl std::fmt::Display for Role {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 match self {
49 Role::User => write!(f, "user"),
50 Role::Assistant => write!(f, "assistant"),
51 Role::System => write!(f, "system"),
52 Role::Other(s) => write!(f, "{}", s),
53 }
54 }
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
59pub struct TokenUsage {
60 pub input_tokens: Option<u32>,
62 pub output_tokens: Option<u32>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub cache_read_tokens: Option<u32>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub cache_write_tokens: Option<u32>,
70}
71
72#[derive(Debug, Clone, Default, Serialize, Deserialize)]
77pub struct ProducerInfo {
78 pub name: String,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub version: Option<String>,
83}
84
85#[derive(Debug, Clone, Default, Serialize, Deserialize)]
89pub struct SessionBase {
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub working_dir: Option<String>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub vcs_revision: Option<String>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub vcs_branch: Option<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub vcs_remote: Option<String>,
102}
103
104#[derive(Debug, Clone, Default, Serialize, Deserialize)]
113pub struct FileMutation {
114 pub path: String,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub tool_id: Option<String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub operation: Option<String>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub raw_diff: Option<String>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub before: Option<String>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub after: Option<String>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub rename_to: Option<String>,
137}
138
139#[derive(Debug, Clone, Default, Serialize, Deserialize)]
143pub struct EnvironmentSnapshot {
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub working_dir: Option<String>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub vcs_branch: Option<String>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub vcs_revision: Option<String>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct DelegatedWork {
158 pub agent_id: String,
160 pub prompt: String,
162 #[serde(default, skip_serializing_if = "Vec::is_empty")]
165 pub turns: Vec<Turn>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub result: Option<String>,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct ConversationEvent {
177 pub id: String,
179 pub timestamp: String,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub parent_id: Option<String>,
184 pub event_type: String,
186 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
188 pub data: HashMap<String, serde_json::Value>,
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
198#[serde(rename_all = "snake_case")]
199pub enum ToolCategory {
200 FileRead,
202 FileWrite,
204 FileSearch,
206 Shell,
208 Network,
210 Delegation,
212}
213
214#[derive(Debug, Clone, Default, Serialize, Deserialize)]
216pub struct ToolInvocation {
217 pub id: String,
219 pub name: String,
221 pub input: serde_json::Value,
223 pub result: Option<ToolResult>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub category: Option<ToolCategory>,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct ToolResult {
234 pub content: String,
236 pub is_error: bool,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct Turn {
243 pub id: String,
245
246 pub parent_id: Option<String>,
248
249 pub role: Role,
251
252 pub timestamp: String,
254
255 pub text: String,
257
258 pub thinking: Option<String>,
260
261 pub tool_uses: Vec<ToolInvocation>,
263
264 pub model: Option<String>,
266
267 pub stop_reason: Option<String>,
269
270 pub token_usage: Option<TokenUsage>,
272
273 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub environment: Option<EnvironmentSnapshot>,
276
277 #[serde(default, skip_serializing_if = "Vec::is_empty")]
279 pub delegations: Vec<DelegatedWork>,
280
281 #[serde(default, skip_serializing_if = "Vec::is_empty")]
287 pub file_mutations: Vec<FileMutation>,
288}
289
290#[derive(Debug, Clone, Default, Serialize, Deserialize)]
292pub struct ConversationView {
293 pub id: String,
295
296 pub started_at: Option<DateTime<Utc>>,
298
299 pub last_activity: Option<DateTime<Utc>>,
301
302 pub turns: Vec<Turn>,
304
305 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub total_usage: Option<TokenUsage>,
308
309 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub provider_id: Option<String>,
312
313 #[serde(default, skip_serializing_if = "Vec::is_empty")]
316 pub files_changed: Vec<String>,
317
318 #[serde(default, skip_serializing_if = "Vec::is_empty")]
322 pub session_ids: Vec<String>,
323
324 #[serde(default, skip_serializing_if = "Vec::is_empty")]
328 pub events: Vec<ConversationEvent>,
329
330 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub base: Option<SessionBase>,
334
335 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub producer: Option<ProducerInfo>,
339}
340
341impl ConversationView {
342 pub fn title(&self, max_len: usize) -> Option<String> {
344 let text = self
345 .turns
346 .iter()
347 .find(|t| t.role == Role::User && !t.text.is_empty())
348 .map(|t| &t.text)?;
349
350 if text.chars().count() > max_len {
351 let truncated: String = text.chars().take(max_len).collect();
352 Some(format!("{}...", truncated))
353 } else {
354 Some(text.clone())
355 }
356 }
357
358 pub fn turns_by_role(&self, role: &Role) -> Vec<&Turn> {
360 self.turns.iter().filter(|t| &t.role == role).collect()
361 }
362
363 pub fn turns_since(&self, turn_id: &str) -> &[Turn] {
368 match self.turns.iter().position(|t| t.id == turn_id) {
369 Some(idx) if idx + 1 < self.turns.len() => &self.turns[idx + 1..],
370 Some(_) => &[],
371 None => &self.turns,
372 }
373 }
374}
375
376#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct ConversationMeta {
383 pub id: String,
385 pub started_at: Option<DateTime<Utc>>,
387 pub last_activity: Option<DateTime<Utc>>,
389 pub message_count: usize,
391 pub file_path: Option<PathBuf>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub predecessor: Option<SessionLink>,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub successor: Option<SessionLink>,
399}
400
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
405pub enum SessionLinkKind {
406 Rotation,
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize)]
412pub struct SessionLink {
413 pub session_id: String,
415 pub kind: SessionLinkKind,
417}
418
419#[derive(Debug, Clone)]
452pub enum WatcherEvent {
453 Turn(Box<Turn>),
455
456 TurnUpdated(Box<Turn>),
462
463 Progress {
465 kind: String,
466 data: serde_json::Value,
467 },
468}
469
470impl WatcherEvent {
471 pub fn as_turn(&self) -> Option<&Turn> {
474 match self {
475 WatcherEvent::Turn(t) | WatcherEvent::TurnUpdated(t) => Some(t),
476 WatcherEvent::Progress { .. } => None,
477 }
478 }
479
480 pub fn as_progress(&self) -> Option<(&str, &serde_json::Value)> {
482 match self {
483 WatcherEvent::Progress { kind, data } => Some((kind, data)),
484 _ => None,
485 }
486 }
487
488 pub fn is_update(&self) -> bool {
490 matches!(self, WatcherEvent::TurnUpdated(_))
491 }
492
493 pub fn turn_id(&self) -> Option<&str> {
495 self.as_turn().map(|t| t.id.as_str())
496 }
497}
498
499pub trait ConversationProvider {
506 fn list_conversations(&self, project: &str) -> Result<Vec<String>>;
508
509 fn load_conversation(&self, project: &str, conversation_id: &str) -> Result<ConversationView>;
511
512 fn load_metadata(&self, project: &str, conversation_id: &str) -> Result<ConversationMeta>;
514
515 fn list_metadata(&self, project: &str) -> Result<Vec<ConversationMeta>>;
517}
518
519pub trait ConversationWatcher {
521 fn poll(&mut self) -> Result<Vec<WatcherEvent>>;
523
524 fn seen_count(&self) -> usize;
526}
527
528pub use extract::extract_conversation;
529pub use project::{AnyProjector, ConversationProjector};
530
531#[cfg(test)]
534mod tests {
535 use super::*;
536
537 fn sample_view() -> ConversationView {
538 ConversationView {
539 id: "sess-1".into(),
540 started_at: None,
541 last_activity: None,
542 turns: vec![
543 Turn {
544 id: "t1".into(),
545 parent_id: None,
546 role: Role::User,
547 timestamp: "2026-01-01T00:00:00Z".into(),
548 text: "Fix the authentication bug in login.rs".into(),
549 thinking: None,
550 tool_uses: vec![],
551 model: None,
552 stop_reason: None,
553 token_usage: None,
554 environment: None,
555 delegations: vec![],
556 file_mutations: Vec::new(),
557 },
558 Turn {
559 id: "t2".into(),
560 parent_id: Some("t1".into()),
561 role: Role::Assistant,
562 timestamp: "2026-01-01T00:00:01Z".into(),
563 text: "I'll fix that for you.".into(),
564 thinking: Some("The bug is in the token validation".into()),
565 tool_uses: vec![ToolInvocation {
566 id: "tool-1".into(),
567 name: "Read".into(),
568 input: serde_json::json!({"file": "src/login.rs"}),
569 result: Some(ToolResult {
570 content: "fn login() { ... }".into(),
571 is_error: false,
572 }),
573 category: Some(ToolCategory::FileRead),
574 }],
575 model: Some("claude-opus-4-6".into()),
576 stop_reason: Some("end_turn".into()),
577 token_usage: Some(TokenUsage {
578 input_tokens: Some(100),
579 output_tokens: Some(50),
580 cache_read_tokens: None,
581 cache_write_tokens: None,
582 }),
583 environment: None,
584 delegations: vec![],
585 file_mutations: Vec::new(),
586 },
587 Turn {
588 id: "t3".into(),
589 parent_id: Some("t2".into()),
590 role: Role::User,
591 timestamp: "2026-01-01T00:00:02Z".into(),
592 text: "Thanks!".into(),
593 thinking: None,
594 tool_uses: vec![],
595 model: None,
596 stop_reason: None,
597 token_usage: None,
598 environment: None,
599 delegations: vec![],
600 file_mutations: Vec::new(),
601 },
602 ],
603 total_usage: None,
604 provider_id: None,
605 files_changed: vec![],
606 session_ids: vec![],
607 events: vec![],
608 ..Default::default()
609 }
610 }
611
612 #[test]
613 fn test_title_short() {
614 let view = sample_view();
615 let title = view.title(100).unwrap();
616 assert_eq!(title, "Fix the authentication bug in login.rs");
617 }
618
619 #[test]
620 fn test_title_truncated() {
621 let view = sample_view();
622 let title = view.title(10).unwrap();
623 assert_eq!(title, "Fix the au...");
624 }
625
626 #[test]
627 fn test_title_empty() {
628 let view = ConversationView {
629 id: "empty".into(),
630 started_at: None,
631 last_activity: None,
632 turns: vec![],
633 total_usage: None,
634 provider_id: None,
635 files_changed: vec![],
636 session_ids: vec![],
637 events: vec![],
638 ..Default::default()
639 };
640 assert!(view.title(50).is_none());
641 }
642
643 #[test]
644 fn test_turns_by_role() {
645 let view = sample_view();
646 let users = view.turns_by_role(&Role::User);
647 assert_eq!(users.len(), 2);
648 let assistants = view.turns_by_role(&Role::Assistant);
649 assert_eq!(assistants.len(), 1);
650 }
651
652 #[test]
653 fn test_turns_since_middle() {
654 let view = sample_view();
655 let since = view.turns_since("t1");
656 assert_eq!(since.len(), 2);
657 assert_eq!(since[0].id, "t2");
658 }
659
660 #[test]
661 fn test_turns_since_last() {
662 let view = sample_view();
663 let since = view.turns_since("t3");
664 assert!(since.is_empty());
665 }
666
667 #[test]
668 fn test_turns_since_unknown() {
669 let view = sample_view();
670 let since = view.turns_since("nonexistent");
671 assert_eq!(since.len(), 3);
672 }
673
674 #[test]
675 fn test_role_display() {
676 assert_eq!(Role::User.to_string(), "user");
677 assert_eq!(Role::Assistant.to_string(), "assistant");
678 assert_eq!(Role::System.to_string(), "system");
679 assert_eq!(Role::Other("tool".into()).to_string(), "tool");
680 }
681
682 #[test]
683 fn test_role_equality() {
684 assert_eq!(Role::User, Role::User);
685 assert_ne!(Role::User, Role::Assistant);
686 assert_eq!(Role::Other("x".into()), Role::Other("x".into()));
687 assert_ne!(Role::Other("x".into()), Role::Other("y".into()));
688 }
689
690 #[test]
691 fn test_turn_serde_roundtrip() {
692 let turn = &sample_view().turns[1];
693 let json = serde_json::to_string(turn).unwrap();
694 let back: Turn = serde_json::from_str(&json).unwrap();
695 assert_eq!(back.id, "t2");
696 assert_eq!(back.model, Some("claude-opus-4-6".into()));
697 assert_eq!(back.tool_uses.len(), 1);
698 assert_eq!(back.tool_uses[0].name, "Read");
699 assert!(back.tool_uses[0].result.is_some());
700 }
701
702 #[test]
703 fn test_conversation_view_serde_roundtrip() {
704 let view = sample_view();
705 let json = serde_json::to_string(&view).unwrap();
706 let back: ConversationView = serde_json::from_str(&json).unwrap();
707 assert_eq!(back.id, "sess-1");
708 assert_eq!(back.turns.len(), 3);
709 }
710
711 #[test]
712 fn test_watcher_event_variants() {
713 let turn_event = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
714 assert!(matches!(turn_event, WatcherEvent::Turn(_)));
715
716 let updated_event = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[1].clone()));
717 assert!(matches!(updated_event, WatcherEvent::TurnUpdated(_)));
718
719 let progress_event = WatcherEvent::Progress {
720 kind: "agent_progress".into(),
721 data: serde_json::json!({"status": "running"}),
722 };
723 assert!(matches!(progress_event, WatcherEvent::Progress { .. }));
724 }
725
726 #[test]
727 fn test_watcher_event_as_turn() {
728 let turn = sample_view().turns[0].clone();
729 let event = WatcherEvent::Turn(Box::new(turn.clone()));
730 assert_eq!(event.as_turn().unwrap().id, "t1");
731
732 let updated = WatcherEvent::TurnUpdated(Box::new(turn));
733 assert_eq!(updated.as_turn().unwrap().id, "t1");
734
735 let progress = WatcherEvent::Progress {
736 kind: "test".into(),
737 data: serde_json::Value::Null,
738 };
739 assert!(progress.as_turn().is_none());
740 }
741
742 #[test]
743 fn test_watcher_event_as_progress() {
744 let progress = WatcherEvent::Progress {
745 kind: "hook_progress".into(),
746 data: serde_json::json!({"hookName": "pre-commit"}),
747 };
748 let (kind, data) = progress.as_progress().unwrap();
749 assert_eq!(kind, "hook_progress");
750 assert_eq!(data["hookName"], "pre-commit");
751
752 let turn = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
753 assert!(turn.as_progress().is_none());
754 }
755
756 #[test]
757 fn test_watcher_event_is_update() {
758 let turn = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
759 assert!(!turn.is_update());
760
761 let updated = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[0].clone()));
762 assert!(updated.is_update());
763
764 let progress = WatcherEvent::Progress {
765 kind: "test".into(),
766 data: serde_json::Value::Null,
767 };
768 assert!(!progress.is_update());
769 }
770
771 #[test]
772 fn test_watcher_event_turn_id() {
773 let turn = WatcherEvent::Turn(Box::new(sample_view().turns[1].clone()));
774 assert_eq!(turn.turn_id(), Some("t2"));
775
776 let updated = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[0].clone()));
777 assert_eq!(updated.turn_id(), Some("t1"));
778
779 let progress = WatcherEvent::Progress {
780 kind: "test".into(),
781 data: serde_json::Value::Null,
782 };
783 assert!(progress.turn_id().is_none());
784 }
785
786 #[test]
787 fn test_token_usage_default() {
788 let usage = TokenUsage::default();
789 assert!(usage.input_tokens.is_none());
790 assert!(usage.output_tokens.is_none());
791 assert!(usage.cache_read_tokens.is_none());
792 assert!(usage.cache_write_tokens.is_none());
793 }
794
795 #[test]
796 fn test_token_usage_cache_fields_serde() {
797 let usage = TokenUsage {
798 input_tokens: Some(100),
799 output_tokens: Some(50),
800 cache_read_tokens: Some(500),
801 cache_write_tokens: Some(200),
802 };
803 let json = serde_json::to_string(&usage).unwrap();
804 let back: TokenUsage = serde_json::from_str(&json).unwrap();
805 assert_eq!(back.cache_read_tokens, Some(500));
806 assert_eq!(back.cache_write_tokens, Some(200));
807 }
808
809 #[test]
810 fn test_token_usage_cache_fields_omitted() {
811 let json = r#"{"input_tokens":100,"output_tokens":50}"#;
813 let usage: TokenUsage = serde_json::from_str(json).unwrap();
814 assert_eq!(usage.input_tokens, Some(100));
815 assert!(usage.cache_read_tokens.is_none());
816 assert!(usage.cache_write_tokens.is_none());
817 }
818
819 #[test]
820 fn test_environment_snapshot_serde() {
821 let env = EnvironmentSnapshot {
822 working_dir: Some("/home/user/project".into()),
823 vcs_branch: Some("main".into()),
824 vcs_revision: Some("abc123".into()),
825 };
826 let json = serde_json::to_string(&env).unwrap();
827 let back: EnvironmentSnapshot = serde_json::from_str(&json).unwrap();
828 assert_eq!(back.working_dir.as_deref(), Some("/home/user/project"));
829 assert_eq!(back.vcs_branch.as_deref(), Some("main"));
830 assert_eq!(back.vcs_revision.as_deref(), Some("abc123"));
831 }
832
833 #[test]
834 fn test_environment_snapshot_default() {
835 let env = EnvironmentSnapshot::default();
836 assert!(env.working_dir.is_none());
837 assert!(env.vcs_branch.is_none());
838 assert!(env.vcs_revision.is_none());
839 }
840
841 #[test]
842 fn test_environment_snapshot_skip_none_fields() {
843 let env = EnvironmentSnapshot {
844 working_dir: Some("/tmp".into()),
845 vcs_branch: None,
846 vcs_revision: None,
847 };
848 let json = serde_json::to_string(&env).unwrap();
849 assert!(!json.contains("vcs_branch"));
850 assert!(!json.contains("vcs_revision"));
851 }
852
853 #[test]
854 fn test_delegated_work_serde() {
855 let dw = DelegatedWork {
856 agent_id: "agent-123".into(),
857 prompt: "Search for the bug".into(),
858 turns: vec![],
859 result: Some("Found the bug in auth.rs".into()),
860 };
861 let json = serde_json::to_string(&dw).unwrap();
862 assert!(!json.contains("turns")); let back: DelegatedWork = serde_json::from_str(&json).unwrap();
864 assert_eq!(back.agent_id, "agent-123");
865 assert_eq!(back.result.as_deref(), Some("Found the bug in auth.rs"));
866 assert!(back.turns.is_empty());
867 }
868
869 #[test]
870 fn test_tool_category_serde() {
871 let ti = ToolInvocation {
872 id: "t1".into(),
873 name: "Bash".into(),
874 input: serde_json::json!({"command": "ls"}),
875 result: None,
876 category: Some(ToolCategory::Shell),
877 };
878 let json = serde_json::to_string(&ti).unwrap();
879 assert!(json.contains("\"shell\""));
880 let back: ToolInvocation = serde_json::from_str(&json).unwrap();
881 assert_eq!(back.category, Some(ToolCategory::Shell));
882 }
883
884 #[test]
885 fn test_tool_category_none_skipped() {
886 let ti = ToolInvocation {
887 id: "t1".into(),
888 name: "CustomTool".into(),
889 input: serde_json::json!({}),
890 result: None,
891 category: None,
892 };
893 let json = serde_json::to_string(&ti).unwrap();
894 assert!(!json.contains("category"));
895 }
896
897 #[test]
898 fn test_tool_category_missing_defaults_none() {
899 let json = r#"{"id":"t1","name":"Read","input":{},"result":null}"#;
901 let ti: ToolInvocation = serde_json::from_str(json).unwrap();
902 assert!(ti.category.is_none());
903 }
904
905 #[test]
906 fn test_tool_category_all_variants_roundtrip() {
907 let variants = vec![
908 ToolCategory::FileRead,
909 ToolCategory::FileWrite,
910 ToolCategory::FileSearch,
911 ToolCategory::Shell,
912 ToolCategory::Network,
913 ToolCategory::Delegation,
914 ];
915 for cat in variants {
916 let json = serde_json::to_value(cat).unwrap();
917 let back: ToolCategory = serde_json::from_value(json).unwrap();
918 assert_eq!(back, cat);
919 }
920 }
921
922 #[test]
923 fn test_turn_with_environment_and_delegations() {
924 let turn = Turn {
925 id: "t1".into(),
926 parent_id: None,
927 role: Role::Assistant,
928 timestamp: "2026-01-01T00:00:00Z".into(),
929 text: "Delegating...".into(),
930 thinking: None,
931 tool_uses: vec![],
932 model: None,
933 stop_reason: None,
934 token_usage: None,
935 environment: Some(EnvironmentSnapshot {
936 working_dir: Some("/project".into()),
937 vcs_branch: Some("feat/auth".into()),
938 vcs_revision: None,
939 }),
940 delegations: vec![DelegatedWork {
941 agent_id: "sub-1".into(),
942 prompt: "Find the bug".into(),
943 turns: vec![],
944 result: None,
945 }],
946 file_mutations: Vec::new(),
947 };
948 let json = serde_json::to_string(&turn).unwrap();
949 let back: Turn = serde_json::from_str(&json).unwrap();
950 assert_eq!(
951 back.environment.as_ref().unwrap().vcs_branch.as_deref(),
952 Some("feat/auth")
953 );
954 assert_eq!(back.delegations.len(), 1);
955 assert_eq!(back.delegations[0].agent_id, "sub-1");
956 }
957
958 #[test]
959 fn test_turn_without_new_fields_deserializes() {
960 let json = r#"{"id":"t1","parent_id":null,"role":"User","timestamp":"2026-01-01T00:00:00Z","text":"hi","thinking":null,"tool_uses":[],"model":null,"stop_reason":null,"token_usage":null}"#;
962 let turn: Turn = serde_json::from_str(json).unwrap();
963 assert!(turn.environment.is_none());
964 assert!(turn.delegations.is_empty());
965 }
966
967 #[test]
968 fn test_conversation_view_new_fields_serde() {
969 let view = ConversationView {
970 id: "s1".into(),
971 started_at: None,
972 last_activity: None,
973 turns: vec![],
974 total_usage: Some(TokenUsage {
975 input_tokens: Some(1000),
976 output_tokens: Some(500),
977 cache_read_tokens: Some(800),
978 cache_write_tokens: None,
979 }),
980 provider_id: Some("claude-code".into()),
981 files_changed: vec!["src/main.rs".into(), "src/lib.rs".into()],
982 session_ids: vec![],
983 events: vec![],
984 ..Default::default()
985 };
986 let json = serde_json::to_string(&view).unwrap();
987 let back: ConversationView = serde_json::from_str(&json).unwrap();
988 assert_eq!(back.provider_id.as_deref(), Some("claude-code"));
989 assert_eq!(back.files_changed, vec!["src/main.rs", "src/lib.rs"]);
990 assert_eq!(back.total_usage.as_ref().unwrap().input_tokens, Some(1000));
991 assert_eq!(
992 back.total_usage.as_ref().unwrap().cache_read_tokens,
993 Some(800)
994 );
995 }
996
997 #[test]
998 fn test_conversation_view_old_format_deserializes() {
999 let json = r#"{"id":"s1","started_at":null,"last_activity":null,"turns":[]}"#;
1001 let view: ConversationView = serde_json::from_str(json).unwrap();
1002 assert!(view.total_usage.is_none());
1003 assert!(view.provider_id.is_none());
1004 assert!(view.files_changed.is_empty());
1005 }
1006
1007 #[test]
1008 fn test_conversation_meta() {
1009 let meta = ConversationMeta {
1010 id: "sess-1".into(),
1011 started_at: None,
1012 last_activity: None,
1013 message_count: 5,
1014 file_path: Some("/tmp/test.jsonl".into()),
1015 predecessor: None,
1016 successor: None,
1017 };
1018 let json = serde_json::to_string(&meta).unwrap();
1019 let back: ConversationMeta = serde_json::from_str(&json).unwrap();
1020 assert_eq!(back.message_count, 5);
1021 }
1022
1023 #[test]
1024 fn test_conversation_event_serde_roundtrip() {
1025 let event = ConversationEvent {
1026 id: "evt-1".into(),
1027 timestamp: "2026-01-01T00:00:00Z".into(),
1028 parent_id: Some("t1".into()),
1029 event_type: "attachment".into(),
1030 data: {
1031 let mut m = HashMap::new();
1032 m.insert("cwd".into(), serde_json::json!("/project"));
1033 m.insert("version".into(), serde_json::json!("1.0"));
1034 m
1035 },
1036 };
1037 let json = serde_json::to_string(&event).unwrap();
1038 let back: ConversationEvent = serde_json::from_str(&json).unwrap();
1039 assert_eq!(back.id, "evt-1");
1040 assert_eq!(back.event_type, "attachment");
1041 assert_eq!(back.parent_id.as_deref(), Some("t1"));
1042 assert_eq!(back.data["cwd"], serde_json::json!("/project"));
1043 }
1044
1045 #[test]
1046 fn test_conversation_event_empty_data_omitted() {
1047 let event = ConversationEvent {
1048 id: "evt-2".into(),
1049 timestamp: "2026-01-01T00:00:00Z".into(),
1050 parent_id: None,
1051 event_type: "system".into(),
1052 data: HashMap::new(),
1053 };
1054 let json = serde_json::to_string(&event).unwrap();
1055 assert!(!json.contains("data"));
1056 assert!(!json.contains("parent_id"));
1057 }
1058
1059 #[test]
1060 fn test_conversation_view_with_events_serde() {
1061 let view = ConversationView {
1062 id: "s1".into(),
1063 started_at: None,
1064 last_activity: None,
1065 turns: vec![],
1066 total_usage: None,
1067 provider_id: None,
1068 files_changed: vec![],
1069 session_ids: vec![],
1070 events: vec![ConversationEvent {
1071 id: "evt-1".into(),
1072 timestamp: "2026-01-01T00:00:00Z".into(),
1073 parent_id: None,
1074 event_type: "attachment".into(),
1075 data: HashMap::new(),
1076 }],
1077 ..Default::default()
1078 };
1079 let json = serde_json::to_string(&view).unwrap();
1080 assert!(json.contains("events"));
1081 let back: ConversationView = serde_json::from_str(&json).unwrap();
1082 assert_eq!(back.events.len(), 1);
1083 assert_eq!(back.events[0].event_type, "attachment");
1084 }
1085
1086 #[test]
1087 fn test_conversation_view_empty_events_omitted() {
1088 let view = ConversationView {
1089 id: "s1".into(),
1090 started_at: None,
1091 last_activity: None,
1092 turns: vec![],
1093 total_usage: None,
1094 provider_id: None,
1095 files_changed: vec![],
1096 session_ids: vec![],
1097 events: vec![],
1098 ..Default::default()
1099 };
1100 let json = serde_json::to_string(&view).unwrap();
1101 assert!(!json.contains("events"));
1102 }
1103
1104 #[test]
1105 fn test_conversation_view_old_format_no_events() {
1106 let json = r#"{"id":"s1","started_at":null,"last_activity":null,"turns":[]}"#;
1108 let view: ConversationView = serde_json::from_str(json).unwrap();
1109 assert!(view.events.is_empty());
1110 }
1111}