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 complete conversational turn.
180    Turn(Box<Turn>),
181
182    /// A non-conversational progress/status event.
183    Progress {
184        kind: String,
185        data: serde_json::Value,
186    },
187}
188
189// ── Traits ───────────────────────────────────────────────────────────
190
191/// Trait for converting provider-specific conversation data into the
192/// generic [`ConversationView`].
193///
194/// Implement this on your provider's manager type (e.g. `ClaudeConvo`).
195pub trait ConversationProvider {
196    /// List conversation IDs for a project/workspace.
197    fn list_conversations(&self, project: &str) -> Result<Vec<String>>;
198
199    /// Load a full conversation as a [`ConversationView`].
200    fn load_conversation(&self, project: &str, conversation_id: &str) -> Result<ConversationView>;
201
202    /// Load metadata only (no turns).
203    fn load_metadata(&self, project: &str, conversation_id: &str) -> Result<ConversationMeta>;
204
205    /// List metadata for all conversations in a project.
206    fn list_metadata(&self, project: &str) -> Result<Vec<ConversationMeta>>;
207}
208
209/// Trait for polling conversation updates from any provider.
210pub trait ConversationWatcher {
211    /// Poll for new events since the last poll.
212    fn poll(&mut self) -> Result<Vec<WatcherEvent>>;
213
214    /// Number of turns seen so far.
215    fn seen_count(&self) -> usize;
216}
217
218// ── Tests ────────────────────────────────────────────────────────────
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    fn sample_view() -> ConversationView {
225        ConversationView {
226            id: "sess-1".into(),
227            started_at: None,
228            last_activity: None,
229            turns: vec![
230                Turn {
231                    id: "t1".into(),
232                    parent_id: None,
233                    role: Role::User,
234                    timestamp: "2026-01-01T00:00:00Z".into(),
235                    text: "Fix the authentication bug in login.rs".into(),
236                    thinking: None,
237                    tool_uses: vec![],
238                    model: None,
239                    stop_reason: None,
240                    token_usage: None,
241                    extra: HashMap::new(),
242                },
243                Turn {
244                    id: "t2".into(),
245                    parent_id: Some("t1".into()),
246                    role: Role::Assistant,
247                    timestamp: "2026-01-01T00:00:01Z".into(),
248                    text: "I'll fix that for you.".into(),
249                    thinking: Some("The bug is in the token validation".into()),
250                    tool_uses: vec![ToolInvocation {
251                        id: "tool-1".into(),
252                        name: "Read".into(),
253                        input: serde_json::json!({"file": "src/login.rs"}),
254                        result: Some(ToolResult {
255                            content: "fn login() { ... }".into(),
256                            is_error: false,
257                        }),
258                    }],
259                    model: Some("claude-opus-4-6".into()),
260                    stop_reason: Some("end_turn".into()),
261                    token_usage: Some(TokenUsage {
262                        input_tokens: Some(100),
263                        output_tokens: Some(50),
264                    }),
265                    extra: HashMap::new(),
266                },
267                Turn {
268                    id: "t3".into(),
269                    parent_id: Some("t2".into()),
270                    role: Role::User,
271                    timestamp: "2026-01-01T00:00:02Z".into(),
272                    text: "Thanks!".into(),
273                    thinking: None,
274                    tool_uses: vec![],
275                    model: None,
276                    stop_reason: None,
277                    token_usage: None,
278                    extra: HashMap::new(),
279                },
280            ],
281        }
282    }
283
284    #[test]
285    fn test_title_short() {
286        let view = sample_view();
287        let title = view.title(100).unwrap();
288        assert_eq!(title, "Fix the authentication bug in login.rs");
289    }
290
291    #[test]
292    fn test_title_truncated() {
293        let view = sample_view();
294        let title = view.title(10).unwrap();
295        assert_eq!(title, "Fix the au...");
296    }
297
298    #[test]
299    fn test_title_empty() {
300        let view = ConversationView {
301            id: "empty".into(),
302            started_at: None,
303            last_activity: None,
304            turns: vec![],
305        };
306        assert!(view.title(50).is_none());
307    }
308
309    #[test]
310    fn test_turns_by_role() {
311        let view = sample_view();
312        let users = view.turns_by_role(&Role::User);
313        assert_eq!(users.len(), 2);
314        let assistants = view.turns_by_role(&Role::Assistant);
315        assert_eq!(assistants.len(), 1);
316    }
317
318    #[test]
319    fn test_turns_since_middle() {
320        let view = sample_view();
321        let since = view.turns_since("t1");
322        assert_eq!(since.len(), 2);
323        assert_eq!(since[0].id, "t2");
324    }
325
326    #[test]
327    fn test_turns_since_last() {
328        let view = sample_view();
329        let since = view.turns_since("t3");
330        assert!(since.is_empty());
331    }
332
333    #[test]
334    fn test_turns_since_unknown() {
335        let view = sample_view();
336        let since = view.turns_since("nonexistent");
337        assert_eq!(since.len(), 3);
338    }
339
340    #[test]
341    fn test_role_display() {
342        assert_eq!(Role::User.to_string(), "user");
343        assert_eq!(Role::Assistant.to_string(), "assistant");
344        assert_eq!(Role::System.to_string(), "system");
345        assert_eq!(Role::Other("tool".into()).to_string(), "tool");
346    }
347
348    #[test]
349    fn test_role_equality() {
350        assert_eq!(Role::User, Role::User);
351        assert_ne!(Role::User, Role::Assistant);
352        assert_eq!(Role::Other("x".into()), Role::Other("x".into()));
353        assert_ne!(Role::Other("x".into()), Role::Other("y".into()));
354    }
355
356    #[test]
357    fn test_turn_serde_roundtrip() {
358        let turn = &sample_view().turns[1];
359        let json = serde_json::to_string(turn).unwrap();
360        let back: Turn = serde_json::from_str(&json).unwrap();
361        assert_eq!(back.id, "t2");
362        assert_eq!(back.model, Some("claude-opus-4-6".into()));
363        assert_eq!(back.tool_uses.len(), 1);
364        assert_eq!(back.tool_uses[0].name, "Read");
365        assert!(back.tool_uses[0].result.is_some());
366    }
367
368    #[test]
369    fn test_conversation_view_serde_roundtrip() {
370        let view = sample_view();
371        let json = serde_json::to_string(&view).unwrap();
372        let back: ConversationView = serde_json::from_str(&json).unwrap();
373        assert_eq!(back.id, "sess-1");
374        assert_eq!(back.turns.len(), 3);
375    }
376
377    #[test]
378    fn test_watcher_event_variants() {
379        let turn_event = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
380        assert!(matches!(turn_event, WatcherEvent::Turn(_)));
381
382        let progress_event = WatcherEvent::Progress {
383            kind: "agent_progress".into(),
384            data: serde_json::json!({"status": "running"}),
385        };
386        assert!(matches!(progress_event, WatcherEvent::Progress { .. }));
387    }
388
389    #[test]
390    fn test_token_usage_default() {
391        let usage = TokenUsage::default();
392        assert!(usage.input_tokens.is_none());
393        assert!(usage.output_tokens.is_none());
394    }
395
396    #[test]
397    fn test_conversation_meta() {
398        let meta = ConversationMeta {
399            id: "sess-1".into(),
400            started_at: None,
401            last_activity: None,
402            message_count: 5,
403            file_path: Some("/tmp/test.jsonl".into()),
404        };
405        let json = serde_json::to_string(&meta).unwrap();
406        let back: ConversationMeta = serde_json::from_str(&json).unwrap();
407        assert_eq!(back.message_count, 5);
408    }
409}