1#![doc = include_str!("../README.md")]
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::PathBuf;
7
8#[derive(Debug, thiserror::Error)]
12pub enum ConvoError {
13 #[error("I/O error: {0}")]
14 Io(#[from] std::io::Error),
15
16 #[error("JSON error: {0}")]
17 Json(#[from] serde_json::Error),
18
19 #[error("provider error: {0}")]
20 Provider(String),
21
22 #[error("{0}")]
23 Other(#[from] Box<dyn std::error::Error + Send + Sync>),
24}
25
26pub type Result<T> = std::result::Result<T, ConvoError>;
27
28#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
32pub enum Role {
33 User,
34 Assistant,
35 System,
36 Other(String),
38}
39
40impl std::fmt::Display for Role {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 match self {
43 Role::User => write!(f, "user"),
44 Role::Assistant => write!(f, "assistant"),
45 Role::System => write!(f, "system"),
46 Role::Other(s) => write!(f, "{}", s),
47 }
48 }
49}
50
51#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53pub struct TokenUsage {
54 pub input_tokens: Option<u32>,
56 pub output_tokens: Option<u32>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub cache_read_tokens: Option<u32>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub cache_write_tokens: Option<u32>,
64}
65
66#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70pub struct EnvironmentSnapshot {
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub working_dir: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub vcs_branch: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub vcs_revision: Option<String>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct DelegatedWork {
85 pub agent_id: String,
87 pub prompt: String,
89 #[serde(default, skip_serializing_if = "Vec::is_empty")]
92 pub turns: Vec<Turn>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub result: Option<String>,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum ToolCategory {
107 FileRead,
109 FileWrite,
111 FileSearch,
113 Shell,
115 Network,
117 Delegation,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ToolInvocation {
124 pub id: String,
126 pub name: String,
128 pub input: serde_json::Value,
130 pub result: Option<ToolResult>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub category: Option<ToolCategory>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct ToolResult {
141 pub content: String,
143 pub is_error: bool,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct Turn {
150 pub id: String,
152
153 pub parent_id: Option<String>,
155
156 pub role: Role,
158
159 pub timestamp: String,
161
162 pub text: String,
164
165 pub thinking: Option<String>,
167
168 pub tool_uses: Vec<ToolInvocation>,
170
171 pub model: Option<String>,
173
174 pub stop_reason: Option<String>,
176
177 pub token_usage: Option<TokenUsage>,
179
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub environment: Option<EnvironmentSnapshot>,
183
184 #[serde(default, skip_serializing_if = "Vec::is_empty")]
186 pub delegations: Vec<DelegatedWork>,
187
188 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
194 pub extra: HashMap<String, serde_json::Value>,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct ConversationView {
200 pub id: String,
202
203 pub started_at: Option<DateTime<Utc>>,
205
206 pub last_activity: Option<DateTime<Utc>>,
208
209 pub turns: Vec<Turn>,
211
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub total_usage: Option<TokenUsage>,
215
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub provider_id: Option<String>,
219
220 #[serde(default, skip_serializing_if = "Vec::is_empty")]
223 pub files_changed: Vec<String>,
224
225 #[serde(default, skip_serializing_if = "Vec::is_empty")]
229 pub session_ids: Vec<String>,
230}
231
232impl ConversationView {
233 pub fn title(&self, max_len: usize) -> Option<String> {
235 let text = self
236 .turns
237 .iter()
238 .find(|t| t.role == Role::User && !t.text.is_empty())
239 .map(|t| &t.text)?;
240
241 if text.chars().count() > max_len {
242 let truncated: String = text.chars().take(max_len).collect();
243 Some(format!("{}...", truncated))
244 } else {
245 Some(text.clone())
246 }
247 }
248
249 pub fn turns_by_role(&self, role: &Role) -> Vec<&Turn> {
251 self.turns.iter().filter(|t| &t.role == role).collect()
252 }
253
254 pub fn turns_since(&self, turn_id: &str) -> &[Turn] {
259 match self.turns.iter().position(|t| t.id == turn_id) {
260 Some(idx) if idx + 1 < self.turns.len() => &self.turns[idx + 1..],
261 Some(_) => &[],
262 None => &self.turns,
263 }
264 }
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct ConversationMeta {
274 pub id: String,
276 pub started_at: Option<DateTime<Utc>>,
278 pub last_activity: Option<DateTime<Utc>>,
280 pub message_count: usize,
282 pub file_path: Option<PathBuf>,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub predecessor: Option<SessionLink>,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub successor: Option<SessionLink>,
290}
291
292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296pub enum SessionLinkKind {
297 Rotation,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct SessionLink {
304 pub session_id: String,
306 pub kind: SessionLinkKind,
308}
309
310#[derive(Debug, Clone)]
343pub enum WatcherEvent {
344 Turn(Box<Turn>),
346
347 TurnUpdated(Box<Turn>),
353
354 Progress {
356 kind: String,
357 data: serde_json::Value,
358 },
359}
360
361impl WatcherEvent {
362 pub fn as_turn(&self) -> Option<&Turn> {
365 match self {
366 WatcherEvent::Turn(t) | WatcherEvent::TurnUpdated(t) => Some(t),
367 WatcherEvent::Progress { .. } => None,
368 }
369 }
370
371 pub fn as_progress(&self) -> Option<(&str, &serde_json::Value)> {
373 match self {
374 WatcherEvent::Progress { kind, data } => Some((kind, data)),
375 _ => None,
376 }
377 }
378
379 pub fn is_update(&self) -> bool {
381 matches!(self, WatcherEvent::TurnUpdated(_))
382 }
383
384 pub fn turn_id(&self) -> Option<&str> {
386 self.as_turn().map(|t| t.id.as_str())
387 }
388}
389
390pub trait ConversationProvider {
397 fn list_conversations(&self, project: &str) -> Result<Vec<String>>;
399
400 fn load_conversation(&self, project: &str, conversation_id: &str) -> Result<ConversationView>;
402
403 fn load_metadata(&self, project: &str, conversation_id: &str) -> Result<ConversationMeta>;
405
406 fn list_metadata(&self, project: &str) -> Result<Vec<ConversationMeta>>;
408}
409
410pub trait ConversationWatcher {
412 fn poll(&mut self) -> Result<Vec<WatcherEvent>>;
414
415 fn seen_count(&self) -> usize;
417}
418
419#[cfg(test)]
422mod tests {
423 use super::*;
424
425 fn sample_view() -> ConversationView {
426 ConversationView {
427 id: "sess-1".into(),
428 started_at: None,
429 last_activity: None,
430 turns: vec![
431 Turn {
432 id: "t1".into(),
433 parent_id: None,
434 role: Role::User,
435 timestamp: "2026-01-01T00:00:00Z".into(),
436 text: "Fix the authentication bug in login.rs".into(),
437 thinking: None,
438 tool_uses: vec![],
439 model: None,
440 stop_reason: None,
441 token_usage: None,
442 environment: None,
443 delegations: vec![],
444 extra: HashMap::new(),
445 },
446 Turn {
447 id: "t2".into(),
448 parent_id: Some("t1".into()),
449 role: Role::Assistant,
450 timestamp: "2026-01-01T00:00:01Z".into(),
451 text: "I'll fix that for you.".into(),
452 thinking: Some("The bug is in the token validation".into()),
453 tool_uses: vec![ToolInvocation {
454 id: "tool-1".into(),
455 name: "Read".into(),
456 input: serde_json::json!({"file": "src/login.rs"}),
457 result: Some(ToolResult {
458 content: "fn login() { ... }".into(),
459 is_error: false,
460 }),
461 category: Some(ToolCategory::FileRead),
462 }],
463 model: Some("claude-opus-4-6".into()),
464 stop_reason: Some("end_turn".into()),
465 token_usage: Some(TokenUsage {
466 input_tokens: Some(100),
467 output_tokens: Some(50),
468 cache_read_tokens: None,
469 cache_write_tokens: None,
470 }),
471 environment: None,
472 delegations: vec![],
473 extra: HashMap::new(),
474 },
475 Turn {
476 id: "t3".into(),
477 parent_id: Some("t2".into()),
478 role: Role::User,
479 timestamp: "2026-01-01T00:00:02Z".into(),
480 text: "Thanks!".into(),
481 thinking: None,
482 tool_uses: vec![],
483 model: None,
484 stop_reason: None,
485 token_usage: None,
486 environment: None,
487 delegations: vec![],
488 extra: HashMap::new(),
489 },
490 ],
491 total_usage: None,
492 provider_id: None,
493 files_changed: vec![],
494 session_ids: vec![],
495 }
496 }
497
498 #[test]
499 fn test_title_short() {
500 let view = sample_view();
501 let title = view.title(100).unwrap();
502 assert_eq!(title, "Fix the authentication bug in login.rs");
503 }
504
505 #[test]
506 fn test_title_truncated() {
507 let view = sample_view();
508 let title = view.title(10).unwrap();
509 assert_eq!(title, "Fix the au...");
510 }
511
512 #[test]
513 fn test_title_empty() {
514 let view = ConversationView {
515 id: "empty".into(),
516 started_at: None,
517 last_activity: None,
518 turns: vec![],
519 total_usage: None,
520 provider_id: None,
521 files_changed: vec![],
522 session_ids: vec![],
523 };
524 assert!(view.title(50).is_none());
525 }
526
527 #[test]
528 fn test_turns_by_role() {
529 let view = sample_view();
530 let users = view.turns_by_role(&Role::User);
531 assert_eq!(users.len(), 2);
532 let assistants = view.turns_by_role(&Role::Assistant);
533 assert_eq!(assistants.len(), 1);
534 }
535
536 #[test]
537 fn test_turns_since_middle() {
538 let view = sample_view();
539 let since = view.turns_since("t1");
540 assert_eq!(since.len(), 2);
541 assert_eq!(since[0].id, "t2");
542 }
543
544 #[test]
545 fn test_turns_since_last() {
546 let view = sample_view();
547 let since = view.turns_since("t3");
548 assert!(since.is_empty());
549 }
550
551 #[test]
552 fn test_turns_since_unknown() {
553 let view = sample_view();
554 let since = view.turns_since("nonexistent");
555 assert_eq!(since.len(), 3);
556 }
557
558 #[test]
559 fn test_role_display() {
560 assert_eq!(Role::User.to_string(), "user");
561 assert_eq!(Role::Assistant.to_string(), "assistant");
562 assert_eq!(Role::System.to_string(), "system");
563 assert_eq!(Role::Other("tool".into()).to_string(), "tool");
564 }
565
566 #[test]
567 fn test_role_equality() {
568 assert_eq!(Role::User, Role::User);
569 assert_ne!(Role::User, Role::Assistant);
570 assert_eq!(Role::Other("x".into()), Role::Other("x".into()));
571 assert_ne!(Role::Other("x".into()), Role::Other("y".into()));
572 }
573
574 #[test]
575 fn test_turn_serde_roundtrip() {
576 let turn = &sample_view().turns[1];
577 let json = serde_json::to_string(turn).unwrap();
578 let back: Turn = serde_json::from_str(&json).unwrap();
579 assert_eq!(back.id, "t2");
580 assert_eq!(back.model, Some("claude-opus-4-6".into()));
581 assert_eq!(back.tool_uses.len(), 1);
582 assert_eq!(back.tool_uses[0].name, "Read");
583 assert!(back.tool_uses[0].result.is_some());
584 }
585
586 #[test]
587 fn test_conversation_view_serde_roundtrip() {
588 let view = sample_view();
589 let json = serde_json::to_string(&view).unwrap();
590 let back: ConversationView = serde_json::from_str(&json).unwrap();
591 assert_eq!(back.id, "sess-1");
592 assert_eq!(back.turns.len(), 3);
593 }
594
595 #[test]
596 fn test_watcher_event_variants() {
597 let turn_event = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
598 assert!(matches!(turn_event, WatcherEvent::Turn(_)));
599
600 let updated_event = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[1].clone()));
601 assert!(matches!(updated_event, WatcherEvent::TurnUpdated(_)));
602
603 let progress_event = WatcherEvent::Progress {
604 kind: "agent_progress".into(),
605 data: serde_json::json!({"status": "running"}),
606 };
607 assert!(matches!(progress_event, WatcherEvent::Progress { .. }));
608 }
609
610 #[test]
611 fn test_watcher_event_as_turn() {
612 let turn = sample_view().turns[0].clone();
613 let event = WatcherEvent::Turn(Box::new(turn.clone()));
614 assert_eq!(event.as_turn().unwrap().id, "t1");
615
616 let updated = WatcherEvent::TurnUpdated(Box::new(turn));
617 assert_eq!(updated.as_turn().unwrap().id, "t1");
618
619 let progress = WatcherEvent::Progress {
620 kind: "test".into(),
621 data: serde_json::Value::Null,
622 };
623 assert!(progress.as_turn().is_none());
624 }
625
626 #[test]
627 fn test_watcher_event_as_progress() {
628 let progress = WatcherEvent::Progress {
629 kind: "hook_progress".into(),
630 data: serde_json::json!({"hookName": "pre-commit"}),
631 };
632 let (kind, data) = progress.as_progress().unwrap();
633 assert_eq!(kind, "hook_progress");
634 assert_eq!(data["hookName"], "pre-commit");
635
636 let turn = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
637 assert!(turn.as_progress().is_none());
638 }
639
640 #[test]
641 fn test_watcher_event_is_update() {
642 let turn = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
643 assert!(!turn.is_update());
644
645 let updated = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[0].clone()));
646 assert!(updated.is_update());
647
648 let progress = WatcherEvent::Progress {
649 kind: "test".into(),
650 data: serde_json::Value::Null,
651 };
652 assert!(!progress.is_update());
653 }
654
655 #[test]
656 fn test_watcher_event_turn_id() {
657 let turn = WatcherEvent::Turn(Box::new(sample_view().turns[1].clone()));
658 assert_eq!(turn.turn_id(), Some("t2"));
659
660 let updated = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[0].clone()));
661 assert_eq!(updated.turn_id(), Some("t1"));
662
663 let progress = WatcherEvent::Progress {
664 kind: "test".into(),
665 data: serde_json::Value::Null,
666 };
667 assert!(progress.turn_id().is_none());
668 }
669
670 #[test]
671 fn test_token_usage_default() {
672 let usage = TokenUsage::default();
673 assert!(usage.input_tokens.is_none());
674 assert!(usage.output_tokens.is_none());
675 assert!(usage.cache_read_tokens.is_none());
676 assert!(usage.cache_write_tokens.is_none());
677 }
678
679 #[test]
680 fn test_token_usage_cache_fields_serde() {
681 let usage = TokenUsage {
682 input_tokens: Some(100),
683 output_tokens: Some(50),
684 cache_read_tokens: Some(500),
685 cache_write_tokens: Some(200),
686 };
687 let json = serde_json::to_string(&usage).unwrap();
688 let back: TokenUsage = serde_json::from_str(&json).unwrap();
689 assert_eq!(back.cache_read_tokens, Some(500));
690 assert_eq!(back.cache_write_tokens, Some(200));
691 }
692
693 #[test]
694 fn test_token_usage_cache_fields_omitted() {
695 let json = r#"{"input_tokens":100,"output_tokens":50}"#;
697 let usage: TokenUsage = serde_json::from_str(json).unwrap();
698 assert_eq!(usage.input_tokens, Some(100));
699 assert!(usage.cache_read_tokens.is_none());
700 assert!(usage.cache_write_tokens.is_none());
701 }
702
703 #[test]
704 fn test_environment_snapshot_serde() {
705 let env = EnvironmentSnapshot {
706 working_dir: Some("/home/user/project".into()),
707 vcs_branch: Some("main".into()),
708 vcs_revision: Some("abc123".into()),
709 };
710 let json = serde_json::to_string(&env).unwrap();
711 let back: EnvironmentSnapshot = serde_json::from_str(&json).unwrap();
712 assert_eq!(back.working_dir.as_deref(), Some("/home/user/project"));
713 assert_eq!(back.vcs_branch.as_deref(), Some("main"));
714 assert_eq!(back.vcs_revision.as_deref(), Some("abc123"));
715 }
716
717 #[test]
718 fn test_environment_snapshot_default() {
719 let env = EnvironmentSnapshot::default();
720 assert!(env.working_dir.is_none());
721 assert!(env.vcs_branch.is_none());
722 assert!(env.vcs_revision.is_none());
723 }
724
725 #[test]
726 fn test_environment_snapshot_skip_none_fields() {
727 let env = EnvironmentSnapshot {
728 working_dir: Some("/tmp".into()),
729 vcs_branch: None,
730 vcs_revision: None,
731 };
732 let json = serde_json::to_string(&env).unwrap();
733 assert!(!json.contains("vcs_branch"));
734 assert!(!json.contains("vcs_revision"));
735 }
736
737 #[test]
738 fn test_delegated_work_serde() {
739 let dw = DelegatedWork {
740 agent_id: "agent-123".into(),
741 prompt: "Search for the bug".into(),
742 turns: vec![],
743 result: Some("Found the bug in auth.rs".into()),
744 };
745 let json = serde_json::to_string(&dw).unwrap();
746 assert!(!json.contains("turns")); let back: DelegatedWork = serde_json::from_str(&json).unwrap();
748 assert_eq!(back.agent_id, "agent-123");
749 assert_eq!(back.result.as_deref(), Some("Found the bug in auth.rs"));
750 assert!(back.turns.is_empty());
751 }
752
753 #[test]
754 fn test_tool_category_serde() {
755 let ti = ToolInvocation {
756 id: "t1".into(),
757 name: "Bash".into(),
758 input: serde_json::json!({"command": "ls"}),
759 result: None,
760 category: Some(ToolCategory::Shell),
761 };
762 let json = serde_json::to_string(&ti).unwrap();
763 assert!(json.contains("\"shell\""));
764 let back: ToolInvocation = serde_json::from_str(&json).unwrap();
765 assert_eq!(back.category, Some(ToolCategory::Shell));
766 }
767
768 #[test]
769 fn test_tool_category_none_skipped() {
770 let ti = ToolInvocation {
771 id: "t1".into(),
772 name: "CustomTool".into(),
773 input: serde_json::json!({}),
774 result: None,
775 category: None,
776 };
777 let json = serde_json::to_string(&ti).unwrap();
778 assert!(!json.contains("category"));
779 }
780
781 #[test]
782 fn test_tool_category_missing_defaults_none() {
783 let json = r#"{"id":"t1","name":"Read","input":{},"result":null}"#;
785 let ti: ToolInvocation = serde_json::from_str(json).unwrap();
786 assert!(ti.category.is_none());
787 }
788
789 #[test]
790 fn test_tool_category_all_variants_roundtrip() {
791 let variants = vec![
792 ToolCategory::FileRead,
793 ToolCategory::FileWrite,
794 ToolCategory::FileSearch,
795 ToolCategory::Shell,
796 ToolCategory::Network,
797 ToolCategory::Delegation,
798 ];
799 for cat in variants {
800 let json = serde_json::to_value(cat).unwrap();
801 let back: ToolCategory = serde_json::from_value(json).unwrap();
802 assert_eq!(back, cat);
803 }
804 }
805
806 #[test]
807 fn test_turn_with_environment_and_delegations() {
808 let turn = Turn {
809 id: "t1".into(),
810 parent_id: None,
811 role: Role::Assistant,
812 timestamp: "2026-01-01T00:00:00Z".into(),
813 text: "Delegating...".into(),
814 thinking: None,
815 tool_uses: vec![],
816 model: None,
817 stop_reason: None,
818 token_usage: None,
819 environment: Some(EnvironmentSnapshot {
820 working_dir: Some("/project".into()),
821 vcs_branch: Some("feat/auth".into()),
822 vcs_revision: None,
823 }),
824 delegations: vec![DelegatedWork {
825 agent_id: "sub-1".into(),
826 prompt: "Find the bug".into(),
827 turns: vec![],
828 result: None,
829 }],
830 extra: HashMap::new(),
831 };
832 let json = serde_json::to_string(&turn).unwrap();
833 let back: Turn = serde_json::from_str(&json).unwrap();
834 assert_eq!(
835 back.environment.as_ref().unwrap().vcs_branch.as_deref(),
836 Some("feat/auth")
837 );
838 assert_eq!(back.delegations.len(), 1);
839 assert_eq!(back.delegations[0].agent_id, "sub-1");
840 }
841
842 #[test]
843 fn test_turn_without_new_fields_deserializes() {
844 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}"#;
846 let turn: Turn = serde_json::from_str(json).unwrap();
847 assert!(turn.environment.is_none());
848 assert!(turn.delegations.is_empty());
849 }
850
851 #[test]
852 fn test_conversation_view_new_fields_serde() {
853 let view = ConversationView {
854 id: "s1".into(),
855 started_at: None,
856 last_activity: None,
857 turns: vec![],
858 total_usage: Some(TokenUsage {
859 input_tokens: Some(1000),
860 output_tokens: Some(500),
861 cache_read_tokens: Some(800),
862 cache_write_tokens: None,
863 }),
864 provider_id: Some("claude-code".into()),
865 files_changed: vec!["src/main.rs".into(), "src/lib.rs".into()],
866 session_ids: vec![],
867 };
868 let json = serde_json::to_string(&view).unwrap();
869 let back: ConversationView = serde_json::from_str(&json).unwrap();
870 assert_eq!(back.provider_id.as_deref(), Some("claude-code"));
871 assert_eq!(back.files_changed, vec!["src/main.rs", "src/lib.rs"]);
872 assert_eq!(back.total_usage.as_ref().unwrap().input_tokens, Some(1000));
873 assert_eq!(
874 back.total_usage.as_ref().unwrap().cache_read_tokens,
875 Some(800)
876 );
877 }
878
879 #[test]
880 fn test_conversation_view_old_format_deserializes() {
881 let json = r#"{"id":"s1","started_at":null,"last_activity":null,"turns":[]}"#;
883 let view: ConversationView = serde_json::from_str(json).unwrap();
884 assert!(view.total_usage.is_none());
885 assert!(view.provider_id.is_none());
886 assert!(view.files_changed.is_empty());
887 }
888
889 #[test]
890 fn test_conversation_meta() {
891 let meta = ConversationMeta {
892 id: "sess-1".into(),
893 started_at: None,
894 last_activity: None,
895 message_count: 5,
896 file_path: Some("/tmp/test.jsonl".into()),
897 predecessor: None,
898 successor: None,
899 };
900 let json = serde_json::to_string(&meta).unwrap();
901 let back: ConversationMeta = serde_json::from_str(&json).unwrap();
902 assert_eq!(back.message_count, 5);
903 }
904}