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::{BTreeMap, 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 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
76 pub breakdowns: BTreeMap<String, BTreeMap<String, u32>>,
77}
78
79#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84pub struct ProducerInfo {
85 pub name: String,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub version: Option<String>,
90}
91
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
96pub struct SessionBase {
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub working_dir: Option<String>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub vcs_revision: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub vcs_branch: Option<String>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub vcs_remote: Option<String>,
109}
110
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
120pub struct FileMutation {
121 pub path: String,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub tool_id: Option<String>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub operation: Option<String>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub raw_diff: Option<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub before: Option<String>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub after: Option<String>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub rename_to: Option<String>,
144}
145
146#[derive(Debug, Clone, Default, Serialize, Deserialize)]
150pub struct EnvironmentSnapshot {
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub working_dir: Option<String>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub vcs_branch: Option<String>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub vcs_revision: Option<String>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct DelegatedWork {
165 pub agent_id: String,
167 pub prompt: String,
169 #[serde(default, skip_serializing_if = "Vec::is_empty")]
172 pub turns: Vec<Turn>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub result: Option<String>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct ConversationEvent {
184 pub id: String,
186 pub timestamp: String,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub parent_id: Option<String>,
191 pub event_type: String,
193 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
195 pub data: HashMap<String, serde_json::Value>,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
205#[serde(rename_all = "snake_case")]
206pub enum ToolCategory {
207 FileRead,
209 FileWrite,
211 FileSearch,
213 Shell,
215 Network,
217 Delegation,
219}
220
221#[derive(Debug, Clone, Default, Serialize, Deserialize)]
223pub struct ToolInvocation {
224 pub id: String,
226 pub name: String,
228 pub input: serde_json::Value,
230 pub result: Option<ToolResult>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub category: Option<ToolCategory>,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct ToolResult {
241 pub content: String,
243 pub is_error: bool,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct Turn {
250 pub id: String,
252
253 pub parent_id: Option<String>,
255
256 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub group_id: Option<String>,
265
266 pub role: Role,
268
269 pub timestamp: String,
271
272 pub text: String,
274
275 pub thinking: Option<String>,
277
278 pub tool_uses: Vec<ToolInvocation>,
280
281 pub model: Option<String>,
283
284 pub stop_reason: Option<String>,
286
287 pub token_usage: Option<TokenUsage>,
292
293 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub attributed_token_usage: Option<TokenUsage>,
306
307 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub environment: Option<EnvironmentSnapshot>,
310
311 #[serde(default, skip_serializing_if = "Vec::is_empty")]
313 pub delegations: Vec<DelegatedWork>,
314
315 #[serde(default, skip_serializing_if = "Vec::is_empty")]
321 pub file_mutations: Vec<FileMutation>,
322}
323
324#[derive(Debug, Clone, Default, Serialize, Deserialize)]
326pub struct ConversationView {
327 pub id: String,
329
330 pub started_at: Option<DateTime<Utc>>,
332
333 pub last_activity: Option<DateTime<Utc>>,
335
336 pub turns: Vec<Turn>,
338
339 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub total_usage: Option<TokenUsage>,
342
343 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub provider_id: Option<String>,
346
347 #[serde(default, skip_serializing_if = "Vec::is_empty")]
350 pub files_changed: Vec<String>,
351
352 #[serde(default, skip_serializing_if = "Vec::is_empty")]
356 pub session_ids: Vec<String>,
357
358 #[serde(default, skip_serializing_if = "Vec::is_empty")]
362 pub events: Vec<ConversationEvent>,
363
364 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub base: Option<SessionBase>,
368
369 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub producer: Option<ProducerInfo>,
373}
374
375impl ConversationView {
376 pub fn title(&self, max_len: usize) -> Option<String> {
378 let text = self
379 .turns
380 .iter()
381 .find(|t| t.role == Role::User && !t.text.is_empty())
382 .map(|t| &t.text)?;
383
384 if text.chars().count() > max_len {
385 let truncated: String = text.chars().take(max_len).collect();
386 Some(format!("{}...", truncated))
387 } else {
388 Some(text.clone())
389 }
390 }
391
392 pub fn turns_by_role(&self, role: &Role) -> Vec<&Turn> {
394 self.turns.iter().filter(|t| &t.role == role).collect()
395 }
396
397 pub fn turns_since(&self, turn_id: &str) -> &[Turn] {
402 match self.turns.iter().position(|t| t.id == turn_id) {
403 Some(idx) if idx + 1 < self.turns.len() => &self.turns[idx + 1..],
404 Some(_) => &[],
405 None => &self.turns,
406 }
407 }
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct ConversationMeta {
417 pub id: String,
419 pub started_at: Option<DateTime<Utc>>,
421 pub last_activity: Option<DateTime<Utc>>,
423 pub message_count: usize,
425 pub file_path: Option<PathBuf>,
427 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub predecessor: Option<SessionLink>,
430 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub successor: Option<SessionLink>,
433}
434
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
439pub enum SessionLinkKind {
440 Rotation,
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize)]
446pub struct SessionLink {
447 pub session_id: String,
449 pub kind: SessionLinkKind,
451}
452
453#[derive(Debug, Clone)]
486pub enum WatcherEvent {
487 Turn(Box<Turn>),
489
490 TurnUpdated(Box<Turn>),
496
497 Progress {
499 kind: String,
500 data: serde_json::Value,
501 },
502}
503
504impl WatcherEvent {
505 pub fn as_turn(&self) -> Option<&Turn> {
508 match self {
509 WatcherEvent::Turn(t) | WatcherEvent::TurnUpdated(t) => Some(t),
510 WatcherEvent::Progress { .. } => None,
511 }
512 }
513
514 pub fn as_progress(&self) -> Option<(&str, &serde_json::Value)> {
516 match self {
517 WatcherEvent::Progress { kind, data } => Some((kind, data)),
518 _ => None,
519 }
520 }
521
522 pub fn is_update(&self) -> bool {
524 matches!(self, WatcherEvent::TurnUpdated(_))
525 }
526
527 pub fn turn_id(&self) -> Option<&str> {
529 self.as_turn().map(|t| t.id.as_str())
530 }
531}
532
533pub trait ConversationProvider {
540 fn list_conversations(&self, project: &str) -> Result<Vec<String>>;
542
543 fn load_conversation(&self, project: &str, conversation_id: &str) -> Result<ConversationView>;
545
546 fn load_metadata(&self, project: &str, conversation_id: &str) -> Result<ConversationMeta>;
548
549 fn list_metadata(&self, project: &str) -> Result<Vec<ConversationMeta>>;
551}
552
553pub trait ConversationWatcher {
555 fn poll(&mut self) -> Result<Vec<WatcherEvent>>;
557
558 fn seen_count(&self) -> usize;
560}
561
562pub use extract::extract_conversation;
563pub use project::{AnyProjector, ConversationProjector};
564
565#[cfg(test)]
568mod tests {
569 use super::*;
570
571 fn sample_view() -> ConversationView {
572 ConversationView {
573 id: "sess-1".into(),
574 started_at: None,
575 last_activity: None,
576 turns: vec![
577 Turn {
578 id: "t1".into(),
579 parent_id: None,
580 group_id: None,
581 role: Role::User,
582 timestamp: "2026-01-01T00:00:00Z".into(),
583 text: "Fix the authentication bug in login.rs".into(),
584 thinking: None,
585 tool_uses: vec![],
586 model: None,
587 stop_reason: None,
588 token_usage: None,
589 attributed_token_usage: None,
590 environment: None,
591 delegations: vec![],
592 file_mutations: Vec::new(),
593 },
594 Turn {
595 id: "t2".into(),
596 parent_id: Some("t1".into()),
597 group_id: None,
598 role: Role::Assistant,
599 timestamp: "2026-01-01T00:00:01Z".into(),
600 text: "I'll fix that for you.".into(),
601 thinking: Some("The bug is in the token validation".into()),
602 tool_uses: vec![ToolInvocation {
603 id: "tool-1".into(),
604 name: "Read".into(),
605 input: serde_json::json!({"file": "src/login.rs"}),
606 result: Some(ToolResult {
607 content: "fn login() { ... }".into(),
608 is_error: false,
609 }),
610 category: Some(ToolCategory::FileRead),
611 }],
612 model: Some("claude-opus-4-6".into()),
613 stop_reason: Some("end_turn".into()),
614 token_usage: Some(TokenUsage {
615 input_tokens: Some(100),
616 output_tokens: Some(50),
617 cache_read_tokens: None,
618 cache_write_tokens: None,
619 ..Default::default()
620 }),
621 attributed_token_usage: None,
622 environment: None,
623 delegations: vec![],
624 file_mutations: Vec::new(),
625 },
626 Turn {
627 id: "t3".into(),
628 parent_id: Some("t2".into()),
629 group_id: None,
630 role: Role::User,
631 timestamp: "2026-01-01T00:00:02Z".into(),
632 text: "Thanks!".into(),
633 thinking: None,
634 tool_uses: vec![],
635 model: None,
636 stop_reason: None,
637 token_usage: None,
638 attributed_token_usage: None,
639 environment: None,
640 delegations: vec![],
641 file_mutations: Vec::new(),
642 },
643 ],
644 total_usage: None,
645 provider_id: None,
646 files_changed: vec![],
647 session_ids: vec![],
648 events: vec![],
649 ..Default::default()
650 }
651 }
652
653 #[test]
654 fn test_title_short() {
655 let view = sample_view();
656 let title = view.title(100).unwrap();
657 assert_eq!(title, "Fix the authentication bug in login.rs");
658 }
659
660 #[test]
661 fn test_title_truncated() {
662 let view = sample_view();
663 let title = view.title(10).unwrap();
664 assert_eq!(title, "Fix the au...");
665 }
666
667 #[test]
668 fn test_title_empty() {
669 let view = ConversationView {
670 id: "empty".into(),
671 started_at: None,
672 last_activity: None,
673 turns: vec![],
674 total_usage: None,
675 provider_id: None,
676 files_changed: vec![],
677 session_ids: vec![],
678 events: vec![],
679 ..Default::default()
680 };
681 assert!(view.title(50).is_none());
682 }
683
684 #[test]
685 fn test_turns_by_role() {
686 let view = sample_view();
687 let users = view.turns_by_role(&Role::User);
688 assert_eq!(users.len(), 2);
689 let assistants = view.turns_by_role(&Role::Assistant);
690 assert_eq!(assistants.len(), 1);
691 }
692
693 #[test]
694 fn test_turns_since_middle() {
695 let view = sample_view();
696 let since = view.turns_since("t1");
697 assert_eq!(since.len(), 2);
698 assert_eq!(since[0].id, "t2");
699 }
700
701 #[test]
702 fn test_turns_since_last() {
703 let view = sample_view();
704 let since = view.turns_since("t3");
705 assert!(since.is_empty());
706 }
707
708 #[test]
709 fn test_turns_since_unknown() {
710 let view = sample_view();
711 let since = view.turns_since("nonexistent");
712 assert_eq!(since.len(), 3);
713 }
714
715 #[test]
716 fn test_role_display() {
717 assert_eq!(Role::User.to_string(), "user");
718 assert_eq!(Role::Assistant.to_string(), "assistant");
719 assert_eq!(Role::System.to_string(), "system");
720 assert_eq!(Role::Other("tool".into()).to_string(), "tool");
721 }
722
723 #[test]
724 fn test_role_equality() {
725 assert_eq!(Role::User, Role::User);
726 assert_ne!(Role::User, Role::Assistant);
727 assert_eq!(Role::Other("x".into()), Role::Other("x".into()));
728 assert_ne!(Role::Other("x".into()), Role::Other("y".into()));
729 }
730
731 #[test]
732 fn test_turn_serde_roundtrip() {
733 let turn = &sample_view().turns[1];
734 let json = serde_json::to_string(turn).unwrap();
735 let back: Turn = serde_json::from_str(&json).unwrap();
736 assert_eq!(back.id, "t2");
737 assert_eq!(back.model, Some("claude-opus-4-6".into()));
738 assert_eq!(back.tool_uses.len(), 1);
739 assert_eq!(back.tool_uses[0].name, "Read");
740 assert!(back.tool_uses[0].result.is_some());
741 }
742
743 #[test]
744 fn test_conversation_view_serde_roundtrip() {
745 let view = sample_view();
746 let json = serde_json::to_string(&view).unwrap();
747 let back: ConversationView = serde_json::from_str(&json).unwrap();
748 assert_eq!(back.id, "sess-1");
749 assert_eq!(back.turns.len(), 3);
750 }
751
752 #[test]
753 fn test_watcher_event_variants() {
754 let turn_event = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
755 assert!(matches!(turn_event, WatcherEvent::Turn(_)));
756
757 let updated_event = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[1].clone()));
758 assert!(matches!(updated_event, WatcherEvent::TurnUpdated(_)));
759
760 let progress_event = WatcherEvent::Progress {
761 kind: "agent_progress".into(),
762 data: serde_json::json!({"status": "running"}),
763 };
764 assert!(matches!(progress_event, WatcherEvent::Progress { .. }));
765 }
766
767 #[test]
768 fn test_watcher_event_as_turn() {
769 let turn = sample_view().turns[0].clone();
770 let event = WatcherEvent::Turn(Box::new(turn.clone()));
771 assert_eq!(event.as_turn().unwrap().id, "t1");
772
773 let updated = WatcherEvent::TurnUpdated(Box::new(turn));
774 assert_eq!(updated.as_turn().unwrap().id, "t1");
775
776 let progress = WatcherEvent::Progress {
777 kind: "test".into(),
778 data: serde_json::Value::Null,
779 };
780 assert!(progress.as_turn().is_none());
781 }
782
783 #[test]
784 fn test_watcher_event_as_progress() {
785 let progress = WatcherEvent::Progress {
786 kind: "hook_progress".into(),
787 data: serde_json::json!({"hookName": "pre-commit"}),
788 };
789 let (kind, data) = progress.as_progress().unwrap();
790 assert_eq!(kind, "hook_progress");
791 assert_eq!(data["hookName"], "pre-commit");
792
793 let turn = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
794 assert!(turn.as_progress().is_none());
795 }
796
797 #[test]
798 fn test_watcher_event_is_update() {
799 let turn = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
800 assert!(!turn.is_update());
801
802 let updated = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[0].clone()));
803 assert!(updated.is_update());
804
805 let progress = WatcherEvent::Progress {
806 kind: "test".into(),
807 data: serde_json::Value::Null,
808 };
809 assert!(!progress.is_update());
810 }
811
812 #[test]
813 fn test_watcher_event_turn_id() {
814 let turn = WatcherEvent::Turn(Box::new(sample_view().turns[1].clone()));
815 assert_eq!(turn.turn_id(), Some("t2"));
816
817 let updated = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[0].clone()));
818 assert_eq!(updated.turn_id(), Some("t1"));
819
820 let progress = WatcherEvent::Progress {
821 kind: "test".into(),
822 data: serde_json::Value::Null,
823 };
824 assert!(progress.turn_id().is_none());
825 }
826
827 #[test]
828 fn test_token_usage_default() {
829 let usage = TokenUsage::default();
830 assert!(usage.input_tokens.is_none());
831 assert!(usage.output_tokens.is_none());
832 assert!(usage.cache_read_tokens.is_none());
833 assert!(usage.cache_write_tokens.is_none());
834 }
835
836 #[test]
837 fn test_token_usage_cache_fields_serde() {
838 let usage = TokenUsage {
839 input_tokens: Some(100),
840 output_tokens: Some(50),
841 cache_read_tokens: Some(500),
842 cache_write_tokens: Some(200),
843 ..Default::default()
844 };
845 let json = serde_json::to_string(&usage).unwrap();
846 let back: TokenUsage = serde_json::from_str(&json).unwrap();
847 assert_eq!(back.cache_read_tokens, Some(500));
848 assert_eq!(back.cache_write_tokens, Some(200));
849 }
850
851 #[test]
852 fn test_token_usage_cache_fields_omitted() {
853 let json = r#"{"input_tokens":100,"output_tokens":50}"#;
855 let usage: TokenUsage = serde_json::from_str(json).unwrap();
856 assert_eq!(usage.input_tokens, Some(100));
857 assert!(usage.cache_read_tokens.is_none());
858 assert!(usage.cache_write_tokens.is_none());
859 }
860
861 #[test]
862 fn test_environment_snapshot_serde() {
863 let env = EnvironmentSnapshot {
864 working_dir: Some("/home/user/project".into()),
865 vcs_branch: Some("main".into()),
866 vcs_revision: Some("abc123".into()),
867 };
868 let json = serde_json::to_string(&env).unwrap();
869 let back: EnvironmentSnapshot = serde_json::from_str(&json).unwrap();
870 assert_eq!(back.working_dir.as_deref(), Some("/home/user/project"));
871 assert_eq!(back.vcs_branch.as_deref(), Some("main"));
872 assert_eq!(back.vcs_revision.as_deref(), Some("abc123"));
873 }
874
875 #[test]
876 fn test_environment_snapshot_default() {
877 let env = EnvironmentSnapshot::default();
878 assert!(env.working_dir.is_none());
879 assert!(env.vcs_branch.is_none());
880 assert!(env.vcs_revision.is_none());
881 }
882
883 #[test]
884 fn test_environment_snapshot_skip_none_fields() {
885 let env = EnvironmentSnapshot {
886 working_dir: Some("/tmp".into()),
887 vcs_branch: None,
888 vcs_revision: None,
889 };
890 let json = serde_json::to_string(&env).unwrap();
891 assert!(!json.contains("vcs_branch"));
892 assert!(!json.contains("vcs_revision"));
893 }
894
895 #[test]
896 fn test_delegated_work_serde() {
897 let dw = DelegatedWork {
898 agent_id: "agent-123".into(),
899 prompt: "Search for the bug".into(),
900 turns: vec![],
901 result: Some("Found the bug in auth.rs".into()),
902 };
903 let json = serde_json::to_string(&dw).unwrap();
904 assert!(!json.contains("turns")); let back: DelegatedWork = serde_json::from_str(&json).unwrap();
906 assert_eq!(back.agent_id, "agent-123");
907 assert_eq!(back.result.as_deref(), Some("Found the bug in auth.rs"));
908 assert!(back.turns.is_empty());
909 }
910
911 #[test]
912 fn test_tool_category_serde() {
913 let ti = ToolInvocation {
914 id: "t1".into(),
915 name: "Bash".into(),
916 input: serde_json::json!({"command": "ls"}),
917 result: None,
918 category: Some(ToolCategory::Shell),
919 };
920 let json = serde_json::to_string(&ti).unwrap();
921 assert!(json.contains("\"shell\""));
922 let back: ToolInvocation = serde_json::from_str(&json).unwrap();
923 assert_eq!(back.category, Some(ToolCategory::Shell));
924 }
925
926 #[test]
927 fn test_tool_category_none_skipped() {
928 let ti = ToolInvocation {
929 id: "t1".into(),
930 name: "CustomTool".into(),
931 input: serde_json::json!({}),
932 result: None,
933 category: None,
934 };
935 let json = serde_json::to_string(&ti).unwrap();
936 assert!(!json.contains("category"));
937 }
938
939 #[test]
940 fn test_tool_category_missing_defaults_none() {
941 let json = r#"{"id":"t1","name":"Read","input":{},"result":null}"#;
943 let ti: ToolInvocation = serde_json::from_str(json).unwrap();
944 assert!(ti.category.is_none());
945 }
946
947 #[test]
948 fn test_tool_category_all_variants_roundtrip() {
949 let variants = vec![
950 ToolCategory::FileRead,
951 ToolCategory::FileWrite,
952 ToolCategory::FileSearch,
953 ToolCategory::Shell,
954 ToolCategory::Network,
955 ToolCategory::Delegation,
956 ];
957 for cat in variants {
958 let json = serde_json::to_value(cat).unwrap();
959 let back: ToolCategory = serde_json::from_value(json).unwrap();
960 assert_eq!(back, cat);
961 }
962 }
963
964 #[test]
965 fn test_turn_with_environment_and_delegations() {
966 let turn = Turn {
967 id: "t1".into(),
968 parent_id: None,
969 group_id: None,
970 role: Role::Assistant,
971 timestamp: "2026-01-01T00:00:00Z".into(),
972 text: "Delegating...".into(),
973 thinking: None,
974 tool_uses: vec![],
975 model: None,
976 stop_reason: None,
977 token_usage: None,
978 attributed_token_usage: None,
979 environment: Some(EnvironmentSnapshot {
980 working_dir: Some("/project".into()),
981 vcs_branch: Some("feat/auth".into()),
982 vcs_revision: None,
983 }),
984 delegations: vec![DelegatedWork {
985 agent_id: "sub-1".into(),
986 prompt: "Find the bug".into(),
987 turns: vec![],
988 result: None,
989 }],
990 file_mutations: Vec::new(),
991 };
992 let json = serde_json::to_string(&turn).unwrap();
993 let back: Turn = serde_json::from_str(&json).unwrap();
994 assert_eq!(
995 back.environment.as_ref().unwrap().vcs_branch.as_deref(),
996 Some("feat/auth")
997 );
998 assert_eq!(back.delegations.len(), 1);
999 assert_eq!(back.delegations[0].agent_id, "sub-1");
1000 }
1001
1002 #[test]
1003 fn test_turn_without_new_fields_deserializes() {
1004 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}"#;
1006 let turn: Turn = serde_json::from_str(json).unwrap();
1007 assert!(turn.environment.is_none());
1008 assert!(turn.delegations.is_empty());
1009 }
1010
1011 #[test]
1012 fn test_conversation_view_new_fields_serde() {
1013 let view = ConversationView {
1014 id: "s1".into(),
1015 started_at: None,
1016 last_activity: None,
1017 turns: vec![],
1018 total_usage: Some(TokenUsage {
1019 input_tokens: Some(1000),
1020 output_tokens: Some(500),
1021 cache_read_tokens: Some(800),
1022 cache_write_tokens: None,
1023 ..Default::default()
1024 }),
1025 provider_id: Some("claude-code".into()),
1026 files_changed: vec!["src/main.rs".into(), "src/lib.rs".into()],
1027 session_ids: vec![],
1028 events: vec![],
1029 ..Default::default()
1030 };
1031 let json = serde_json::to_string(&view).unwrap();
1032 let back: ConversationView = serde_json::from_str(&json).unwrap();
1033 assert_eq!(back.provider_id.as_deref(), Some("claude-code"));
1034 assert_eq!(back.files_changed, vec!["src/main.rs", "src/lib.rs"]);
1035 assert_eq!(back.total_usage.as_ref().unwrap().input_tokens, Some(1000));
1036 assert_eq!(
1037 back.total_usage.as_ref().unwrap().cache_read_tokens,
1038 Some(800)
1039 );
1040 }
1041
1042 #[test]
1043 fn test_conversation_view_old_format_deserializes() {
1044 let json = r#"{"id":"s1","started_at":null,"last_activity":null,"turns":[]}"#;
1046 let view: ConversationView = serde_json::from_str(json).unwrap();
1047 assert!(view.total_usage.is_none());
1048 assert!(view.provider_id.is_none());
1049 assert!(view.files_changed.is_empty());
1050 }
1051
1052 #[test]
1053 fn test_conversation_meta() {
1054 let meta = ConversationMeta {
1055 id: "sess-1".into(),
1056 started_at: None,
1057 last_activity: None,
1058 message_count: 5,
1059 file_path: Some("/tmp/test.jsonl".into()),
1060 predecessor: None,
1061 successor: None,
1062 };
1063 let json = serde_json::to_string(&meta).unwrap();
1064 let back: ConversationMeta = serde_json::from_str(&json).unwrap();
1065 assert_eq!(back.message_count, 5);
1066 }
1067
1068 #[test]
1069 fn test_conversation_event_serde_roundtrip() {
1070 let event = ConversationEvent {
1071 id: "evt-1".into(),
1072 timestamp: "2026-01-01T00:00:00Z".into(),
1073 parent_id: Some("t1".into()),
1074 event_type: "attachment".into(),
1075 data: {
1076 let mut m = HashMap::new();
1077 m.insert("cwd".into(), serde_json::json!("/project"));
1078 m.insert("version".into(), serde_json::json!("1.0"));
1079 m
1080 },
1081 };
1082 let json = serde_json::to_string(&event).unwrap();
1083 let back: ConversationEvent = serde_json::from_str(&json).unwrap();
1084 assert_eq!(back.id, "evt-1");
1085 assert_eq!(back.event_type, "attachment");
1086 assert_eq!(back.parent_id.as_deref(), Some("t1"));
1087 assert_eq!(back.data["cwd"], serde_json::json!("/project"));
1088 }
1089
1090 #[test]
1091 fn test_conversation_event_empty_data_omitted() {
1092 let event = ConversationEvent {
1093 id: "evt-2".into(),
1094 timestamp: "2026-01-01T00:00:00Z".into(),
1095 parent_id: None,
1096 event_type: "system".into(),
1097 data: HashMap::new(),
1098 };
1099 let json = serde_json::to_string(&event).unwrap();
1100 assert!(!json.contains("data"));
1101 assert!(!json.contains("parent_id"));
1102 }
1103
1104 #[test]
1105 fn test_conversation_view_with_events_serde() {
1106 let view = ConversationView {
1107 id: "s1".into(),
1108 started_at: None,
1109 last_activity: None,
1110 turns: vec![],
1111 total_usage: None,
1112 provider_id: None,
1113 files_changed: vec![],
1114 session_ids: vec![],
1115 events: vec![ConversationEvent {
1116 id: "evt-1".into(),
1117 timestamp: "2026-01-01T00:00:00Z".into(),
1118 parent_id: None,
1119 event_type: "attachment".into(),
1120 data: HashMap::new(),
1121 }],
1122 ..Default::default()
1123 };
1124 let json = serde_json::to_string(&view).unwrap();
1125 assert!(json.contains("events"));
1126 let back: ConversationView = serde_json::from_str(&json).unwrap();
1127 assert_eq!(back.events.len(), 1);
1128 assert_eq!(back.events[0].event_type, "attachment");
1129 }
1130
1131 #[test]
1132 fn test_conversation_view_empty_events_omitted() {
1133 let view = ConversationView {
1134 id: "s1".into(),
1135 started_at: None,
1136 last_activity: None,
1137 turns: vec![],
1138 total_usage: None,
1139 provider_id: None,
1140 files_changed: vec![],
1141 session_ids: vec![],
1142 events: vec![],
1143 ..Default::default()
1144 };
1145 let json = serde_json::to_string(&view).unwrap();
1146 assert!(!json.contains("events"));
1147 }
1148
1149 #[test]
1150 fn test_conversation_view_old_format_no_events() {
1151 let json = r#"{"id":"s1","started_at":null,"last_activity":null,"turns":[]}"#;
1153 let view: ConversationView = serde_json::from_str(json).unwrap();
1154 assert!(view.events.is_empty());
1155 }
1156}