Skip to main content

toolpath_convo/
lib.rs

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// ── Error ────────────────────────────────────────────────────────────
9
10/// Errors from conversation provider operations.
11#[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// ── Core types ───────────────────────────────────────────────────────
29
30/// Who produced a turn.
31#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
32pub enum Role {
33    User,
34    Assistant,
35    System,
36    /// Provider-specific roles (e.g. "tool", "function").
37    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/// Token usage for a single turn.
52#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53pub struct TokenUsage {
54    pub input_tokens: Option<u32>,
55    pub output_tokens: Option<u32>,
56    /// Tokens read from cache (prompt caching, context caching).
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub cache_read_tokens: Option<u32>,
59    /// Tokens written to cache.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub cache_write_tokens: Option<u32>,
62}
63
64/// Snapshot of the working environment when a turn was produced.
65///
66/// All fields are optional. Providers populate what they have.
67#[derive(Debug, Clone, Default, Serialize, Deserialize)]
68pub struct EnvironmentSnapshot {
69    /// Working directory (absolute path).
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub working_dir: Option<String>,
72    /// Version control branch (git, hg, jj, etc.).
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub vcs_branch: Option<String>,
75    /// Version control revision (commit hash, changeset ID, etc.).
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub vcs_revision: Option<String>,
78}
79
80/// A sub-agent delegation: a turn that spawned child work.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct DelegatedWork {
83    /// Provider-specific agent identifier (e.g. session ID, task ID).
84    pub agent_id: String,
85    /// The prompt/instruction given to the sub-agent.
86    pub prompt: String,
87    /// Turns produced by the sub-agent (may be empty if not available
88    /// or if the sub-agent's work is stored in a separate session).
89    #[serde(default, skip_serializing_if = "Vec::is_empty")]
90    pub turns: Vec<Turn>,
91    /// Final result returned by the sub-agent.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub result: Option<String>,
94}
95
96/// Toolpath's classification of what a tool invocation does.
97///
98/// This is toolpath's ontology, not a provider-specific label. Provider
99/// crates map their tool names into these categories. `None` means the
100/// tool isn't recognized — consumers still have `name` and `input` for
101/// anything we don't classify.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub enum ToolCategory {
105    /// Read a file — no side effects on the filesystem.
106    FileRead,
107    /// Write, edit, create, or delete a file.
108    FileWrite,
109    /// Search or discover files by name or content pattern.
110    FileSearch,
111    /// Shell or terminal command execution.
112    Shell,
113    /// Network access — web fetch, search, API call.
114    Network,
115    /// Spawn a sub-agent or delegate work.
116    Delegation,
117}
118
119/// A tool invocation within a turn.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct ToolInvocation {
122    pub id: String,
123    pub name: String,
124    pub input: serde_json::Value,
125    /// Populated when the result is available in the same turn.
126    pub result: Option<ToolResult>,
127    /// Toolpath's classification of this invocation. Set by the provider
128    /// crate; `None` for unrecognized tools.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub category: Option<ToolCategory>,
131}
132
133/// The result of a tool invocation.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct ToolResult {
136    pub content: String,
137    pub is_error: bool,
138}
139
140/// A single turn in a conversation, from any provider.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct Turn {
143    /// Unique identifier within the conversation.
144    pub id: String,
145
146    /// Parent turn ID (for branching conversations).
147    pub parent_id: Option<String>,
148
149    /// Who produced this turn.
150    pub role: Role,
151
152    /// When this turn occurred (ISO 8601).
153    pub timestamp: String,
154
155    /// The visible text content (already collapsed from provider-specific formats).
156    pub text: String,
157
158    /// Internal reasoning (chain-of-thought, thinking blocks).
159    pub thinking: Option<String>,
160
161    /// Tool invocations in this turn.
162    pub tool_uses: Vec<ToolInvocation>,
163
164    /// Model identifier (e.g. "claude-opus-4-6", "gpt-4o").
165    pub model: Option<String>,
166
167    /// Why the turn ended (e.g. "end_turn", "tool_use", "max_tokens").
168    pub stop_reason: Option<String>,
169
170    /// Token usage for this turn.
171    pub token_usage: Option<TokenUsage>,
172
173    /// Environment at time of this turn.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub environment: Option<EnvironmentSnapshot>,
176
177    /// Sub-agent work delegated from this turn.
178    #[serde(default, skip_serializing_if = "Vec::is_empty")]
179    pub delegations: Vec<DelegatedWork>,
180
181    /// Provider-specific data that doesn't fit the common schema.
182    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
183    pub extra: HashMap<String, serde_json::Value>,
184}
185
186/// A complete conversation from any provider.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct ConversationView {
189    /// Unique session/conversation identifier.
190    pub id: String,
191
192    /// When the conversation started.
193    pub started_at: Option<DateTime<Utc>>,
194
195    /// When the conversation was last active.
196    pub last_activity: Option<DateTime<Utc>>,
197
198    /// Ordered turns.
199    pub turns: Vec<Turn>,
200
201    /// Aggregate token usage across all turns.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub total_usage: Option<TokenUsage>,
204
205    /// Provider identity (e.g. "claude-code", "aider", "codex-cli").
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub provider_id: Option<String>,
208
209    /// Files mutated during this conversation, deduplicated, in first-touch order.
210    /// Populated by the provider from tool invocation inputs.
211    #[serde(default, skip_serializing_if = "Vec::is_empty")]
212    pub files_changed: Vec<String>,
213}
214
215impl ConversationView {
216    /// Title derived from the first user turn, truncated to `max_len` characters.
217    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    /// All turns with the given role.
233    pub fn turns_by_role(&self, role: &Role) -> Vec<&Turn> {
234        self.turns.iter().filter(|t| &t.role == role).collect()
235    }
236
237    /// Turns added after the turn with the given ID.
238    ///
239    /// If the ID is not found, returns all turns. If the ID is the last
240    /// turn, returns an empty slice.
241    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/// Lightweight metadata for a conversation (no turns loaded).
251#[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// ── Events ───────────────────────────────────────────────────────────
261
262/// Events emitted by a [`ConversationWatcher`].
263#[derive(Debug, Clone)]
264pub enum WatcherEvent {
265    /// A turn seen for the first time.
266    Turn(Box<Turn>),
267
268    /// A previously-emitted turn with additional data filled in
269    /// (e.g. tool results that arrived in a later log entry).
270    ///
271    /// Consumers should replace their stored copy of the turn with this
272    /// updated version. The turn's `id` field identifies which turn to replace.
273    TurnUpdated(Box<Turn>),
274
275    /// A non-conversational progress/status event.
276    Progress {
277        kind: String,
278        data: serde_json::Value,
279    },
280}
281
282// ── Traits ───────────────────────────────────────────────────────────
283
284/// Trait for converting provider-specific conversation data into the
285/// generic [`ConversationView`].
286///
287/// Implement this on your provider's manager type (e.g. `ClaudeConvo`).
288pub trait ConversationProvider {
289    /// List conversation IDs for a project/workspace.
290    fn list_conversations(&self, project: &str) -> Result<Vec<String>>;
291
292    /// Load a full conversation as a [`ConversationView`].
293    fn load_conversation(&self, project: &str, conversation_id: &str) -> Result<ConversationView>;
294
295    /// Load metadata only (no turns).
296    fn load_metadata(&self, project: &str, conversation_id: &str) -> Result<ConversationMeta>;
297
298    /// List metadata for all conversations in a project.
299    fn list_metadata(&self, project: &str) -> Result<Vec<ConversationMeta>>;
300}
301
302/// Trait for polling conversation updates from any provider.
303pub trait ConversationWatcher {
304    /// Poll for new events since the last poll.
305    fn poll(&mut self) -> Result<Vec<WatcherEvent>>;
306
307    /// Number of turns seen so far.
308    fn seen_count(&self) -> usize;
309}
310
311// ── Tests ────────────────────────────────────────────────────────────
312
313#[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        // Old-format JSON without cache fields should deserialize with None
526        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")); // empty vec skipped
577        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        // Old-format JSON without category should deserialize as None
614        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        // Old-format Turn JSON without environment/delegations
675        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        // Old-format JSON without total_usage/provider_id/files_changed
711        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}