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    /// Tokens sent to the model (prompt + context).
55    pub input_tokens: Option<u32>,
56    /// Tokens generated by the model.
57    pub output_tokens: Option<u32>,
58    /// Tokens read from cache (prompt caching, context caching).
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub cache_read_tokens: Option<u32>,
61    /// Tokens written to cache.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub cache_write_tokens: Option<u32>,
64}
65
66/// Snapshot of the working environment when a turn was produced.
67///
68/// All fields are optional. Providers populate what they have.
69#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70pub struct EnvironmentSnapshot {
71    /// Working directory (absolute path).
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub working_dir: Option<String>,
74    /// Version control branch (git, hg, jj, etc.).
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub vcs_branch: Option<String>,
77    /// Version control revision (commit hash, changeset ID, etc.).
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub vcs_revision: Option<String>,
80}
81
82/// A sub-agent delegation: a turn that spawned child work.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct DelegatedWork {
85    /// Provider-specific agent identifier (e.g. session ID, task ID).
86    pub agent_id: String,
87    /// The prompt/instruction given to the sub-agent.
88    pub prompt: String,
89    /// Turns produced by the sub-agent (may be empty if not available
90    /// or if the sub-agent's work is stored in a separate session).
91    #[serde(default, skip_serializing_if = "Vec::is_empty")]
92    pub turns: Vec<Turn>,
93    /// Final result returned by the sub-agent.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub result: Option<String>,
96}
97
98/// Toolpath's classification of what a tool invocation does.
99///
100/// This is toolpath's ontology, not a provider-specific label. Provider
101/// crates map their tool names into these categories. `None` means the
102/// tool isn't recognized — consumers still have `name` and `input` for
103/// anything we don't classify.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum ToolCategory {
107    /// Read a file — no side effects on the filesystem.
108    FileRead,
109    /// Write, edit, create, or delete a file.
110    FileWrite,
111    /// Search or discover files by name or content pattern.
112    FileSearch,
113    /// Shell or terminal command execution.
114    Shell,
115    /// Network access — web fetch, search, API call.
116    Network,
117    /// Spawn a sub-agent or delegate work.
118    Delegation,
119}
120
121/// A tool invocation within a turn.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ToolInvocation {
124    /// Provider-assigned identifier for this invocation.
125    pub id: String,
126    /// Provider-specific tool name (e.g. `"Read"`, `"Bash"`, `"editor"`).
127    pub name: String,
128    /// Tool input parameters as provider-specific JSON.
129    pub input: serde_json::Value,
130    /// Populated when the result is available in the same turn.
131    pub result: Option<ToolResult>,
132    /// Toolpath's classification of this invocation. Set by the provider
133    /// crate; `None` for unrecognized tools.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub category: Option<ToolCategory>,
136}
137
138/// The result of a tool invocation.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct ToolResult {
141    /// The text content returned by the tool.
142    pub content: String,
143    /// Whether the tool reported an error.
144    pub is_error: bool,
145}
146
147/// A single turn in a conversation, from any provider.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct Turn {
150    /// Unique identifier within the conversation.
151    pub id: String,
152
153    /// Parent turn ID (for branching conversations).
154    pub parent_id: Option<String>,
155
156    /// Who produced this turn.
157    pub role: Role,
158
159    /// When this turn occurred (ISO 8601).
160    pub timestamp: String,
161
162    /// The visible text content (already collapsed from provider-specific formats).
163    pub text: String,
164
165    /// Internal reasoning (chain-of-thought, thinking blocks).
166    pub thinking: Option<String>,
167
168    /// Tool invocations in this turn.
169    pub tool_uses: Vec<ToolInvocation>,
170
171    /// Model identifier (e.g. "claude-opus-4-6", "gpt-4o").
172    pub model: Option<String>,
173
174    /// Why the turn ended (e.g. "end_turn", "tool_use", "max_tokens").
175    pub stop_reason: Option<String>,
176
177    /// Token usage for this turn.
178    pub token_usage: Option<TokenUsage>,
179
180    /// Environment at time of this turn.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub environment: Option<EnvironmentSnapshot>,
183
184    /// Sub-agent work delegated from this turn.
185    #[serde(default, skip_serializing_if = "Vec::is_empty")]
186    pub delegations: Vec<DelegatedWork>,
187
188    /// Provider-specific data that doesn't fit the common schema.
189    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
190    pub extra: HashMap<String, serde_json::Value>,
191}
192
193/// A complete conversation from any provider.
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct ConversationView {
196    /// Unique session/conversation identifier.
197    pub id: String,
198
199    /// When the conversation started.
200    pub started_at: Option<DateTime<Utc>>,
201
202    /// When the conversation was last active.
203    pub last_activity: Option<DateTime<Utc>>,
204
205    /// Ordered turns.
206    pub turns: Vec<Turn>,
207
208    /// Aggregate token usage across all turns.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub total_usage: Option<TokenUsage>,
211
212    /// Provider identity (e.g. "claude-code", "aider", "codex-cli").
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub provider_id: Option<String>,
215
216    /// Files mutated during this conversation, deduplicated, in first-touch order.
217    /// Populated by the provider from tool invocation inputs.
218    #[serde(default, skip_serializing_if = "Vec::is_empty")]
219    pub files_changed: Vec<String>,
220
221    /// All session IDs that were merged to produce this view, in
222    /// chronological order (oldest segment first). Empty or single-element
223    /// for non-chained conversations.
224    #[serde(default, skip_serializing_if = "Vec::is_empty")]
225    pub session_ids: Vec<String>,
226}
227
228impl ConversationView {
229    /// Title derived from the first user turn, truncated to `max_len` characters.
230    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    /// All turns with the given role.
246    pub fn turns_by_role(&self, role: &Role) -> Vec<&Turn> {
247        self.turns.iter().filter(|t| &t.role == role).collect()
248    }
249
250    /// Turns added after the turn with the given ID.
251    ///
252    /// If the ID is not found, returns all turns. If the ID is the last
253    /// turn, returns an empty slice.
254    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/// Lightweight metadata for a conversation (no turns loaded).
264///
265/// Returned by [`ConversationProvider::load_metadata`] and
266/// [`ConversationProvider::list_metadata`] for listing conversations
267/// without the cost of loading all turns.
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct ConversationMeta {
270    /// Unique session/conversation identifier.
271    pub id: String,
272    /// When the conversation started.
273    pub started_at: Option<DateTime<Utc>>,
274    /// When the conversation was last active.
275    pub last_activity: Option<DateTime<Utc>>,
276    /// Total number of messages (entries) in the conversation.
277    pub message_count: usize,
278    /// Path to the backing file, if file-based.
279    pub file_path: Option<PathBuf>,
280    /// Link to the preceding session segment (if this is a continuation).
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub predecessor: Option<SessionLink>,
283    /// Link to the following session segment (if this was continued).
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub successor: Option<SessionLink>,
286}
287
288// ── Session chaining ─────────────────────────────────────────────────
289
290/// Why two session files are linked.
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292pub enum SessionLinkKind {
293    /// The provider rotated to a new file (plan-mode exit, context overflow, etc.).
294    Rotation,
295}
296
297/// A link between two session segments.
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct SessionLink {
300    /// Session ID of the linked segment.
301    pub session_id: String,
302    /// Why the link exists.
303    pub kind: SessionLinkKind,
304}
305
306// ── Events ───────────────────────────────────────────────────────────
307
308/// Events emitted by a [`ConversationWatcher`].
309#[derive(Debug, Clone)]
310pub enum WatcherEvent {
311    /// A turn seen for the first time.
312    Turn(Box<Turn>),
313
314    /// A previously-emitted turn with additional data filled in
315    /// (e.g. tool results that arrived in a later log entry).
316    ///
317    /// Consumers should replace their stored copy of the turn with this
318    /// updated version. The turn's `id` field identifies which turn to replace.
319    TurnUpdated(Box<Turn>),
320
321    /// A non-conversational progress/status event.
322    Progress {
323        kind: String,
324        data: serde_json::Value,
325    },
326}
327
328// ── Traits ───────────────────────────────────────────────────────────
329
330/// Trait for converting provider-specific conversation data into the
331/// generic [`ConversationView`].
332///
333/// Implement this on your provider's manager type (e.g. `ClaudeConvo`).
334pub trait ConversationProvider {
335    /// List conversation IDs for a project/workspace.
336    fn list_conversations(&self, project: &str) -> Result<Vec<String>>;
337
338    /// Load a full conversation as a [`ConversationView`].
339    fn load_conversation(&self, project: &str, conversation_id: &str) -> Result<ConversationView>;
340
341    /// Load metadata only (no turns).
342    fn load_metadata(&self, project: &str, conversation_id: &str) -> Result<ConversationMeta>;
343
344    /// List metadata for all conversations in a project.
345    fn list_metadata(&self, project: &str) -> Result<Vec<ConversationMeta>>;
346}
347
348/// Trait for polling conversation updates from any provider.
349pub trait ConversationWatcher {
350    /// Poll for new events since the last poll.
351    fn poll(&mut self) -> Result<Vec<WatcherEvent>>;
352
353    /// Number of turns seen so far.
354    fn seen_count(&self) -> usize;
355}
356
357// ── Tests ────────────────────────────────────────────────────────────
358
359#[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        // Old-format JSON without cache fields should deserialize with None
574        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")); // empty vec skipped
625        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        // Old-format JSON without category should deserialize as None
662        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        // Old-format Turn JSON without environment/delegations
723        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        // Old-format JSON without total_usage/provider_id/files_changed
760        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}