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    ///
190    /// Providers namespace their data under a provider key (e.g.
191    /// `extra["claude"]` for Claude Code) to avoid collisions when
192    /// consumers work with multiple providers.
193    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
194    pub extra: HashMap<String, serde_json::Value>,
195}
196
197/// A complete conversation from any provider.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct ConversationView {
200    /// Unique session/conversation identifier.
201    pub id: String,
202
203    /// When the conversation started.
204    pub started_at: Option<DateTime<Utc>>,
205
206    /// When the conversation was last active.
207    pub last_activity: Option<DateTime<Utc>>,
208
209    /// Ordered turns.
210    pub turns: Vec<Turn>,
211
212    /// Aggregate token usage across all turns.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub total_usage: Option<TokenUsage>,
215
216    /// Provider identity (e.g. "claude-code", "aider", "codex-cli").
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub provider_id: Option<String>,
219
220    /// Files mutated during this conversation, deduplicated, in first-touch order.
221    /// Populated by the provider from tool invocation inputs.
222    #[serde(default, skip_serializing_if = "Vec::is_empty")]
223    pub files_changed: Vec<String>,
224
225    /// All session IDs that were merged to produce this view, in
226    /// chronological order (oldest segment first). Empty or single-element
227    /// for non-chained conversations.
228    #[serde(default, skip_serializing_if = "Vec::is_empty")]
229    pub session_ids: Vec<String>,
230}
231
232impl ConversationView {
233    /// Title derived from the first user turn, truncated to `max_len` characters.
234    pub fn title(&self, max_len: usize) -> Option<String> {
235        let text = self
236            .turns
237            .iter()
238            .find(|t| t.role == Role::User && !t.text.is_empty())
239            .map(|t| &t.text)?;
240
241        if text.chars().count() > max_len {
242            let truncated: String = text.chars().take(max_len).collect();
243            Some(format!("{}...", truncated))
244        } else {
245            Some(text.clone())
246        }
247    }
248
249    /// All turns with the given role.
250    pub fn turns_by_role(&self, role: &Role) -> Vec<&Turn> {
251        self.turns.iter().filter(|t| &t.role == role).collect()
252    }
253
254    /// Turns added after the turn with the given ID.
255    ///
256    /// If the ID is not found, returns all turns. If the ID is the last
257    /// turn, returns an empty slice.
258    pub fn turns_since(&self, turn_id: &str) -> &[Turn] {
259        match self.turns.iter().position(|t| t.id == turn_id) {
260            Some(idx) if idx + 1 < self.turns.len() => &self.turns[idx + 1..],
261            Some(_) => &[],
262            None => &self.turns,
263        }
264    }
265}
266
267/// Lightweight metadata for a conversation (no turns loaded).
268///
269/// Returned by [`ConversationProvider::load_metadata`] and
270/// [`ConversationProvider::list_metadata`] for listing conversations
271/// without the cost of loading all turns.
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct ConversationMeta {
274    /// Unique session/conversation identifier.
275    pub id: String,
276    /// When the conversation started.
277    pub started_at: Option<DateTime<Utc>>,
278    /// When the conversation was last active.
279    pub last_activity: Option<DateTime<Utc>>,
280    /// Total number of messages (entries) in the conversation.
281    pub message_count: usize,
282    /// Path to the backing file, if file-based.
283    pub file_path: Option<PathBuf>,
284    /// Link to the preceding session segment (if this is a continuation).
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub predecessor: Option<SessionLink>,
287    /// Link to the following session segment (if this was continued).
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub successor: Option<SessionLink>,
290}
291
292// ── Session chaining ─────────────────────────────────────────────────
293
294/// Why two session files are linked.
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296pub enum SessionLinkKind {
297    /// The provider rotated to a new file (plan-mode exit, context overflow, etc.).
298    Rotation,
299}
300
301/// A link between two session segments.
302#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct SessionLink {
304    /// Session ID of the linked segment.
305    pub session_id: String,
306    /// Why the link exists.
307    pub kind: SessionLinkKind,
308}
309
310// ── Events ───────────────────────────────────────────────────────────
311
312/// Events emitted by a [`ConversationWatcher`].
313///
314/// # Dispatch
315///
316/// Use `match` for exhaustive dispatch — the compiler catches new variants:
317///
318/// ```
319/// use toolpath_convo::WatcherEvent;
320///
321/// fn handle_events(events: &[WatcherEvent]) {
322///     for event in events {
323///         match event {
324///             WatcherEvent::Turn(turn) => {
325///                 println!("new turn {}: {}", turn.id, turn.text);
326///             }
327///             WatcherEvent::TurnUpdated(turn) => {
328///                 println!("updated turn {}: {}", turn.id, turn.text);
329///             }
330///             WatcherEvent::Progress { kind, data } => {
331///                 println!("progress ({}): {}", kind, data);
332///             }
333///         }
334///     }
335/// }
336/// ```
337///
338/// Convenience methods ([`as_turn`](WatcherEvent::as_turn),
339/// [`turn_id`](WatcherEvent::turn_id), [`is_update`](WatcherEvent::is_update),
340/// [`as_progress`](WatcherEvent::as_progress)) are useful when `Turn` and
341/// `TurnUpdated` collapse into the same code path or for quick field access.
342#[derive(Debug, Clone)]
343pub enum WatcherEvent {
344    /// A turn seen for the first time.
345    Turn(Box<Turn>),
346
347    /// A previously-emitted turn with additional data filled in
348    /// (e.g. tool results that arrived in a later log entry).
349    ///
350    /// Consumers should replace their stored copy of the turn with this
351    /// updated version. The turn's `id` field identifies which turn to replace.
352    TurnUpdated(Box<Turn>),
353
354    /// A non-conversational progress/status event.
355    Progress {
356        kind: String,
357        data: serde_json::Value,
358    },
359}
360
361impl WatcherEvent {
362    /// Returns the [`Turn`] payload for both [`Turn`](WatcherEvent::Turn)
363    /// and [`TurnUpdated`](WatcherEvent::TurnUpdated) variants.
364    pub fn as_turn(&self) -> Option<&Turn> {
365        match self {
366            WatcherEvent::Turn(t) | WatcherEvent::TurnUpdated(t) => Some(t),
367            WatcherEvent::Progress { .. } => None,
368        }
369    }
370
371    /// Returns `(kind, data)` for [`Progress`](WatcherEvent::Progress) events.
372    pub fn as_progress(&self) -> Option<(&str, &serde_json::Value)> {
373        match self {
374            WatcherEvent::Progress { kind, data } => Some((kind, data)),
375            _ => None,
376        }
377    }
378
379    /// Returns `true` only for [`TurnUpdated`](WatcherEvent::TurnUpdated).
380    pub fn is_update(&self) -> bool {
381        matches!(self, WatcherEvent::TurnUpdated(_))
382    }
383
384    /// Returns the turn ID for turn-carrying variants.
385    pub fn turn_id(&self) -> Option<&str> {
386        self.as_turn().map(|t| t.id.as_str())
387    }
388}
389
390// ── Traits ───────────────────────────────────────────────────────────
391
392/// Trait for converting provider-specific conversation data into the
393/// generic [`ConversationView`].
394///
395/// Implement this on your provider's manager type (e.g. `ClaudeConvo`).
396pub trait ConversationProvider {
397    /// List conversation IDs for a project/workspace.
398    fn list_conversations(&self, project: &str) -> Result<Vec<String>>;
399
400    /// Load a full conversation as a [`ConversationView`].
401    fn load_conversation(&self, project: &str, conversation_id: &str) -> Result<ConversationView>;
402
403    /// Load metadata only (no turns).
404    fn load_metadata(&self, project: &str, conversation_id: &str) -> Result<ConversationMeta>;
405
406    /// List metadata for all conversations in a project.
407    fn list_metadata(&self, project: &str) -> Result<Vec<ConversationMeta>>;
408}
409
410/// Trait for polling conversation updates from any provider.
411pub trait ConversationWatcher {
412    /// Poll for new events since the last poll.
413    fn poll(&mut self) -> Result<Vec<WatcherEvent>>;
414
415    /// Number of turns seen so far.
416    fn seen_count(&self) -> usize;
417}
418
419// ── Tests ────────────────────────────────────────────────────────────
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    fn sample_view() -> ConversationView {
426        ConversationView {
427            id: "sess-1".into(),
428            started_at: None,
429            last_activity: None,
430            turns: vec![
431                Turn {
432                    id: "t1".into(),
433                    parent_id: None,
434                    role: Role::User,
435                    timestamp: "2026-01-01T00:00:00Z".into(),
436                    text: "Fix the authentication bug in login.rs".into(),
437                    thinking: None,
438                    tool_uses: vec![],
439                    model: None,
440                    stop_reason: None,
441                    token_usage: None,
442                    environment: None,
443                    delegations: vec![],
444                    extra: HashMap::new(),
445                },
446                Turn {
447                    id: "t2".into(),
448                    parent_id: Some("t1".into()),
449                    role: Role::Assistant,
450                    timestamp: "2026-01-01T00:00:01Z".into(),
451                    text: "I'll fix that for you.".into(),
452                    thinking: Some("The bug is in the token validation".into()),
453                    tool_uses: vec![ToolInvocation {
454                        id: "tool-1".into(),
455                        name: "Read".into(),
456                        input: serde_json::json!({"file": "src/login.rs"}),
457                        result: Some(ToolResult {
458                            content: "fn login() { ... }".into(),
459                            is_error: false,
460                        }),
461                        category: Some(ToolCategory::FileRead),
462                    }],
463                    model: Some("claude-opus-4-6".into()),
464                    stop_reason: Some("end_turn".into()),
465                    token_usage: Some(TokenUsage {
466                        input_tokens: Some(100),
467                        output_tokens: Some(50),
468                        cache_read_tokens: None,
469                        cache_write_tokens: None,
470                    }),
471                    environment: None,
472                    delegations: vec![],
473                    extra: HashMap::new(),
474                },
475                Turn {
476                    id: "t3".into(),
477                    parent_id: Some("t2".into()),
478                    role: Role::User,
479                    timestamp: "2026-01-01T00:00:02Z".into(),
480                    text: "Thanks!".into(),
481                    thinking: None,
482                    tool_uses: vec![],
483                    model: None,
484                    stop_reason: None,
485                    token_usage: None,
486                    environment: None,
487                    delegations: vec![],
488                    extra: HashMap::new(),
489                },
490            ],
491            total_usage: None,
492            provider_id: None,
493            files_changed: vec![],
494            session_ids: vec![],
495        }
496    }
497
498    #[test]
499    fn test_title_short() {
500        let view = sample_view();
501        let title = view.title(100).unwrap();
502        assert_eq!(title, "Fix the authentication bug in login.rs");
503    }
504
505    #[test]
506    fn test_title_truncated() {
507        let view = sample_view();
508        let title = view.title(10).unwrap();
509        assert_eq!(title, "Fix the au...");
510    }
511
512    #[test]
513    fn test_title_empty() {
514        let view = ConversationView {
515            id: "empty".into(),
516            started_at: None,
517            last_activity: None,
518            turns: vec![],
519            total_usage: None,
520            provider_id: None,
521            files_changed: vec![],
522            session_ids: vec![],
523        };
524        assert!(view.title(50).is_none());
525    }
526
527    #[test]
528    fn test_turns_by_role() {
529        let view = sample_view();
530        let users = view.turns_by_role(&Role::User);
531        assert_eq!(users.len(), 2);
532        let assistants = view.turns_by_role(&Role::Assistant);
533        assert_eq!(assistants.len(), 1);
534    }
535
536    #[test]
537    fn test_turns_since_middle() {
538        let view = sample_view();
539        let since = view.turns_since("t1");
540        assert_eq!(since.len(), 2);
541        assert_eq!(since[0].id, "t2");
542    }
543
544    #[test]
545    fn test_turns_since_last() {
546        let view = sample_view();
547        let since = view.turns_since("t3");
548        assert!(since.is_empty());
549    }
550
551    #[test]
552    fn test_turns_since_unknown() {
553        let view = sample_view();
554        let since = view.turns_since("nonexistent");
555        assert_eq!(since.len(), 3);
556    }
557
558    #[test]
559    fn test_role_display() {
560        assert_eq!(Role::User.to_string(), "user");
561        assert_eq!(Role::Assistant.to_string(), "assistant");
562        assert_eq!(Role::System.to_string(), "system");
563        assert_eq!(Role::Other("tool".into()).to_string(), "tool");
564    }
565
566    #[test]
567    fn test_role_equality() {
568        assert_eq!(Role::User, Role::User);
569        assert_ne!(Role::User, Role::Assistant);
570        assert_eq!(Role::Other("x".into()), Role::Other("x".into()));
571        assert_ne!(Role::Other("x".into()), Role::Other("y".into()));
572    }
573
574    #[test]
575    fn test_turn_serde_roundtrip() {
576        let turn = &sample_view().turns[1];
577        let json = serde_json::to_string(turn).unwrap();
578        let back: Turn = serde_json::from_str(&json).unwrap();
579        assert_eq!(back.id, "t2");
580        assert_eq!(back.model, Some("claude-opus-4-6".into()));
581        assert_eq!(back.tool_uses.len(), 1);
582        assert_eq!(back.tool_uses[0].name, "Read");
583        assert!(back.tool_uses[0].result.is_some());
584    }
585
586    #[test]
587    fn test_conversation_view_serde_roundtrip() {
588        let view = sample_view();
589        let json = serde_json::to_string(&view).unwrap();
590        let back: ConversationView = serde_json::from_str(&json).unwrap();
591        assert_eq!(back.id, "sess-1");
592        assert_eq!(back.turns.len(), 3);
593    }
594
595    #[test]
596    fn test_watcher_event_variants() {
597        let turn_event = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
598        assert!(matches!(turn_event, WatcherEvent::Turn(_)));
599
600        let updated_event = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[1].clone()));
601        assert!(matches!(updated_event, WatcherEvent::TurnUpdated(_)));
602
603        let progress_event = WatcherEvent::Progress {
604            kind: "agent_progress".into(),
605            data: serde_json::json!({"status": "running"}),
606        };
607        assert!(matches!(progress_event, WatcherEvent::Progress { .. }));
608    }
609
610    #[test]
611    fn test_watcher_event_as_turn() {
612        let turn = sample_view().turns[0].clone();
613        let event = WatcherEvent::Turn(Box::new(turn.clone()));
614        assert_eq!(event.as_turn().unwrap().id, "t1");
615
616        let updated = WatcherEvent::TurnUpdated(Box::new(turn));
617        assert_eq!(updated.as_turn().unwrap().id, "t1");
618
619        let progress = WatcherEvent::Progress {
620            kind: "test".into(),
621            data: serde_json::Value::Null,
622        };
623        assert!(progress.as_turn().is_none());
624    }
625
626    #[test]
627    fn test_watcher_event_as_progress() {
628        let progress = WatcherEvent::Progress {
629            kind: "hook_progress".into(),
630            data: serde_json::json!({"hookName": "pre-commit"}),
631        };
632        let (kind, data) = progress.as_progress().unwrap();
633        assert_eq!(kind, "hook_progress");
634        assert_eq!(data["hookName"], "pre-commit");
635
636        let turn = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
637        assert!(turn.as_progress().is_none());
638    }
639
640    #[test]
641    fn test_watcher_event_is_update() {
642        let turn = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
643        assert!(!turn.is_update());
644
645        let updated = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[0].clone()));
646        assert!(updated.is_update());
647
648        let progress = WatcherEvent::Progress {
649            kind: "test".into(),
650            data: serde_json::Value::Null,
651        };
652        assert!(!progress.is_update());
653    }
654
655    #[test]
656    fn test_watcher_event_turn_id() {
657        let turn = WatcherEvent::Turn(Box::new(sample_view().turns[1].clone()));
658        assert_eq!(turn.turn_id(), Some("t2"));
659
660        let updated = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[0].clone()));
661        assert_eq!(updated.turn_id(), Some("t1"));
662
663        let progress = WatcherEvent::Progress {
664            kind: "test".into(),
665            data: serde_json::Value::Null,
666        };
667        assert!(progress.turn_id().is_none());
668    }
669
670    #[test]
671    fn test_token_usage_default() {
672        let usage = TokenUsage::default();
673        assert!(usage.input_tokens.is_none());
674        assert!(usage.output_tokens.is_none());
675        assert!(usage.cache_read_tokens.is_none());
676        assert!(usage.cache_write_tokens.is_none());
677    }
678
679    #[test]
680    fn test_token_usage_cache_fields_serde() {
681        let usage = TokenUsage {
682            input_tokens: Some(100),
683            output_tokens: Some(50),
684            cache_read_tokens: Some(500),
685            cache_write_tokens: Some(200),
686        };
687        let json = serde_json::to_string(&usage).unwrap();
688        let back: TokenUsage = serde_json::from_str(&json).unwrap();
689        assert_eq!(back.cache_read_tokens, Some(500));
690        assert_eq!(back.cache_write_tokens, Some(200));
691    }
692
693    #[test]
694    fn test_token_usage_cache_fields_omitted() {
695        // Old-format JSON without cache fields should deserialize with None
696        let json = r#"{"input_tokens":100,"output_tokens":50}"#;
697        let usage: TokenUsage = serde_json::from_str(json).unwrap();
698        assert_eq!(usage.input_tokens, Some(100));
699        assert!(usage.cache_read_tokens.is_none());
700        assert!(usage.cache_write_tokens.is_none());
701    }
702
703    #[test]
704    fn test_environment_snapshot_serde() {
705        let env = EnvironmentSnapshot {
706            working_dir: Some("/home/user/project".into()),
707            vcs_branch: Some("main".into()),
708            vcs_revision: Some("abc123".into()),
709        };
710        let json = serde_json::to_string(&env).unwrap();
711        let back: EnvironmentSnapshot = serde_json::from_str(&json).unwrap();
712        assert_eq!(back.working_dir.as_deref(), Some("/home/user/project"));
713        assert_eq!(back.vcs_branch.as_deref(), Some("main"));
714        assert_eq!(back.vcs_revision.as_deref(), Some("abc123"));
715    }
716
717    #[test]
718    fn test_environment_snapshot_default() {
719        let env = EnvironmentSnapshot::default();
720        assert!(env.working_dir.is_none());
721        assert!(env.vcs_branch.is_none());
722        assert!(env.vcs_revision.is_none());
723    }
724
725    #[test]
726    fn test_environment_snapshot_skip_none_fields() {
727        let env = EnvironmentSnapshot {
728            working_dir: Some("/tmp".into()),
729            vcs_branch: None,
730            vcs_revision: None,
731        };
732        let json = serde_json::to_string(&env).unwrap();
733        assert!(!json.contains("vcs_branch"));
734        assert!(!json.contains("vcs_revision"));
735    }
736
737    #[test]
738    fn test_delegated_work_serde() {
739        let dw = DelegatedWork {
740            agent_id: "agent-123".into(),
741            prompt: "Search for the bug".into(),
742            turns: vec![],
743            result: Some("Found the bug in auth.rs".into()),
744        };
745        let json = serde_json::to_string(&dw).unwrap();
746        assert!(!json.contains("turns")); // empty vec skipped
747        let back: DelegatedWork = serde_json::from_str(&json).unwrap();
748        assert_eq!(back.agent_id, "agent-123");
749        assert_eq!(back.result.as_deref(), Some("Found the bug in auth.rs"));
750        assert!(back.turns.is_empty());
751    }
752
753    #[test]
754    fn test_tool_category_serde() {
755        let ti = ToolInvocation {
756            id: "t1".into(),
757            name: "Bash".into(),
758            input: serde_json::json!({"command": "ls"}),
759            result: None,
760            category: Some(ToolCategory::Shell),
761        };
762        let json = serde_json::to_string(&ti).unwrap();
763        assert!(json.contains("\"shell\""));
764        let back: ToolInvocation = serde_json::from_str(&json).unwrap();
765        assert_eq!(back.category, Some(ToolCategory::Shell));
766    }
767
768    #[test]
769    fn test_tool_category_none_skipped() {
770        let ti = ToolInvocation {
771            id: "t1".into(),
772            name: "CustomTool".into(),
773            input: serde_json::json!({}),
774            result: None,
775            category: None,
776        };
777        let json = serde_json::to_string(&ti).unwrap();
778        assert!(!json.contains("category"));
779    }
780
781    #[test]
782    fn test_tool_category_missing_defaults_none() {
783        // Old-format JSON without category should deserialize as None
784        let json = r#"{"id":"t1","name":"Read","input":{},"result":null}"#;
785        let ti: ToolInvocation = serde_json::from_str(json).unwrap();
786        assert!(ti.category.is_none());
787    }
788
789    #[test]
790    fn test_tool_category_all_variants_roundtrip() {
791        let variants = vec![
792            ToolCategory::FileRead,
793            ToolCategory::FileWrite,
794            ToolCategory::FileSearch,
795            ToolCategory::Shell,
796            ToolCategory::Network,
797            ToolCategory::Delegation,
798        ];
799        for cat in variants {
800            let json = serde_json::to_value(cat).unwrap();
801            let back: ToolCategory = serde_json::from_value(json).unwrap();
802            assert_eq!(back, cat);
803        }
804    }
805
806    #[test]
807    fn test_turn_with_environment_and_delegations() {
808        let turn = Turn {
809            id: "t1".into(),
810            parent_id: None,
811            role: Role::Assistant,
812            timestamp: "2026-01-01T00:00:00Z".into(),
813            text: "Delegating...".into(),
814            thinking: None,
815            tool_uses: vec![],
816            model: None,
817            stop_reason: None,
818            token_usage: None,
819            environment: Some(EnvironmentSnapshot {
820                working_dir: Some("/project".into()),
821                vcs_branch: Some("feat/auth".into()),
822                vcs_revision: None,
823            }),
824            delegations: vec![DelegatedWork {
825                agent_id: "sub-1".into(),
826                prompt: "Find the bug".into(),
827                turns: vec![],
828                result: None,
829            }],
830            extra: HashMap::new(),
831        };
832        let json = serde_json::to_string(&turn).unwrap();
833        let back: Turn = serde_json::from_str(&json).unwrap();
834        assert_eq!(
835            back.environment.as_ref().unwrap().vcs_branch.as_deref(),
836            Some("feat/auth")
837        );
838        assert_eq!(back.delegations.len(), 1);
839        assert_eq!(back.delegations[0].agent_id, "sub-1");
840    }
841
842    #[test]
843    fn test_turn_without_new_fields_deserializes() {
844        // Old-format Turn JSON without environment/delegations
845        let json = r#"{"id":"t1","parent_id":null,"role":"User","timestamp":"2026-01-01T00:00:00Z","text":"hi","thinking":null,"tool_uses":[],"model":null,"stop_reason":null,"token_usage":null}"#;
846        let turn: Turn = serde_json::from_str(json).unwrap();
847        assert!(turn.environment.is_none());
848        assert!(turn.delegations.is_empty());
849    }
850
851    #[test]
852    fn test_conversation_view_new_fields_serde() {
853        let view = ConversationView {
854            id: "s1".into(),
855            started_at: None,
856            last_activity: None,
857            turns: vec![],
858            total_usage: Some(TokenUsage {
859                input_tokens: Some(1000),
860                output_tokens: Some(500),
861                cache_read_tokens: Some(800),
862                cache_write_tokens: None,
863            }),
864            provider_id: Some("claude-code".into()),
865            files_changed: vec!["src/main.rs".into(), "src/lib.rs".into()],
866            session_ids: vec![],
867        };
868        let json = serde_json::to_string(&view).unwrap();
869        let back: ConversationView = serde_json::from_str(&json).unwrap();
870        assert_eq!(back.provider_id.as_deref(), Some("claude-code"));
871        assert_eq!(back.files_changed, vec!["src/main.rs", "src/lib.rs"]);
872        assert_eq!(back.total_usage.as_ref().unwrap().input_tokens, Some(1000));
873        assert_eq!(
874            back.total_usage.as_ref().unwrap().cache_read_tokens,
875            Some(800)
876        );
877    }
878
879    #[test]
880    fn test_conversation_view_old_format_deserializes() {
881        // Old-format JSON without total_usage/provider_id/files_changed
882        let json = r#"{"id":"s1","started_at":null,"last_activity":null,"turns":[]}"#;
883        let view: ConversationView = serde_json::from_str(json).unwrap();
884        assert!(view.total_usage.is_none());
885        assert!(view.provider_id.is_none());
886        assert!(view.files_changed.is_empty());
887    }
888
889    #[test]
890    fn test_conversation_meta() {
891        let meta = ConversationMeta {
892            id: "sess-1".into(),
893            started_at: None,
894            last_activity: None,
895            message_count: 5,
896            file_path: Some("/tmp/test.jsonl".into()),
897            predecessor: None,
898            successor: None,
899        };
900        let json = serde_json::to_string(&meta).unwrap();
901        let back: ConversationMeta = serde_json::from_str(&json).unwrap();
902        assert_eq!(back.message_count, 5);
903    }
904}