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