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>,
55 pub output_tokens: Option<u32>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub cache_read_tokens: Option<u32>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub cache_write_tokens: Option<u32>,
62}
63
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
68pub struct EnvironmentSnapshot {
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub working_dir: Option<String>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub vcs_branch: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub vcs_revision: Option<String>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct DelegatedWork {
83 pub agent_id: String,
85 pub prompt: String,
87 #[serde(default, skip_serializing_if = "Vec::is_empty")]
90 pub turns: Vec<Turn>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub result: Option<String>,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub enum ToolCategory {
105 FileRead,
107 FileWrite,
109 FileSearch,
111 Shell,
113 Network,
115 Delegation,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct ToolInvocation {
122 pub id: String,
123 pub name: String,
124 pub input: serde_json::Value,
125 pub result: Option<ToolResult>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub category: Option<ToolCategory>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct ToolResult {
136 pub content: String,
137 pub is_error: bool,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct Turn {
143 pub id: String,
145
146 pub parent_id: Option<String>,
148
149 pub role: Role,
151
152 pub timestamp: String,
154
155 pub text: String,
157
158 pub thinking: Option<String>,
160
161 pub tool_uses: Vec<ToolInvocation>,
163
164 pub model: Option<String>,
166
167 pub stop_reason: Option<String>,
169
170 pub token_usage: Option<TokenUsage>,
172
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub environment: Option<EnvironmentSnapshot>,
176
177 #[serde(default, skip_serializing_if = "Vec::is_empty")]
179 pub delegations: Vec<DelegatedWork>,
180
181 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
183 pub extra: HashMap<String, serde_json::Value>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct ConversationView {
189 pub id: String,
191
192 pub started_at: Option<DateTime<Utc>>,
194
195 pub last_activity: Option<DateTime<Utc>>,
197
198 pub turns: Vec<Turn>,
200
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub total_usage: Option<TokenUsage>,
204
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub provider_id: Option<String>,
208
209 #[serde(default, skip_serializing_if = "Vec::is_empty")]
212 pub files_changed: Vec<String>,
213}
214
215impl ConversationView {
216 pub fn title(&self, max_len: usize) -> Option<String> {
218 let text = self
219 .turns
220 .iter()
221 .find(|t| t.role == Role::User && !t.text.is_empty())
222 .map(|t| &t.text)?;
223
224 if text.chars().count() > max_len {
225 let truncated: String = text.chars().take(max_len).collect();
226 Some(format!("{}...", truncated))
227 } else {
228 Some(text.clone())
229 }
230 }
231
232 pub fn turns_by_role(&self, role: &Role) -> Vec<&Turn> {
234 self.turns.iter().filter(|t| &t.role == role).collect()
235 }
236
237 pub fn turns_since(&self, turn_id: &str) -> &[Turn] {
242 match self.turns.iter().position(|t| t.id == turn_id) {
243 Some(idx) if idx + 1 < self.turns.len() => &self.turns[idx + 1..],
244 Some(_) => &[],
245 None => &self.turns,
246 }
247 }
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct ConversationMeta {
253 pub id: String,
254 pub started_at: Option<DateTime<Utc>>,
255 pub last_activity: Option<DateTime<Utc>>,
256 pub message_count: usize,
257 pub file_path: Option<PathBuf>,
258}
259
260#[derive(Debug, Clone)]
264pub enum WatcherEvent {
265 Turn(Box<Turn>),
267
268 TurnUpdated(Box<Turn>),
274
275 Progress {
277 kind: String,
278 data: serde_json::Value,
279 },
280}
281
282pub trait ConversationProvider {
289 fn list_conversations(&self, project: &str) -> Result<Vec<String>>;
291
292 fn load_conversation(&self, project: &str, conversation_id: &str) -> Result<ConversationView>;
294
295 fn load_metadata(&self, project: &str, conversation_id: &str) -> Result<ConversationMeta>;
297
298 fn list_metadata(&self, project: &str) -> Result<Vec<ConversationMeta>>;
300}
301
302pub trait ConversationWatcher {
304 fn poll(&mut self) -> Result<Vec<WatcherEvent>>;
306
307 fn seen_count(&self) -> usize;
309}
310
311#[cfg(test)]
314mod tests {
315 use super::*;
316
317 fn sample_view() -> ConversationView {
318 ConversationView {
319 id: "sess-1".into(),
320 started_at: None,
321 last_activity: None,
322 turns: vec![
323 Turn {
324 id: "t1".into(),
325 parent_id: None,
326 role: Role::User,
327 timestamp: "2026-01-01T00:00:00Z".into(),
328 text: "Fix the authentication bug in login.rs".into(),
329 thinking: None,
330 tool_uses: vec![],
331 model: None,
332 stop_reason: None,
333 token_usage: None,
334 environment: None,
335 delegations: vec![],
336 extra: HashMap::new(),
337 },
338 Turn {
339 id: "t2".into(),
340 parent_id: Some("t1".into()),
341 role: Role::Assistant,
342 timestamp: "2026-01-01T00:00:01Z".into(),
343 text: "I'll fix that for you.".into(),
344 thinking: Some("The bug is in the token validation".into()),
345 tool_uses: vec![ToolInvocation {
346 id: "tool-1".into(),
347 name: "Read".into(),
348 input: serde_json::json!({"file": "src/login.rs"}),
349 result: Some(ToolResult {
350 content: "fn login() { ... }".into(),
351 is_error: false,
352 }),
353 category: Some(ToolCategory::FileRead),
354 }],
355 model: Some("claude-opus-4-6".into()),
356 stop_reason: Some("end_turn".into()),
357 token_usage: Some(TokenUsage {
358 input_tokens: Some(100),
359 output_tokens: Some(50),
360 cache_read_tokens: None,
361 cache_write_tokens: None,
362 }),
363 environment: None,
364 delegations: vec![],
365 extra: HashMap::new(),
366 },
367 Turn {
368 id: "t3".into(),
369 parent_id: Some("t2".into()),
370 role: Role::User,
371 timestamp: "2026-01-01T00:00:02Z".into(),
372 text: "Thanks!".into(),
373 thinking: None,
374 tool_uses: vec![],
375 model: None,
376 stop_reason: None,
377 token_usage: None,
378 environment: None,
379 delegations: vec![],
380 extra: HashMap::new(),
381 },
382 ],
383 total_usage: None,
384 provider_id: None,
385 files_changed: vec![],
386 }
387 }
388
389 #[test]
390 fn test_title_short() {
391 let view = sample_view();
392 let title = view.title(100).unwrap();
393 assert_eq!(title, "Fix the authentication bug in login.rs");
394 }
395
396 #[test]
397 fn test_title_truncated() {
398 let view = sample_view();
399 let title = view.title(10).unwrap();
400 assert_eq!(title, "Fix the au...");
401 }
402
403 #[test]
404 fn test_title_empty() {
405 let view = ConversationView {
406 id: "empty".into(),
407 started_at: None,
408 last_activity: None,
409 turns: vec![],
410 total_usage: None,
411 provider_id: None,
412 files_changed: vec![],
413 };
414 assert!(view.title(50).is_none());
415 }
416
417 #[test]
418 fn test_turns_by_role() {
419 let view = sample_view();
420 let users = view.turns_by_role(&Role::User);
421 assert_eq!(users.len(), 2);
422 let assistants = view.turns_by_role(&Role::Assistant);
423 assert_eq!(assistants.len(), 1);
424 }
425
426 #[test]
427 fn test_turns_since_middle() {
428 let view = sample_view();
429 let since = view.turns_since("t1");
430 assert_eq!(since.len(), 2);
431 assert_eq!(since[0].id, "t2");
432 }
433
434 #[test]
435 fn test_turns_since_last() {
436 let view = sample_view();
437 let since = view.turns_since("t3");
438 assert!(since.is_empty());
439 }
440
441 #[test]
442 fn test_turns_since_unknown() {
443 let view = sample_view();
444 let since = view.turns_since("nonexistent");
445 assert_eq!(since.len(), 3);
446 }
447
448 #[test]
449 fn test_role_display() {
450 assert_eq!(Role::User.to_string(), "user");
451 assert_eq!(Role::Assistant.to_string(), "assistant");
452 assert_eq!(Role::System.to_string(), "system");
453 assert_eq!(Role::Other("tool".into()).to_string(), "tool");
454 }
455
456 #[test]
457 fn test_role_equality() {
458 assert_eq!(Role::User, Role::User);
459 assert_ne!(Role::User, Role::Assistant);
460 assert_eq!(Role::Other("x".into()), Role::Other("x".into()));
461 assert_ne!(Role::Other("x".into()), Role::Other("y".into()));
462 }
463
464 #[test]
465 fn test_turn_serde_roundtrip() {
466 let turn = &sample_view().turns[1];
467 let json = serde_json::to_string(turn).unwrap();
468 let back: Turn = serde_json::from_str(&json).unwrap();
469 assert_eq!(back.id, "t2");
470 assert_eq!(back.model, Some("claude-opus-4-6".into()));
471 assert_eq!(back.tool_uses.len(), 1);
472 assert_eq!(back.tool_uses[0].name, "Read");
473 assert!(back.tool_uses[0].result.is_some());
474 }
475
476 #[test]
477 fn test_conversation_view_serde_roundtrip() {
478 let view = sample_view();
479 let json = serde_json::to_string(&view).unwrap();
480 let back: ConversationView = serde_json::from_str(&json).unwrap();
481 assert_eq!(back.id, "sess-1");
482 assert_eq!(back.turns.len(), 3);
483 }
484
485 #[test]
486 fn test_watcher_event_variants() {
487 let turn_event = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
488 assert!(matches!(turn_event, WatcherEvent::Turn(_)));
489
490 let updated_event = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[1].clone()));
491 assert!(matches!(updated_event, WatcherEvent::TurnUpdated(_)));
492
493 let progress_event = WatcherEvent::Progress {
494 kind: "agent_progress".into(),
495 data: serde_json::json!({"status": "running"}),
496 };
497 assert!(matches!(progress_event, WatcherEvent::Progress { .. }));
498 }
499
500 #[test]
501 fn test_token_usage_default() {
502 let usage = TokenUsage::default();
503 assert!(usage.input_tokens.is_none());
504 assert!(usage.output_tokens.is_none());
505 assert!(usage.cache_read_tokens.is_none());
506 assert!(usage.cache_write_tokens.is_none());
507 }
508
509 #[test]
510 fn test_token_usage_cache_fields_serde() {
511 let usage = TokenUsage {
512 input_tokens: Some(100),
513 output_tokens: Some(50),
514 cache_read_tokens: Some(500),
515 cache_write_tokens: Some(200),
516 };
517 let json = serde_json::to_string(&usage).unwrap();
518 let back: TokenUsage = serde_json::from_str(&json).unwrap();
519 assert_eq!(back.cache_read_tokens, Some(500));
520 assert_eq!(back.cache_write_tokens, Some(200));
521 }
522
523 #[test]
524 fn test_token_usage_cache_fields_omitted() {
525 let json = r#"{"input_tokens":100,"output_tokens":50}"#;
527 let usage: TokenUsage = serde_json::from_str(json).unwrap();
528 assert_eq!(usage.input_tokens, Some(100));
529 assert!(usage.cache_read_tokens.is_none());
530 assert!(usage.cache_write_tokens.is_none());
531 }
532
533 #[test]
534 fn test_environment_snapshot_serde() {
535 let env = EnvironmentSnapshot {
536 working_dir: Some("/home/user/project".into()),
537 vcs_branch: Some("main".into()),
538 vcs_revision: Some("abc123".into()),
539 };
540 let json = serde_json::to_string(&env).unwrap();
541 let back: EnvironmentSnapshot = serde_json::from_str(&json).unwrap();
542 assert_eq!(back.working_dir.as_deref(), Some("/home/user/project"));
543 assert_eq!(back.vcs_branch.as_deref(), Some("main"));
544 assert_eq!(back.vcs_revision.as_deref(), Some("abc123"));
545 }
546
547 #[test]
548 fn test_environment_snapshot_default() {
549 let env = EnvironmentSnapshot::default();
550 assert!(env.working_dir.is_none());
551 assert!(env.vcs_branch.is_none());
552 assert!(env.vcs_revision.is_none());
553 }
554
555 #[test]
556 fn test_environment_snapshot_skip_none_fields() {
557 let env = EnvironmentSnapshot {
558 working_dir: Some("/tmp".into()),
559 vcs_branch: None,
560 vcs_revision: None,
561 };
562 let json = serde_json::to_string(&env).unwrap();
563 assert!(!json.contains("vcs_branch"));
564 assert!(!json.contains("vcs_revision"));
565 }
566
567 #[test]
568 fn test_delegated_work_serde() {
569 let dw = DelegatedWork {
570 agent_id: "agent-123".into(),
571 prompt: "Search for the bug".into(),
572 turns: vec![],
573 result: Some("Found the bug in auth.rs".into()),
574 };
575 let json = serde_json::to_string(&dw).unwrap();
576 assert!(!json.contains("turns")); let back: DelegatedWork = serde_json::from_str(&json).unwrap();
578 assert_eq!(back.agent_id, "agent-123");
579 assert_eq!(back.result.as_deref(), Some("Found the bug in auth.rs"));
580 assert!(back.turns.is_empty());
581 }
582
583 #[test]
584 fn test_tool_category_serde() {
585 let ti = ToolInvocation {
586 id: "t1".into(),
587 name: "Bash".into(),
588 input: serde_json::json!({"command": "ls"}),
589 result: None,
590 category: Some(ToolCategory::Shell),
591 };
592 let json = serde_json::to_string(&ti).unwrap();
593 assert!(json.contains("\"shell\""));
594 let back: ToolInvocation = serde_json::from_str(&json).unwrap();
595 assert_eq!(back.category, Some(ToolCategory::Shell));
596 }
597
598 #[test]
599 fn test_tool_category_none_skipped() {
600 let ti = ToolInvocation {
601 id: "t1".into(),
602 name: "CustomTool".into(),
603 input: serde_json::json!({}),
604 result: None,
605 category: None,
606 };
607 let json = serde_json::to_string(&ti).unwrap();
608 assert!(!json.contains("category"));
609 }
610
611 #[test]
612 fn test_tool_category_missing_defaults_none() {
613 let json = r#"{"id":"t1","name":"Read","input":{},"result":null}"#;
615 let ti: ToolInvocation = serde_json::from_str(json).unwrap();
616 assert!(ti.category.is_none());
617 }
618
619 #[test]
620 fn test_tool_category_all_variants_roundtrip() {
621 let variants = vec![
622 ToolCategory::FileRead,
623 ToolCategory::FileWrite,
624 ToolCategory::FileSearch,
625 ToolCategory::Shell,
626 ToolCategory::Network,
627 ToolCategory::Delegation,
628 ];
629 for cat in variants {
630 let json = serde_json::to_value(&cat).unwrap();
631 let back: ToolCategory = serde_json::from_value(json).unwrap();
632 assert_eq!(back, cat);
633 }
634 }
635
636 #[test]
637 fn test_turn_with_environment_and_delegations() {
638 let turn = Turn {
639 id: "t1".into(),
640 parent_id: None,
641 role: Role::Assistant,
642 timestamp: "2026-01-01T00:00:00Z".into(),
643 text: "Delegating...".into(),
644 thinking: None,
645 tool_uses: vec![],
646 model: None,
647 stop_reason: None,
648 token_usage: None,
649 environment: Some(EnvironmentSnapshot {
650 working_dir: Some("/project".into()),
651 vcs_branch: Some("feat/auth".into()),
652 vcs_revision: None,
653 }),
654 delegations: vec![DelegatedWork {
655 agent_id: "sub-1".into(),
656 prompt: "Find the bug".into(),
657 turns: vec![],
658 result: None,
659 }],
660 extra: HashMap::new(),
661 };
662 let json = serde_json::to_string(&turn).unwrap();
663 let back: Turn = serde_json::from_str(&json).unwrap();
664 assert_eq!(
665 back.environment.as_ref().unwrap().vcs_branch.as_deref(),
666 Some("feat/auth")
667 );
668 assert_eq!(back.delegations.len(), 1);
669 assert_eq!(back.delegations[0].agent_id, "sub-1");
670 }
671
672 #[test]
673 fn test_turn_without_new_fields_deserializes() {
674 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}"#;
676 let turn: Turn = serde_json::from_str(json).unwrap();
677 assert!(turn.environment.is_none());
678 assert!(turn.delegations.is_empty());
679 }
680
681 #[test]
682 fn test_conversation_view_new_fields_serde() {
683 let view = ConversationView {
684 id: "s1".into(),
685 started_at: None,
686 last_activity: None,
687 turns: vec![],
688 total_usage: Some(TokenUsage {
689 input_tokens: Some(1000),
690 output_tokens: Some(500),
691 cache_read_tokens: Some(800),
692 cache_write_tokens: None,
693 }),
694 provider_id: Some("claude-code".into()),
695 files_changed: vec!["src/main.rs".into(), "src/lib.rs".into()],
696 };
697 let json = serde_json::to_string(&view).unwrap();
698 let back: ConversationView = serde_json::from_str(&json).unwrap();
699 assert_eq!(back.provider_id.as_deref(), Some("claude-code"));
700 assert_eq!(back.files_changed, vec!["src/main.rs", "src/lib.rs"]);
701 assert_eq!(back.total_usage.as_ref().unwrap().input_tokens, Some(1000));
702 assert_eq!(
703 back.total_usage.as_ref().unwrap().cache_read_tokens,
704 Some(800)
705 );
706 }
707
708 #[test]
709 fn test_conversation_view_old_format_deserializes() {
710 let json = r#"{"id":"s1","started_at":null,"last_activity":null,"turns":[]}"#;
712 let view: ConversationView = serde_json::from_str(json).unwrap();
713 assert!(view.total_usage.is_none());
714 assert!(view.provider_id.is_none());
715 assert!(view.files_changed.is_empty());
716 }
717
718 #[test]
719 fn test_conversation_meta() {
720 let meta = ConversationMeta {
721 id: "sess-1".into(),
722 started_at: None,
723 last_activity: None,
724 message_count: 5,
725 file_path: Some("/tmp/test.jsonl".into()),
726 };
727 let json = serde_json::to_string(&meta).unwrap();
728 let back: ConversationMeta = serde_json::from_str(&json).unwrap();
729 assert_eq!(back.message_count, 5);
730 }
731}