Skip to main content

toolpath_convo/
lib.rs

1#![doc = include_str!("../README.md")]
2
3pub mod derive;
4pub mod extract;
5pub mod project;
6
7pub use derive::{DeriveConfig, derive_path, file_write_diff, unified_diff};
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::PathBuf;
13
14// ── Error ────────────────────────────────────────────────────────────
15
16/// Errors from conversation provider operations.
17#[derive(Debug, thiserror::Error)]
18pub enum ConvoError {
19    #[error("I/O error: {0}")]
20    Io(#[from] std::io::Error),
21
22    #[error("JSON error: {0}")]
23    Json(#[from] serde_json::Error),
24
25    #[error("provider error: {0}")]
26    Provider(String),
27
28    #[error("{0}")]
29    Other(#[from] Box<dyn std::error::Error + Send + Sync>),
30}
31
32pub type Result<T> = std::result::Result<T, ConvoError>;
33
34// ── Core types ───────────────────────────────────────────────────────
35
36/// Who produced a turn.
37#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
38pub enum Role {
39    User,
40    Assistant,
41    System,
42    /// Provider-specific roles (e.g. "tool", "function").
43    Other(String),
44}
45
46impl std::fmt::Display for Role {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            Role::User => write!(f, "user"),
50            Role::Assistant => write!(f, "assistant"),
51            Role::System => write!(f, "system"),
52            Role::Other(s) => write!(f, "{}", s),
53        }
54    }
55}
56
57/// Token usage for a single turn.
58#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59pub struct TokenUsage {
60    /// Tokens sent to the model (prompt + context).
61    pub input_tokens: Option<u32>,
62    /// Tokens generated by the model.
63    pub output_tokens: Option<u32>,
64    /// Tokens read from cache (prompt caching, context caching).
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub cache_read_tokens: Option<u32>,
67    /// Tokens written to cache.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub cache_write_tokens: Option<u32>,
70}
71
72/// Snapshot of the working environment when a turn was produced.
73///
74/// All fields are optional. Providers populate what they have.
75#[derive(Debug, Clone, Default, Serialize, Deserialize)]
76pub struct EnvironmentSnapshot {
77    /// Working directory (absolute path).
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub working_dir: Option<String>,
80    /// Version control branch (git, hg, jj, etc.).
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub vcs_branch: Option<String>,
83    /// Version control revision (commit hash, changeset ID, etc.).
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub vcs_revision: Option<String>,
86}
87
88/// A sub-agent delegation: a turn that spawned child work.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct DelegatedWork {
91    /// Provider-specific agent identifier (e.g. session ID, task ID).
92    pub agent_id: String,
93    /// The prompt/instruction given to the sub-agent.
94    pub prompt: String,
95    /// Turns produced by the sub-agent (may be empty if not available
96    /// or if the sub-agent's work is stored in a separate session).
97    #[serde(default, skip_serializing_if = "Vec::is_empty")]
98    pub turns: Vec<Turn>,
99    /// Final result returned by the sub-agent.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub result: Option<String>,
102}
103
104/// A non-conversational event from the session (hook result, snapshot, etc.)
105///
106/// These are provider-specific entries that aren't turns but need to
107/// be preserved for round-trip fidelity.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct ConversationEvent {
110    /// Unique identifier (may be synthetic for entries without UUIDs).
111    pub id: String,
112    /// When this event occurred.
113    pub timestamp: String,
114    /// Parent event or turn ID (for ordering).
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub parent_id: Option<String>,
117    /// Event type (e.g., "attachment", "system", "file-history-snapshot").
118    pub event_type: String,
119    /// Event data — provider-specific key-value pairs.
120    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
121    pub data: HashMap<String, serde_json::Value>,
122}
123
124/// Toolpath's classification of what a tool invocation does.
125///
126/// This is toolpath's ontology, not a provider-specific label. Provider
127/// crates map their tool names into these categories. `None` means the
128/// tool isn't recognized — consumers still have `name` and `input` for
129/// anything we don't classify.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
131#[serde(rename_all = "snake_case")]
132pub enum ToolCategory {
133    /// Read a file — no side effects on the filesystem.
134    FileRead,
135    /// Write, edit, create, or delete a file.
136    FileWrite,
137    /// Search or discover files by name or content pattern.
138    FileSearch,
139    /// Shell or terminal command execution.
140    Shell,
141    /// Network access — web fetch, search, API call.
142    Network,
143    /// Spawn a sub-agent or delegate work.
144    Delegation,
145}
146
147/// A tool invocation within a turn.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct ToolInvocation {
150    /// Provider-assigned identifier for this invocation.
151    pub id: String,
152    /// Provider-specific tool name (e.g. `"Read"`, `"Bash"`, `"editor"`).
153    pub name: String,
154    /// Tool input parameters as provider-specific JSON.
155    pub input: serde_json::Value,
156    /// Populated when the result is available in the same turn.
157    pub result: Option<ToolResult>,
158    /// Toolpath's classification of this invocation. Set by the provider
159    /// crate; `None` for unrecognized tools.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub category: Option<ToolCategory>,
162}
163
164/// The result of a tool invocation.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct ToolResult {
167    /// The text content returned by the tool.
168    pub content: String,
169    /// Whether the tool reported an error.
170    pub is_error: bool,
171}
172
173/// A single turn in a conversation, from any provider.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct Turn {
176    /// Unique identifier within the conversation.
177    pub id: String,
178
179    /// Parent turn ID (for branching conversations).
180    pub parent_id: Option<String>,
181
182    /// Who produced this turn.
183    pub role: Role,
184
185    /// When this turn occurred (ISO 8601).
186    pub timestamp: String,
187
188    /// The visible text content (already collapsed from provider-specific formats).
189    pub text: String,
190
191    /// Internal reasoning (chain-of-thought, thinking blocks).
192    pub thinking: Option<String>,
193
194    /// Tool invocations in this turn.
195    pub tool_uses: Vec<ToolInvocation>,
196
197    /// Model identifier (e.g. "claude-opus-4-6", "gpt-4o").
198    pub model: Option<String>,
199
200    /// Why the turn ended (e.g. "end_turn", "tool_use", "max_tokens").
201    pub stop_reason: Option<String>,
202
203    /// Token usage for this turn.
204    pub token_usage: Option<TokenUsage>,
205
206    /// Environment at time of this turn.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub environment: Option<EnvironmentSnapshot>,
209
210    /// Sub-agent work delegated from this turn.
211    #[serde(default, skip_serializing_if = "Vec::is_empty")]
212    pub delegations: Vec<DelegatedWork>,
213
214    /// Provider-specific data that doesn't fit the common schema.
215    ///
216    /// Providers namespace their data under a provider key (e.g.
217    /// `extra["claude"]` for Claude Code) to avoid collisions when
218    /// consumers work with multiple providers.
219    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
220    pub extra: HashMap<String, serde_json::Value>,
221}
222
223/// A complete conversation from any provider.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct ConversationView {
226    /// Unique session/conversation identifier.
227    pub id: String,
228
229    /// When the conversation started.
230    pub started_at: Option<DateTime<Utc>>,
231
232    /// When the conversation was last active.
233    pub last_activity: Option<DateTime<Utc>>,
234
235    /// Ordered turns.
236    pub turns: Vec<Turn>,
237
238    /// Aggregate token usage across all turns.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub total_usage: Option<TokenUsage>,
241
242    /// Provider identity (e.g. "claude-code", "aider", "codex-cli").
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub provider_id: Option<String>,
245
246    /// Files mutated during this conversation, deduplicated, in first-touch order.
247    /// Populated by the provider from tool invocation inputs.
248    #[serde(default, skip_serializing_if = "Vec::is_empty")]
249    pub files_changed: Vec<String>,
250
251    /// All session IDs that were merged to produce this view, in
252    /// chronological order (oldest segment first). Empty or single-element
253    /// for non-chained conversations.
254    #[serde(default, skip_serializing_if = "Vec::is_empty")]
255    pub session_ids: Vec<String>,
256
257    /// Non-conversational events (hooks, snapshots, metadata) in order.
258    /// These are provider-specific entries that aren't turns but need to
259    /// be preserved for round-trip fidelity.
260    #[serde(default, skip_serializing_if = "Vec::is_empty")]
261    pub events: Vec<ConversationEvent>,
262}
263
264impl ConversationView {
265    /// Title derived from the first user turn, truncated to `max_len` characters.
266    pub fn title(&self, max_len: usize) -> Option<String> {
267        let text = self
268            .turns
269            .iter()
270            .find(|t| t.role == Role::User && !t.text.is_empty())
271            .map(|t| &t.text)?;
272
273        if text.chars().count() > max_len {
274            let truncated: String = text.chars().take(max_len).collect();
275            Some(format!("{}...", truncated))
276        } else {
277            Some(text.clone())
278        }
279    }
280
281    /// All turns with the given role.
282    pub fn turns_by_role(&self, role: &Role) -> Vec<&Turn> {
283        self.turns.iter().filter(|t| &t.role == role).collect()
284    }
285
286    /// Turns added after the turn with the given ID.
287    ///
288    /// If the ID is not found, returns all turns. If the ID is the last
289    /// turn, returns an empty slice.
290    pub fn turns_since(&self, turn_id: &str) -> &[Turn] {
291        match self.turns.iter().position(|t| t.id == turn_id) {
292            Some(idx) if idx + 1 < self.turns.len() => &self.turns[idx + 1..],
293            Some(_) => &[],
294            None => &self.turns,
295        }
296    }
297}
298
299/// Lightweight metadata for a conversation (no turns loaded).
300///
301/// Returned by [`ConversationProvider::load_metadata`] and
302/// [`ConversationProvider::list_metadata`] for listing conversations
303/// without the cost of loading all turns.
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct ConversationMeta {
306    /// Unique session/conversation identifier.
307    pub id: String,
308    /// When the conversation started.
309    pub started_at: Option<DateTime<Utc>>,
310    /// When the conversation was last active.
311    pub last_activity: Option<DateTime<Utc>>,
312    /// Total number of messages (entries) in the conversation.
313    pub message_count: usize,
314    /// Path to the backing file, if file-based.
315    pub file_path: Option<PathBuf>,
316    /// Link to the preceding session segment (if this is a continuation).
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub predecessor: Option<SessionLink>,
319    /// Link to the following session segment (if this was continued).
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub successor: Option<SessionLink>,
322}
323
324// ── Session chaining ─────────────────────────────────────────────────
325
326/// Why two session files are linked.
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328pub enum SessionLinkKind {
329    /// The provider rotated to a new file (plan-mode exit, context overflow, etc.).
330    Rotation,
331}
332
333/// A link between two session segments.
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct SessionLink {
336    /// Session ID of the linked segment.
337    pub session_id: String,
338    /// Why the link exists.
339    pub kind: SessionLinkKind,
340}
341
342// ── Events ───────────────────────────────────────────────────────────
343
344/// Events emitted by a [`ConversationWatcher`].
345///
346/// # Dispatch
347///
348/// Use `match` for exhaustive dispatch — the compiler catches new variants:
349///
350/// ```
351/// use toolpath_convo::WatcherEvent;
352///
353/// fn handle_events(events: &[WatcherEvent]) {
354///     for event in events {
355///         match event {
356///             WatcherEvent::Turn(turn) => {
357///                 println!("new turn {}: {}", turn.id, turn.text);
358///             }
359///             WatcherEvent::TurnUpdated(turn) => {
360///                 println!("updated turn {}: {}", turn.id, turn.text);
361///             }
362///             WatcherEvent::Progress { kind, data } => {
363///                 println!("progress ({}): {}", kind, data);
364///             }
365///         }
366///     }
367/// }
368/// ```
369///
370/// Convenience methods ([`as_turn`](WatcherEvent::as_turn),
371/// [`turn_id`](WatcherEvent::turn_id), [`is_update`](WatcherEvent::is_update),
372/// [`as_progress`](WatcherEvent::as_progress)) are useful when `Turn` and
373/// `TurnUpdated` collapse into the same code path or for quick field access.
374#[derive(Debug, Clone)]
375pub enum WatcherEvent {
376    /// A turn seen for the first time.
377    Turn(Box<Turn>),
378
379    /// A previously-emitted turn with additional data filled in
380    /// (e.g. tool results that arrived in a later log entry).
381    ///
382    /// Consumers should replace their stored copy of the turn with this
383    /// updated version. The turn's `id` field identifies which turn to replace.
384    TurnUpdated(Box<Turn>),
385
386    /// A non-conversational progress/status event.
387    Progress {
388        kind: String,
389        data: serde_json::Value,
390    },
391}
392
393impl WatcherEvent {
394    /// Returns the [`Turn`] payload for both [`Turn`](WatcherEvent::Turn)
395    /// and [`TurnUpdated`](WatcherEvent::TurnUpdated) variants.
396    pub fn as_turn(&self) -> Option<&Turn> {
397        match self {
398            WatcherEvent::Turn(t) | WatcherEvent::TurnUpdated(t) => Some(t),
399            WatcherEvent::Progress { .. } => None,
400        }
401    }
402
403    /// Returns `(kind, data)` for [`Progress`](WatcherEvent::Progress) events.
404    pub fn as_progress(&self) -> Option<(&str, &serde_json::Value)> {
405        match self {
406            WatcherEvent::Progress { kind, data } => Some((kind, data)),
407            _ => None,
408        }
409    }
410
411    /// Returns `true` only for [`TurnUpdated`](WatcherEvent::TurnUpdated).
412    pub fn is_update(&self) -> bool {
413        matches!(self, WatcherEvent::TurnUpdated(_))
414    }
415
416    /// Returns the turn ID for turn-carrying variants.
417    pub fn turn_id(&self) -> Option<&str> {
418        self.as_turn().map(|t| t.id.as_str())
419    }
420}
421
422// ── Traits ───────────────────────────────────────────────────────────
423
424/// Trait for converting provider-specific conversation data into the
425/// generic [`ConversationView`].
426///
427/// Implement this on your provider's manager type (e.g. `ClaudeConvo`).
428pub trait ConversationProvider {
429    /// List conversation IDs for a project/workspace.
430    fn list_conversations(&self, project: &str) -> Result<Vec<String>>;
431
432    /// Load a full conversation as a [`ConversationView`].
433    fn load_conversation(&self, project: &str, conversation_id: &str) -> Result<ConversationView>;
434
435    /// Load metadata only (no turns).
436    fn load_metadata(&self, project: &str, conversation_id: &str) -> Result<ConversationMeta>;
437
438    /// List metadata for all conversations in a project.
439    fn list_metadata(&self, project: &str) -> Result<Vec<ConversationMeta>>;
440}
441
442/// Trait for polling conversation updates from any provider.
443pub trait ConversationWatcher {
444    /// Poll for new events since the last poll.
445    fn poll(&mut self) -> Result<Vec<WatcherEvent>>;
446
447    /// Number of turns seen so far.
448    fn seen_count(&self) -> usize;
449}
450
451pub use extract::extract_conversation;
452pub use project::{AnyProjector, ConversationProjector};
453
454// ── Tests ────────────────────────────────────────────────────────────
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    fn sample_view() -> ConversationView {
461        ConversationView {
462            id: "sess-1".into(),
463            started_at: None,
464            last_activity: None,
465            turns: vec![
466                Turn {
467                    id: "t1".into(),
468                    parent_id: None,
469                    role: Role::User,
470                    timestamp: "2026-01-01T00:00:00Z".into(),
471                    text: "Fix the authentication bug in login.rs".into(),
472                    thinking: None,
473                    tool_uses: vec![],
474                    model: None,
475                    stop_reason: None,
476                    token_usage: None,
477                    environment: None,
478                    delegations: vec![],
479                    extra: HashMap::new(),
480                },
481                Turn {
482                    id: "t2".into(),
483                    parent_id: Some("t1".into()),
484                    role: Role::Assistant,
485                    timestamp: "2026-01-01T00:00:01Z".into(),
486                    text: "I'll fix that for you.".into(),
487                    thinking: Some("The bug is in the token validation".into()),
488                    tool_uses: vec![ToolInvocation {
489                        id: "tool-1".into(),
490                        name: "Read".into(),
491                        input: serde_json::json!({"file": "src/login.rs"}),
492                        result: Some(ToolResult {
493                            content: "fn login() { ... }".into(),
494                            is_error: false,
495                        }),
496                        category: Some(ToolCategory::FileRead),
497                    }],
498                    model: Some("claude-opus-4-6".into()),
499                    stop_reason: Some("end_turn".into()),
500                    token_usage: Some(TokenUsage {
501                        input_tokens: Some(100),
502                        output_tokens: Some(50),
503                        cache_read_tokens: None,
504                        cache_write_tokens: None,
505                    }),
506                    environment: None,
507                    delegations: vec![],
508                    extra: HashMap::new(),
509                },
510                Turn {
511                    id: "t3".into(),
512                    parent_id: Some("t2".into()),
513                    role: Role::User,
514                    timestamp: "2026-01-01T00:00:02Z".into(),
515                    text: "Thanks!".into(),
516                    thinking: None,
517                    tool_uses: vec![],
518                    model: None,
519                    stop_reason: None,
520                    token_usage: None,
521                    environment: None,
522                    delegations: vec![],
523                    extra: HashMap::new(),
524                },
525            ],
526            total_usage: None,
527            provider_id: None,
528            files_changed: vec![],
529            session_ids: vec![],
530            events: vec![],
531        }
532    }
533
534    #[test]
535    fn test_title_short() {
536        let view = sample_view();
537        let title = view.title(100).unwrap();
538        assert_eq!(title, "Fix the authentication bug in login.rs");
539    }
540
541    #[test]
542    fn test_title_truncated() {
543        let view = sample_view();
544        let title = view.title(10).unwrap();
545        assert_eq!(title, "Fix the au...");
546    }
547
548    #[test]
549    fn test_title_empty() {
550        let view = ConversationView {
551            id: "empty".into(),
552            started_at: None,
553            last_activity: None,
554            turns: vec![],
555            total_usage: None,
556            provider_id: None,
557            files_changed: vec![],
558            session_ids: vec![],
559            events: vec![],
560        };
561        assert!(view.title(50).is_none());
562    }
563
564    #[test]
565    fn test_turns_by_role() {
566        let view = sample_view();
567        let users = view.turns_by_role(&Role::User);
568        assert_eq!(users.len(), 2);
569        let assistants = view.turns_by_role(&Role::Assistant);
570        assert_eq!(assistants.len(), 1);
571    }
572
573    #[test]
574    fn test_turns_since_middle() {
575        let view = sample_view();
576        let since = view.turns_since("t1");
577        assert_eq!(since.len(), 2);
578        assert_eq!(since[0].id, "t2");
579    }
580
581    #[test]
582    fn test_turns_since_last() {
583        let view = sample_view();
584        let since = view.turns_since("t3");
585        assert!(since.is_empty());
586    }
587
588    #[test]
589    fn test_turns_since_unknown() {
590        let view = sample_view();
591        let since = view.turns_since("nonexistent");
592        assert_eq!(since.len(), 3);
593    }
594
595    #[test]
596    fn test_role_display() {
597        assert_eq!(Role::User.to_string(), "user");
598        assert_eq!(Role::Assistant.to_string(), "assistant");
599        assert_eq!(Role::System.to_string(), "system");
600        assert_eq!(Role::Other("tool".into()).to_string(), "tool");
601    }
602
603    #[test]
604    fn test_role_equality() {
605        assert_eq!(Role::User, Role::User);
606        assert_ne!(Role::User, Role::Assistant);
607        assert_eq!(Role::Other("x".into()), Role::Other("x".into()));
608        assert_ne!(Role::Other("x".into()), Role::Other("y".into()));
609    }
610
611    #[test]
612    fn test_turn_serde_roundtrip() {
613        let turn = &sample_view().turns[1];
614        let json = serde_json::to_string(turn).unwrap();
615        let back: Turn = serde_json::from_str(&json).unwrap();
616        assert_eq!(back.id, "t2");
617        assert_eq!(back.model, Some("claude-opus-4-6".into()));
618        assert_eq!(back.tool_uses.len(), 1);
619        assert_eq!(back.tool_uses[0].name, "Read");
620        assert!(back.tool_uses[0].result.is_some());
621    }
622
623    #[test]
624    fn test_conversation_view_serde_roundtrip() {
625        let view = sample_view();
626        let json = serde_json::to_string(&view).unwrap();
627        let back: ConversationView = serde_json::from_str(&json).unwrap();
628        assert_eq!(back.id, "sess-1");
629        assert_eq!(back.turns.len(), 3);
630    }
631
632    #[test]
633    fn test_watcher_event_variants() {
634        let turn_event = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
635        assert!(matches!(turn_event, WatcherEvent::Turn(_)));
636
637        let updated_event = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[1].clone()));
638        assert!(matches!(updated_event, WatcherEvent::TurnUpdated(_)));
639
640        let progress_event = WatcherEvent::Progress {
641            kind: "agent_progress".into(),
642            data: serde_json::json!({"status": "running"}),
643        };
644        assert!(matches!(progress_event, WatcherEvent::Progress { .. }));
645    }
646
647    #[test]
648    fn test_watcher_event_as_turn() {
649        let turn = sample_view().turns[0].clone();
650        let event = WatcherEvent::Turn(Box::new(turn.clone()));
651        assert_eq!(event.as_turn().unwrap().id, "t1");
652
653        let updated = WatcherEvent::TurnUpdated(Box::new(turn));
654        assert_eq!(updated.as_turn().unwrap().id, "t1");
655
656        let progress = WatcherEvent::Progress {
657            kind: "test".into(),
658            data: serde_json::Value::Null,
659        };
660        assert!(progress.as_turn().is_none());
661    }
662
663    #[test]
664    fn test_watcher_event_as_progress() {
665        let progress = WatcherEvent::Progress {
666            kind: "hook_progress".into(),
667            data: serde_json::json!({"hookName": "pre-commit"}),
668        };
669        let (kind, data) = progress.as_progress().unwrap();
670        assert_eq!(kind, "hook_progress");
671        assert_eq!(data["hookName"], "pre-commit");
672
673        let turn = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
674        assert!(turn.as_progress().is_none());
675    }
676
677    #[test]
678    fn test_watcher_event_is_update() {
679        let turn = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
680        assert!(!turn.is_update());
681
682        let updated = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[0].clone()));
683        assert!(updated.is_update());
684
685        let progress = WatcherEvent::Progress {
686            kind: "test".into(),
687            data: serde_json::Value::Null,
688        };
689        assert!(!progress.is_update());
690    }
691
692    #[test]
693    fn test_watcher_event_turn_id() {
694        let turn = WatcherEvent::Turn(Box::new(sample_view().turns[1].clone()));
695        assert_eq!(turn.turn_id(), Some("t2"));
696
697        let updated = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[0].clone()));
698        assert_eq!(updated.turn_id(), Some("t1"));
699
700        let progress = WatcherEvent::Progress {
701            kind: "test".into(),
702            data: serde_json::Value::Null,
703        };
704        assert!(progress.turn_id().is_none());
705    }
706
707    #[test]
708    fn test_token_usage_default() {
709        let usage = TokenUsage::default();
710        assert!(usage.input_tokens.is_none());
711        assert!(usage.output_tokens.is_none());
712        assert!(usage.cache_read_tokens.is_none());
713        assert!(usage.cache_write_tokens.is_none());
714    }
715
716    #[test]
717    fn test_token_usage_cache_fields_serde() {
718        let usage = TokenUsage {
719            input_tokens: Some(100),
720            output_tokens: Some(50),
721            cache_read_tokens: Some(500),
722            cache_write_tokens: Some(200),
723        };
724        let json = serde_json::to_string(&usage).unwrap();
725        let back: TokenUsage = serde_json::from_str(&json).unwrap();
726        assert_eq!(back.cache_read_tokens, Some(500));
727        assert_eq!(back.cache_write_tokens, Some(200));
728    }
729
730    #[test]
731    fn test_token_usage_cache_fields_omitted() {
732        // Old-format JSON without cache fields should deserialize with None
733        let json = r#"{"input_tokens":100,"output_tokens":50}"#;
734        let usage: TokenUsage = serde_json::from_str(json).unwrap();
735        assert_eq!(usage.input_tokens, Some(100));
736        assert!(usage.cache_read_tokens.is_none());
737        assert!(usage.cache_write_tokens.is_none());
738    }
739
740    #[test]
741    fn test_environment_snapshot_serde() {
742        let env = EnvironmentSnapshot {
743            working_dir: Some("/home/user/project".into()),
744            vcs_branch: Some("main".into()),
745            vcs_revision: Some("abc123".into()),
746        };
747        let json = serde_json::to_string(&env).unwrap();
748        let back: EnvironmentSnapshot = serde_json::from_str(&json).unwrap();
749        assert_eq!(back.working_dir.as_deref(), Some("/home/user/project"));
750        assert_eq!(back.vcs_branch.as_deref(), Some("main"));
751        assert_eq!(back.vcs_revision.as_deref(), Some("abc123"));
752    }
753
754    #[test]
755    fn test_environment_snapshot_default() {
756        let env = EnvironmentSnapshot::default();
757        assert!(env.working_dir.is_none());
758        assert!(env.vcs_branch.is_none());
759        assert!(env.vcs_revision.is_none());
760    }
761
762    #[test]
763    fn test_environment_snapshot_skip_none_fields() {
764        let env = EnvironmentSnapshot {
765            working_dir: Some("/tmp".into()),
766            vcs_branch: None,
767            vcs_revision: None,
768        };
769        let json = serde_json::to_string(&env).unwrap();
770        assert!(!json.contains("vcs_branch"));
771        assert!(!json.contains("vcs_revision"));
772    }
773
774    #[test]
775    fn test_delegated_work_serde() {
776        let dw = DelegatedWork {
777            agent_id: "agent-123".into(),
778            prompt: "Search for the bug".into(),
779            turns: vec![],
780            result: Some("Found the bug in auth.rs".into()),
781        };
782        let json = serde_json::to_string(&dw).unwrap();
783        assert!(!json.contains("turns")); // empty vec skipped
784        let back: DelegatedWork = serde_json::from_str(&json).unwrap();
785        assert_eq!(back.agent_id, "agent-123");
786        assert_eq!(back.result.as_deref(), Some("Found the bug in auth.rs"));
787        assert!(back.turns.is_empty());
788    }
789
790    #[test]
791    fn test_tool_category_serde() {
792        let ti = ToolInvocation {
793            id: "t1".into(),
794            name: "Bash".into(),
795            input: serde_json::json!({"command": "ls"}),
796            result: None,
797            category: Some(ToolCategory::Shell),
798        };
799        let json = serde_json::to_string(&ti).unwrap();
800        assert!(json.contains("\"shell\""));
801        let back: ToolInvocation = serde_json::from_str(&json).unwrap();
802        assert_eq!(back.category, Some(ToolCategory::Shell));
803    }
804
805    #[test]
806    fn test_tool_category_none_skipped() {
807        let ti = ToolInvocation {
808            id: "t1".into(),
809            name: "CustomTool".into(),
810            input: serde_json::json!({}),
811            result: None,
812            category: None,
813        };
814        let json = serde_json::to_string(&ti).unwrap();
815        assert!(!json.contains("category"));
816    }
817
818    #[test]
819    fn test_tool_category_missing_defaults_none() {
820        // Old-format JSON without category should deserialize as None
821        let json = r#"{"id":"t1","name":"Read","input":{},"result":null}"#;
822        let ti: ToolInvocation = serde_json::from_str(json).unwrap();
823        assert!(ti.category.is_none());
824    }
825
826    #[test]
827    fn test_tool_category_all_variants_roundtrip() {
828        let variants = vec![
829            ToolCategory::FileRead,
830            ToolCategory::FileWrite,
831            ToolCategory::FileSearch,
832            ToolCategory::Shell,
833            ToolCategory::Network,
834            ToolCategory::Delegation,
835        ];
836        for cat in variants {
837            let json = serde_json::to_value(cat).unwrap();
838            let back: ToolCategory = serde_json::from_value(json).unwrap();
839            assert_eq!(back, cat);
840        }
841    }
842
843    #[test]
844    fn test_turn_with_environment_and_delegations() {
845        let turn = Turn {
846            id: "t1".into(),
847            parent_id: None,
848            role: Role::Assistant,
849            timestamp: "2026-01-01T00:00:00Z".into(),
850            text: "Delegating...".into(),
851            thinking: None,
852            tool_uses: vec![],
853            model: None,
854            stop_reason: None,
855            token_usage: None,
856            environment: Some(EnvironmentSnapshot {
857                working_dir: Some("/project".into()),
858                vcs_branch: Some("feat/auth".into()),
859                vcs_revision: None,
860            }),
861            delegations: vec![DelegatedWork {
862                agent_id: "sub-1".into(),
863                prompt: "Find the bug".into(),
864                turns: vec![],
865                result: None,
866            }],
867            extra: HashMap::new(),
868        };
869        let json = serde_json::to_string(&turn).unwrap();
870        let back: Turn = serde_json::from_str(&json).unwrap();
871        assert_eq!(
872            back.environment.as_ref().unwrap().vcs_branch.as_deref(),
873            Some("feat/auth")
874        );
875        assert_eq!(back.delegations.len(), 1);
876        assert_eq!(back.delegations[0].agent_id, "sub-1");
877    }
878
879    #[test]
880    fn test_turn_without_new_fields_deserializes() {
881        // Old-format Turn JSON without environment/delegations
882        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}"#;
883        let turn: Turn = serde_json::from_str(json).unwrap();
884        assert!(turn.environment.is_none());
885        assert!(turn.delegations.is_empty());
886    }
887
888    #[test]
889    fn test_conversation_view_new_fields_serde() {
890        let view = ConversationView {
891            id: "s1".into(),
892            started_at: None,
893            last_activity: None,
894            turns: vec![],
895            total_usage: Some(TokenUsage {
896                input_tokens: Some(1000),
897                output_tokens: Some(500),
898                cache_read_tokens: Some(800),
899                cache_write_tokens: None,
900            }),
901            provider_id: Some("claude-code".into()),
902            files_changed: vec!["src/main.rs".into(), "src/lib.rs".into()],
903            session_ids: vec![],
904            events: vec![],
905        };
906        let json = serde_json::to_string(&view).unwrap();
907        let back: ConversationView = serde_json::from_str(&json).unwrap();
908        assert_eq!(back.provider_id.as_deref(), Some("claude-code"));
909        assert_eq!(back.files_changed, vec!["src/main.rs", "src/lib.rs"]);
910        assert_eq!(back.total_usage.as_ref().unwrap().input_tokens, Some(1000));
911        assert_eq!(
912            back.total_usage.as_ref().unwrap().cache_read_tokens,
913            Some(800)
914        );
915    }
916
917    #[test]
918    fn test_conversation_view_old_format_deserializes() {
919        // Old-format JSON without total_usage/provider_id/files_changed
920        let json = r#"{"id":"s1","started_at":null,"last_activity":null,"turns":[]}"#;
921        let view: ConversationView = serde_json::from_str(json).unwrap();
922        assert!(view.total_usage.is_none());
923        assert!(view.provider_id.is_none());
924        assert!(view.files_changed.is_empty());
925    }
926
927    #[test]
928    fn test_conversation_meta() {
929        let meta = ConversationMeta {
930            id: "sess-1".into(),
931            started_at: None,
932            last_activity: None,
933            message_count: 5,
934            file_path: Some("/tmp/test.jsonl".into()),
935            predecessor: None,
936            successor: None,
937        };
938        let json = serde_json::to_string(&meta).unwrap();
939        let back: ConversationMeta = serde_json::from_str(&json).unwrap();
940        assert_eq!(back.message_count, 5);
941    }
942
943    #[test]
944    fn test_conversation_event_serde_roundtrip() {
945        let event = ConversationEvent {
946            id: "evt-1".into(),
947            timestamp: "2026-01-01T00:00:00Z".into(),
948            parent_id: Some("t1".into()),
949            event_type: "attachment".into(),
950            data: {
951                let mut m = HashMap::new();
952                m.insert("cwd".into(), serde_json::json!("/project"));
953                m.insert("version".into(), serde_json::json!("1.0"));
954                m
955            },
956        };
957        let json = serde_json::to_string(&event).unwrap();
958        let back: ConversationEvent = serde_json::from_str(&json).unwrap();
959        assert_eq!(back.id, "evt-1");
960        assert_eq!(back.event_type, "attachment");
961        assert_eq!(back.parent_id.as_deref(), Some("t1"));
962        assert_eq!(back.data["cwd"], serde_json::json!("/project"));
963    }
964
965    #[test]
966    fn test_conversation_event_empty_data_omitted() {
967        let event = ConversationEvent {
968            id: "evt-2".into(),
969            timestamp: "2026-01-01T00:00:00Z".into(),
970            parent_id: None,
971            event_type: "system".into(),
972            data: HashMap::new(),
973        };
974        let json = serde_json::to_string(&event).unwrap();
975        assert!(!json.contains("data"));
976        assert!(!json.contains("parent_id"));
977    }
978
979    #[test]
980    fn test_conversation_view_with_events_serde() {
981        let view = ConversationView {
982            id: "s1".into(),
983            started_at: None,
984            last_activity: None,
985            turns: vec![],
986            total_usage: None,
987            provider_id: None,
988            files_changed: vec![],
989            session_ids: vec![],
990            events: vec![ConversationEvent {
991                id: "evt-1".into(),
992                timestamp: "2026-01-01T00:00:00Z".into(),
993                parent_id: None,
994                event_type: "attachment".into(),
995                data: HashMap::new(),
996            }],
997        };
998        let json = serde_json::to_string(&view).unwrap();
999        assert!(json.contains("events"));
1000        let back: ConversationView = serde_json::from_str(&json).unwrap();
1001        assert_eq!(back.events.len(), 1);
1002        assert_eq!(back.events[0].event_type, "attachment");
1003    }
1004
1005    #[test]
1006    fn test_conversation_view_empty_events_omitted() {
1007        let view = ConversationView {
1008            id: "s1".into(),
1009            started_at: None,
1010            last_activity: None,
1011            turns: vec![],
1012            total_usage: None,
1013            provider_id: None,
1014            files_changed: vec![],
1015            session_ids: vec![],
1016            events: vec![],
1017        };
1018        let json = serde_json::to_string(&view).unwrap();
1019        assert!(!json.contains("events"));
1020    }
1021
1022    #[test]
1023    fn test_conversation_view_old_format_no_events() {
1024        // Old-format JSON without events field should deserialize with empty vec
1025        let json = r#"{"id":"s1","started_at":null,"last_activity":null,"turns":[]}"#;
1026        let view: ConversationView = serde_json::from_str(json).unwrap();
1027        assert!(view.events.is_empty());
1028    }
1029}