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")]
190 pub extra: HashMap<String, serde_json::Value>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct ConversationView {
196 pub id: String,
198
199 pub started_at: Option<DateTime<Utc>>,
201
202 pub last_activity: Option<DateTime<Utc>>,
204
205 pub turns: Vec<Turn>,
207
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub total_usage: Option<TokenUsage>,
211
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub provider_id: Option<String>,
215
216 #[serde(default, skip_serializing_if = "Vec::is_empty")]
219 pub files_changed: Vec<String>,
220
221 #[serde(default, skip_serializing_if = "Vec::is_empty")]
225 pub session_ids: Vec<String>,
226}
227
228impl ConversationView {
229 pub fn title(&self, max_len: usize) -> Option<String> {
231 let text = self
232 .turns
233 .iter()
234 .find(|t| t.role == Role::User && !t.text.is_empty())
235 .map(|t| &t.text)?;
236
237 if text.chars().count() > max_len {
238 let truncated: String = text.chars().take(max_len).collect();
239 Some(format!("{}...", truncated))
240 } else {
241 Some(text.clone())
242 }
243 }
244
245 pub fn turns_by_role(&self, role: &Role) -> Vec<&Turn> {
247 self.turns.iter().filter(|t| &t.role == role).collect()
248 }
249
250 pub fn turns_since(&self, turn_id: &str) -> &[Turn] {
255 match self.turns.iter().position(|t| t.id == turn_id) {
256 Some(idx) if idx + 1 < self.turns.len() => &self.turns[idx + 1..],
257 Some(_) => &[],
258 None => &self.turns,
259 }
260 }
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct ConversationMeta {
270 pub id: String,
272 pub started_at: Option<DateTime<Utc>>,
274 pub last_activity: Option<DateTime<Utc>>,
276 pub message_count: usize,
278 pub file_path: Option<PathBuf>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub predecessor: Option<SessionLink>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub successor: Option<SessionLink>,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292pub enum SessionLinkKind {
293 Rotation,
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct SessionLink {
300 pub session_id: String,
302 pub kind: SessionLinkKind,
304}
305
306#[derive(Debug, Clone)]
310pub enum WatcherEvent {
311 Turn(Box<Turn>),
313
314 TurnUpdated(Box<Turn>),
320
321 Progress {
323 kind: String,
324 data: serde_json::Value,
325 },
326}
327
328pub trait ConversationProvider {
335 fn list_conversations(&self, project: &str) -> Result<Vec<String>>;
337
338 fn load_conversation(&self, project: &str, conversation_id: &str) -> Result<ConversationView>;
340
341 fn load_metadata(&self, project: &str, conversation_id: &str) -> Result<ConversationMeta>;
343
344 fn list_metadata(&self, project: &str) -> Result<Vec<ConversationMeta>>;
346}
347
348pub trait ConversationWatcher {
350 fn poll(&mut self) -> Result<Vec<WatcherEvent>>;
352
353 fn seen_count(&self) -> usize;
355}
356
357#[cfg(test)]
360mod tests {
361 use super::*;
362
363 fn sample_view() -> ConversationView {
364 ConversationView {
365 id: "sess-1".into(),
366 started_at: None,
367 last_activity: None,
368 turns: vec![
369 Turn {
370 id: "t1".into(),
371 parent_id: None,
372 role: Role::User,
373 timestamp: "2026-01-01T00:00:00Z".into(),
374 text: "Fix the authentication bug in login.rs".into(),
375 thinking: None,
376 tool_uses: vec![],
377 model: None,
378 stop_reason: None,
379 token_usage: None,
380 environment: None,
381 delegations: vec![],
382 extra: HashMap::new(),
383 },
384 Turn {
385 id: "t2".into(),
386 parent_id: Some("t1".into()),
387 role: Role::Assistant,
388 timestamp: "2026-01-01T00:00:01Z".into(),
389 text: "I'll fix that for you.".into(),
390 thinking: Some("The bug is in the token validation".into()),
391 tool_uses: vec![ToolInvocation {
392 id: "tool-1".into(),
393 name: "Read".into(),
394 input: serde_json::json!({"file": "src/login.rs"}),
395 result: Some(ToolResult {
396 content: "fn login() { ... }".into(),
397 is_error: false,
398 }),
399 category: Some(ToolCategory::FileRead),
400 }],
401 model: Some("claude-opus-4-6".into()),
402 stop_reason: Some("end_turn".into()),
403 token_usage: Some(TokenUsage {
404 input_tokens: Some(100),
405 output_tokens: Some(50),
406 cache_read_tokens: None,
407 cache_write_tokens: None,
408 }),
409 environment: None,
410 delegations: vec![],
411 extra: HashMap::new(),
412 },
413 Turn {
414 id: "t3".into(),
415 parent_id: Some("t2".into()),
416 role: Role::User,
417 timestamp: "2026-01-01T00:00:02Z".into(),
418 text: "Thanks!".into(),
419 thinking: None,
420 tool_uses: vec![],
421 model: None,
422 stop_reason: None,
423 token_usage: None,
424 environment: None,
425 delegations: vec![],
426 extra: HashMap::new(),
427 },
428 ],
429 total_usage: None,
430 provider_id: None,
431 files_changed: vec![],
432 session_ids: vec![],
433 }
434 }
435
436 #[test]
437 fn test_title_short() {
438 let view = sample_view();
439 let title = view.title(100).unwrap();
440 assert_eq!(title, "Fix the authentication bug in login.rs");
441 }
442
443 #[test]
444 fn test_title_truncated() {
445 let view = sample_view();
446 let title = view.title(10).unwrap();
447 assert_eq!(title, "Fix the au...");
448 }
449
450 #[test]
451 fn test_title_empty() {
452 let view = ConversationView {
453 id: "empty".into(),
454 started_at: None,
455 last_activity: None,
456 turns: vec![],
457 total_usage: None,
458 provider_id: None,
459 files_changed: vec![],
460 session_ids: vec![],
461 };
462 assert!(view.title(50).is_none());
463 }
464
465 #[test]
466 fn test_turns_by_role() {
467 let view = sample_view();
468 let users = view.turns_by_role(&Role::User);
469 assert_eq!(users.len(), 2);
470 let assistants = view.turns_by_role(&Role::Assistant);
471 assert_eq!(assistants.len(), 1);
472 }
473
474 #[test]
475 fn test_turns_since_middle() {
476 let view = sample_view();
477 let since = view.turns_since("t1");
478 assert_eq!(since.len(), 2);
479 assert_eq!(since[0].id, "t2");
480 }
481
482 #[test]
483 fn test_turns_since_last() {
484 let view = sample_view();
485 let since = view.turns_since("t3");
486 assert!(since.is_empty());
487 }
488
489 #[test]
490 fn test_turns_since_unknown() {
491 let view = sample_view();
492 let since = view.turns_since("nonexistent");
493 assert_eq!(since.len(), 3);
494 }
495
496 #[test]
497 fn test_role_display() {
498 assert_eq!(Role::User.to_string(), "user");
499 assert_eq!(Role::Assistant.to_string(), "assistant");
500 assert_eq!(Role::System.to_string(), "system");
501 assert_eq!(Role::Other("tool".into()).to_string(), "tool");
502 }
503
504 #[test]
505 fn test_role_equality() {
506 assert_eq!(Role::User, Role::User);
507 assert_ne!(Role::User, Role::Assistant);
508 assert_eq!(Role::Other("x".into()), Role::Other("x".into()));
509 assert_ne!(Role::Other("x".into()), Role::Other("y".into()));
510 }
511
512 #[test]
513 fn test_turn_serde_roundtrip() {
514 let turn = &sample_view().turns[1];
515 let json = serde_json::to_string(turn).unwrap();
516 let back: Turn = serde_json::from_str(&json).unwrap();
517 assert_eq!(back.id, "t2");
518 assert_eq!(back.model, Some("claude-opus-4-6".into()));
519 assert_eq!(back.tool_uses.len(), 1);
520 assert_eq!(back.tool_uses[0].name, "Read");
521 assert!(back.tool_uses[0].result.is_some());
522 }
523
524 #[test]
525 fn test_conversation_view_serde_roundtrip() {
526 let view = sample_view();
527 let json = serde_json::to_string(&view).unwrap();
528 let back: ConversationView = serde_json::from_str(&json).unwrap();
529 assert_eq!(back.id, "sess-1");
530 assert_eq!(back.turns.len(), 3);
531 }
532
533 #[test]
534 fn test_watcher_event_variants() {
535 let turn_event = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
536 assert!(matches!(turn_event, WatcherEvent::Turn(_)));
537
538 let updated_event = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[1].clone()));
539 assert!(matches!(updated_event, WatcherEvent::TurnUpdated(_)));
540
541 let progress_event = WatcherEvent::Progress {
542 kind: "agent_progress".into(),
543 data: serde_json::json!({"status": "running"}),
544 };
545 assert!(matches!(progress_event, WatcherEvent::Progress { .. }));
546 }
547
548 #[test]
549 fn test_token_usage_default() {
550 let usage = TokenUsage::default();
551 assert!(usage.input_tokens.is_none());
552 assert!(usage.output_tokens.is_none());
553 assert!(usage.cache_read_tokens.is_none());
554 assert!(usage.cache_write_tokens.is_none());
555 }
556
557 #[test]
558 fn test_token_usage_cache_fields_serde() {
559 let usage = TokenUsage {
560 input_tokens: Some(100),
561 output_tokens: Some(50),
562 cache_read_tokens: Some(500),
563 cache_write_tokens: Some(200),
564 };
565 let json = serde_json::to_string(&usage).unwrap();
566 let back: TokenUsage = serde_json::from_str(&json).unwrap();
567 assert_eq!(back.cache_read_tokens, Some(500));
568 assert_eq!(back.cache_write_tokens, Some(200));
569 }
570
571 #[test]
572 fn test_token_usage_cache_fields_omitted() {
573 let json = r#"{"input_tokens":100,"output_tokens":50}"#;
575 let usage: TokenUsage = serde_json::from_str(json).unwrap();
576 assert_eq!(usage.input_tokens, Some(100));
577 assert!(usage.cache_read_tokens.is_none());
578 assert!(usage.cache_write_tokens.is_none());
579 }
580
581 #[test]
582 fn test_environment_snapshot_serde() {
583 let env = EnvironmentSnapshot {
584 working_dir: Some("/home/user/project".into()),
585 vcs_branch: Some("main".into()),
586 vcs_revision: Some("abc123".into()),
587 };
588 let json = serde_json::to_string(&env).unwrap();
589 let back: EnvironmentSnapshot = serde_json::from_str(&json).unwrap();
590 assert_eq!(back.working_dir.as_deref(), Some("/home/user/project"));
591 assert_eq!(back.vcs_branch.as_deref(), Some("main"));
592 assert_eq!(back.vcs_revision.as_deref(), Some("abc123"));
593 }
594
595 #[test]
596 fn test_environment_snapshot_default() {
597 let env = EnvironmentSnapshot::default();
598 assert!(env.working_dir.is_none());
599 assert!(env.vcs_branch.is_none());
600 assert!(env.vcs_revision.is_none());
601 }
602
603 #[test]
604 fn test_environment_snapshot_skip_none_fields() {
605 let env = EnvironmentSnapshot {
606 working_dir: Some("/tmp".into()),
607 vcs_branch: None,
608 vcs_revision: None,
609 };
610 let json = serde_json::to_string(&env).unwrap();
611 assert!(!json.contains("vcs_branch"));
612 assert!(!json.contains("vcs_revision"));
613 }
614
615 #[test]
616 fn test_delegated_work_serde() {
617 let dw = DelegatedWork {
618 agent_id: "agent-123".into(),
619 prompt: "Search for the bug".into(),
620 turns: vec![],
621 result: Some("Found the bug in auth.rs".into()),
622 };
623 let json = serde_json::to_string(&dw).unwrap();
624 assert!(!json.contains("turns")); let back: DelegatedWork = serde_json::from_str(&json).unwrap();
626 assert_eq!(back.agent_id, "agent-123");
627 assert_eq!(back.result.as_deref(), Some("Found the bug in auth.rs"));
628 assert!(back.turns.is_empty());
629 }
630
631 #[test]
632 fn test_tool_category_serde() {
633 let ti = ToolInvocation {
634 id: "t1".into(),
635 name: "Bash".into(),
636 input: serde_json::json!({"command": "ls"}),
637 result: None,
638 category: Some(ToolCategory::Shell),
639 };
640 let json = serde_json::to_string(&ti).unwrap();
641 assert!(json.contains("\"shell\""));
642 let back: ToolInvocation = serde_json::from_str(&json).unwrap();
643 assert_eq!(back.category, Some(ToolCategory::Shell));
644 }
645
646 #[test]
647 fn test_tool_category_none_skipped() {
648 let ti = ToolInvocation {
649 id: "t1".into(),
650 name: "CustomTool".into(),
651 input: serde_json::json!({}),
652 result: None,
653 category: None,
654 };
655 let json = serde_json::to_string(&ti).unwrap();
656 assert!(!json.contains("category"));
657 }
658
659 #[test]
660 fn test_tool_category_missing_defaults_none() {
661 let json = r#"{"id":"t1","name":"Read","input":{},"result":null}"#;
663 let ti: ToolInvocation = serde_json::from_str(json).unwrap();
664 assert!(ti.category.is_none());
665 }
666
667 #[test]
668 fn test_tool_category_all_variants_roundtrip() {
669 let variants = vec![
670 ToolCategory::FileRead,
671 ToolCategory::FileWrite,
672 ToolCategory::FileSearch,
673 ToolCategory::Shell,
674 ToolCategory::Network,
675 ToolCategory::Delegation,
676 ];
677 for cat in variants {
678 let json = serde_json::to_value(&cat).unwrap();
679 let back: ToolCategory = serde_json::from_value(json).unwrap();
680 assert_eq!(back, cat);
681 }
682 }
683
684 #[test]
685 fn test_turn_with_environment_and_delegations() {
686 let turn = Turn {
687 id: "t1".into(),
688 parent_id: None,
689 role: Role::Assistant,
690 timestamp: "2026-01-01T00:00:00Z".into(),
691 text: "Delegating...".into(),
692 thinking: None,
693 tool_uses: vec![],
694 model: None,
695 stop_reason: None,
696 token_usage: None,
697 environment: Some(EnvironmentSnapshot {
698 working_dir: Some("/project".into()),
699 vcs_branch: Some("feat/auth".into()),
700 vcs_revision: None,
701 }),
702 delegations: vec![DelegatedWork {
703 agent_id: "sub-1".into(),
704 prompt: "Find the bug".into(),
705 turns: vec![],
706 result: None,
707 }],
708 extra: HashMap::new(),
709 };
710 let json = serde_json::to_string(&turn).unwrap();
711 let back: Turn = serde_json::from_str(&json).unwrap();
712 assert_eq!(
713 back.environment.as_ref().unwrap().vcs_branch.as_deref(),
714 Some("feat/auth")
715 );
716 assert_eq!(back.delegations.len(), 1);
717 assert_eq!(back.delegations[0].agent_id, "sub-1");
718 }
719
720 #[test]
721 fn test_turn_without_new_fields_deserializes() {
722 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}"#;
724 let turn: Turn = serde_json::from_str(json).unwrap();
725 assert!(turn.environment.is_none());
726 assert!(turn.delegations.is_empty());
727 }
728
729 #[test]
730 fn test_conversation_view_new_fields_serde() {
731 let view = ConversationView {
732 id: "s1".into(),
733 started_at: None,
734 last_activity: None,
735 turns: vec![],
736 total_usage: Some(TokenUsage {
737 input_tokens: Some(1000),
738 output_tokens: Some(500),
739 cache_read_tokens: Some(800),
740 cache_write_tokens: None,
741 }),
742 provider_id: Some("claude-code".into()),
743 files_changed: vec!["src/main.rs".into(), "src/lib.rs".into()],
744 session_ids: vec![],
745 };
746 let json = serde_json::to_string(&view).unwrap();
747 let back: ConversationView = serde_json::from_str(&json).unwrap();
748 assert_eq!(back.provider_id.as_deref(), Some("claude-code"));
749 assert_eq!(back.files_changed, vec!["src/main.rs", "src/lib.rs"]);
750 assert_eq!(back.total_usage.as_ref().unwrap().input_tokens, Some(1000));
751 assert_eq!(
752 back.total_usage.as_ref().unwrap().cache_read_tokens,
753 Some(800)
754 );
755 }
756
757 #[test]
758 fn test_conversation_view_old_format_deserializes() {
759 let json = r#"{"id":"s1","started_at":null,"last_activity":null,"turns":[]}"#;
761 let view: ConversationView = serde_json::from_str(json).unwrap();
762 assert!(view.total_usage.is_none());
763 assert!(view.provider_id.is_none());
764 assert!(view.files_changed.is_empty());
765 }
766
767 #[test]
768 fn test_conversation_meta() {
769 let meta = ConversationMeta {
770 id: "sess-1".into(),
771 started_at: None,
772 last_activity: None,
773 message_count: 5,
774 file_path: Some("/tmp/test.jsonl".into()),
775 predecessor: None,
776 successor: None,
777 };
778 let json = serde_json::to_string(&meta).unwrap();
779 let back: ConversationMeta = serde_json::from_str(&json).unwrap();
780 assert_eq!(back.message_count, 5);
781 }
782}