Skip to main content

toolpath_convo/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::PathBuf;
7
8// ── Error ────────────────────────────────────────────────────────────
9
10/// Errors from conversation provider operations.
11#[derive(Debug, thiserror::Error)]
12pub enum ConvoError {
13    #[error("I/O error: {0}")]
14    Io(#[from] std::io::Error),
15
16    #[error("JSON error: {0}")]
17    Json(#[from] serde_json::Error),
18
19    #[error("provider error: {0}")]
20    Provider(String),
21
22    #[error("{0}")]
23    Other(#[from] Box<dyn std::error::Error + Send + Sync>),
24}
25
26pub type Result<T> = std::result::Result<T, ConvoError>;
27
28// ── Core types ───────────────────────────────────────────────────────
29
30/// Who produced a turn.
31#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
32pub enum Role {
33    User,
34    Assistant,
35    System,
36    /// Provider-specific roles (e.g. "tool", "function").
37    Other(String),
38}
39
40impl std::fmt::Display for Role {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            Role::User => write!(f, "user"),
44            Role::Assistant => write!(f, "assistant"),
45            Role::System => write!(f, "system"),
46            Role::Other(s) => write!(f, "{}", s),
47        }
48    }
49}
50
51/// Token usage for a single turn.
52#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53pub struct TokenUsage {
54    pub input_tokens: Option<u32>,
55    pub output_tokens: Option<u32>,
56}
57
58/// A tool invocation within a turn.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ToolInvocation {
61    pub id: String,
62    pub name: String,
63    pub input: serde_json::Value,
64    /// Populated when the result is available in the same turn.
65    pub result: Option<ToolResult>,
66}
67
68/// The result of a tool invocation.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ToolResult {
71    pub content: String,
72    pub is_error: bool,
73}
74
75/// A single turn in a conversation, from any provider.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct Turn {
78    /// Unique identifier within the conversation.
79    pub id: String,
80
81    /// Parent turn ID (for branching conversations).
82    pub parent_id: Option<String>,
83
84    /// Who produced this turn.
85    pub role: Role,
86
87    /// When this turn occurred (ISO 8601).
88    pub timestamp: String,
89
90    /// The visible text content (already collapsed from provider-specific formats).
91    pub text: String,
92
93    /// Internal reasoning (chain-of-thought, thinking blocks).
94    pub thinking: Option<String>,
95
96    /// Tool invocations in this turn.
97    pub tool_uses: Vec<ToolInvocation>,
98
99    /// Model identifier (e.g. "claude-opus-4-6", "gpt-4o").
100    pub model: Option<String>,
101
102    /// Why the turn ended (e.g. "end_turn", "tool_use", "max_tokens").
103    pub stop_reason: Option<String>,
104
105    /// Token usage for this turn.
106    pub token_usage: Option<TokenUsage>,
107
108    /// Provider-specific data that doesn't fit the common schema.
109    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
110    pub extra: HashMap<String, serde_json::Value>,
111}
112
113/// A complete conversation from any provider.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct ConversationView {
116    /// Unique session/conversation identifier.
117    pub id: String,
118
119    /// When the conversation started.
120    pub started_at: Option<DateTime<Utc>>,
121
122    /// When the conversation was last active.
123    pub last_activity: Option<DateTime<Utc>>,
124
125    /// Ordered turns.
126    pub turns: Vec<Turn>,
127}
128
129impl ConversationView {
130    /// Title derived from the first user turn, truncated to `max_len` characters.
131    pub fn title(&self, max_len: usize) -> Option<String> {
132        let text = self
133            .turns
134            .iter()
135            .find(|t| t.role == Role::User && !t.text.is_empty())
136            .map(|t| &t.text)?;
137
138        if text.chars().count() > max_len {
139            let truncated: String = text.chars().take(max_len).collect();
140            Some(format!("{}...", truncated))
141        } else {
142            Some(text.clone())
143        }
144    }
145
146    /// All turns with the given role.
147    pub fn turns_by_role(&self, role: &Role) -> Vec<&Turn> {
148        self.turns.iter().filter(|t| &t.role == role).collect()
149    }
150
151    /// Turns added after the turn with the given ID.
152    ///
153    /// If the ID is not found, returns all turns. If the ID is the last
154    /// turn, returns an empty slice.
155    pub fn turns_since(&self, turn_id: &str) -> &[Turn] {
156        match self.turns.iter().position(|t| t.id == turn_id) {
157            Some(idx) if idx + 1 < self.turns.len() => &self.turns[idx + 1..],
158            Some(_) => &[],
159            None => &self.turns,
160        }
161    }
162}
163
164/// Lightweight metadata for a conversation (no turns loaded).
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct ConversationMeta {
167    pub id: String,
168    pub started_at: Option<DateTime<Utc>>,
169    pub last_activity: Option<DateTime<Utc>>,
170    pub message_count: usize,
171    pub file_path: Option<PathBuf>,
172}
173
174// ── Events ───────────────────────────────────────────────────────────
175
176/// Events emitted by a [`ConversationWatcher`].
177#[derive(Debug, Clone)]
178pub enum WatcherEvent {
179    /// A turn seen for the first time.
180    Turn(Box<Turn>),
181
182    /// A previously-emitted turn with additional data filled in
183    /// (e.g. tool results that arrived in a later log entry).
184    ///
185    /// Consumers should replace their stored copy of the turn with this
186    /// updated version. The turn's `id` field identifies which turn to replace.
187    TurnUpdated(Box<Turn>),
188
189    /// A non-conversational progress/status event.
190    Progress {
191        kind: String,
192        data: serde_json::Value,
193    },
194}
195
196// ── Traits ───────────────────────────────────────────────────────────
197
198/// Trait for converting provider-specific conversation data into the
199/// generic [`ConversationView`].
200///
201/// Implement this on your provider's manager type (e.g. `ClaudeConvo`).
202pub trait ConversationProvider {
203    /// List conversation IDs for a project/workspace.
204    fn list_conversations(&self, project: &str) -> Result<Vec<String>>;
205
206    /// Load a full conversation as a [`ConversationView`].
207    fn load_conversation(&self, project: &str, conversation_id: &str) -> Result<ConversationView>;
208
209    /// Load metadata only (no turns).
210    fn load_metadata(&self, project: &str, conversation_id: &str) -> Result<ConversationMeta>;
211
212    /// List metadata for all conversations in a project.
213    fn list_metadata(&self, project: &str) -> Result<Vec<ConversationMeta>>;
214}
215
216/// Trait for polling conversation updates from any provider.
217pub trait ConversationWatcher {
218    /// Poll for new events since the last poll.
219    fn poll(&mut self) -> Result<Vec<WatcherEvent>>;
220
221    /// Number of turns seen so far.
222    fn seen_count(&self) -> usize;
223}
224
225// ── Tests ────────────────────────────────────────────────────────────
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    fn sample_view() -> ConversationView {
232        ConversationView {
233            id: "sess-1".into(),
234            started_at: None,
235            last_activity: None,
236            turns: vec![
237                Turn {
238                    id: "t1".into(),
239                    parent_id: None,
240                    role: Role::User,
241                    timestamp: "2026-01-01T00:00:00Z".into(),
242                    text: "Fix the authentication bug in login.rs".into(),
243                    thinking: None,
244                    tool_uses: vec![],
245                    model: None,
246                    stop_reason: None,
247                    token_usage: None,
248                    extra: HashMap::new(),
249                },
250                Turn {
251                    id: "t2".into(),
252                    parent_id: Some("t1".into()),
253                    role: Role::Assistant,
254                    timestamp: "2026-01-01T00:00:01Z".into(),
255                    text: "I'll fix that for you.".into(),
256                    thinking: Some("The bug is in the token validation".into()),
257                    tool_uses: vec![ToolInvocation {
258                        id: "tool-1".into(),
259                        name: "Read".into(),
260                        input: serde_json::json!({"file": "src/login.rs"}),
261                        result: Some(ToolResult {
262                            content: "fn login() { ... }".into(),
263                            is_error: false,
264                        }),
265                    }],
266                    model: Some("claude-opus-4-6".into()),
267                    stop_reason: Some("end_turn".into()),
268                    token_usage: Some(TokenUsage {
269                        input_tokens: Some(100),
270                        output_tokens: Some(50),
271                    }),
272                    extra: HashMap::new(),
273                },
274                Turn {
275                    id: "t3".into(),
276                    parent_id: Some("t2".into()),
277                    role: Role::User,
278                    timestamp: "2026-01-01T00:00:02Z".into(),
279                    text: "Thanks!".into(),
280                    thinking: None,
281                    tool_uses: vec![],
282                    model: None,
283                    stop_reason: None,
284                    token_usage: None,
285                    extra: HashMap::new(),
286                },
287            ],
288        }
289    }
290
291    #[test]
292    fn test_title_short() {
293        let view = sample_view();
294        let title = view.title(100).unwrap();
295        assert_eq!(title, "Fix the authentication bug in login.rs");
296    }
297
298    #[test]
299    fn test_title_truncated() {
300        let view = sample_view();
301        let title = view.title(10).unwrap();
302        assert_eq!(title, "Fix the au...");
303    }
304
305    #[test]
306    fn test_title_empty() {
307        let view = ConversationView {
308            id: "empty".into(),
309            started_at: None,
310            last_activity: None,
311            turns: vec![],
312        };
313        assert!(view.title(50).is_none());
314    }
315
316    #[test]
317    fn test_turns_by_role() {
318        let view = sample_view();
319        let users = view.turns_by_role(&Role::User);
320        assert_eq!(users.len(), 2);
321        let assistants = view.turns_by_role(&Role::Assistant);
322        assert_eq!(assistants.len(), 1);
323    }
324
325    #[test]
326    fn test_turns_since_middle() {
327        let view = sample_view();
328        let since = view.turns_since("t1");
329        assert_eq!(since.len(), 2);
330        assert_eq!(since[0].id, "t2");
331    }
332
333    #[test]
334    fn test_turns_since_last() {
335        let view = sample_view();
336        let since = view.turns_since("t3");
337        assert!(since.is_empty());
338    }
339
340    #[test]
341    fn test_turns_since_unknown() {
342        let view = sample_view();
343        let since = view.turns_since("nonexistent");
344        assert_eq!(since.len(), 3);
345    }
346
347    #[test]
348    fn test_role_display() {
349        assert_eq!(Role::User.to_string(), "user");
350        assert_eq!(Role::Assistant.to_string(), "assistant");
351        assert_eq!(Role::System.to_string(), "system");
352        assert_eq!(Role::Other("tool".into()).to_string(), "tool");
353    }
354
355    #[test]
356    fn test_role_equality() {
357        assert_eq!(Role::User, Role::User);
358        assert_ne!(Role::User, Role::Assistant);
359        assert_eq!(Role::Other("x".into()), Role::Other("x".into()));
360        assert_ne!(Role::Other("x".into()), Role::Other("y".into()));
361    }
362
363    #[test]
364    fn test_turn_serde_roundtrip() {
365        let turn = &sample_view().turns[1];
366        let json = serde_json::to_string(turn).unwrap();
367        let back: Turn = serde_json::from_str(&json).unwrap();
368        assert_eq!(back.id, "t2");
369        assert_eq!(back.model, Some("claude-opus-4-6".into()));
370        assert_eq!(back.tool_uses.len(), 1);
371        assert_eq!(back.tool_uses[0].name, "Read");
372        assert!(back.tool_uses[0].result.is_some());
373    }
374
375    #[test]
376    fn test_conversation_view_serde_roundtrip() {
377        let view = sample_view();
378        let json = serde_json::to_string(&view).unwrap();
379        let back: ConversationView = serde_json::from_str(&json).unwrap();
380        assert_eq!(back.id, "sess-1");
381        assert_eq!(back.turns.len(), 3);
382    }
383
384    #[test]
385    fn test_watcher_event_variants() {
386        let turn_event = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
387        assert!(matches!(turn_event, WatcherEvent::Turn(_)));
388
389        let updated_event = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[1].clone()));
390        assert!(matches!(updated_event, WatcherEvent::TurnUpdated(_)));
391
392        let progress_event = WatcherEvent::Progress {
393            kind: "agent_progress".into(),
394            data: serde_json::json!({"status": "running"}),
395        };
396        assert!(matches!(progress_event, WatcherEvent::Progress { .. }));
397    }
398
399    #[test]
400    fn test_token_usage_default() {
401        let usage = TokenUsage::default();
402        assert!(usage.input_tokens.is_none());
403        assert!(usage.output_tokens.is_none());
404    }
405
406    #[test]
407    fn test_conversation_meta() {
408        let meta = ConversationMeta {
409            id: "sess-1".into(),
410            started_at: None,
411            last_activity: None,
412            message_count: 5,
413            file_path: Some("/tmp/test.jsonl".into()),
414        };
415        let json = serde_json::to_string(&meta).unwrap();
416        let back: ConversationMeta = serde_json::from_str(&json).unwrap();
417        assert_eq!(back.message_count, 5);
418    }
419}