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