1use crate::ClaudeConvo;
4use crate::types::{
5 ContentPart, Conversation, ConversationEntry, Message, MessageContent, MessageRole,
6};
7use toolpath_convo::{
8 ConversationMeta, ConversationProvider, ConversationView, ConvoError, Role, TokenUsage,
9 ToolInvocation, ToolResult, Turn, WatcherEvent,
10};
11
12fn claude_role_to_role(role: &MessageRole) -> Role {
15 match role {
16 MessageRole::User => Role::User,
17 MessageRole::Assistant => Role::Assistant,
18 MessageRole::System => Role::System,
19 }
20}
21
22fn message_to_turn(entry: &ConversationEntry, msg: &Message) -> Turn {
23 let text = msg.text();
24
25 let thinking = msg.thinking().map(|parts| parts.join("\n"));
26
27 let tool_uses = msg
28 .tool_uses()
29 .into_iter()
30 .map(|tu| {
31 let result = find_tool_result_in_parts(msg, tu.id);
32 ToolInvocation {
33 id: tu.id.to_string(),
34 name: tu.name.to_string(),
35 input: tu.input.clone(),
36 result,
37 }
38 })
39 .collect();
40
41 let token_usage = msg.usage.as_ref().map(|u| TokenUsage {
42 input_tokens: u.input_tokens,
43 output_tokens: u.output_tokens,
44 });
45
46 Turn {
47 id: entry.uuid.clone(),
48 parent_id: entry.parent_uuid.clone(),
49 role: claude_role_to_role(&msg.role),
50 timestamp: entry.timestamp.clone(),
51 text,
52 thinking,
53 tool_uses,
54 model: msg.model.clone(),
55 stop_reason: msg.stop_reason.clone(),
56 token_usage,
57 extra: Default::default(),
58 }
59}
60
61fn find_tool_result_in_parts(msg: &Message, tool_use_id: &str) -> Option<ToolResult> {
62 let parts = match &msg.content {
63 Some(MessageContent::Parts(parts)) => parts,
64 _ => return None,
65 };
66 parts.iter().find_map(|p| match p {
67 ContentPart::ToolResult {
68 tool_use_id: id,
69 content,
70 is_error,
71 } if id == tool_use_id => Some(ToolResult {
72 content: content.text(),
73 is_error: *is_error,
74 }),
75 _ => None,
76 })
77}
78
79fn entry_to_turn(entry: &ConversationEntry) -> Option<Turn> {
80 entry
81 .message
82 .as_ref()
83 .map(|msg| message_to_turn(entry, msg))
84}
85
86fn conversation_to_view(convo: &Conversation) -> ConversationView {
87 let turns = convo.entries.iter().filter_map(entry_to_turn).collect();
88
89 ConversationView {
90 id: convo.session_id.clone(),
91 started_at: convo.started_at,
92 last_activity: convo.last_activity,
93 turns,
94 }
95}
96
97fn entry_to_watcher_event(entry: &ConversationEntry) -> WatcherEvent {
98 match entry_to_turn(entry) {
99 Some(turn) => WatcherEvent::Turn(Box::new(turn)),
100 None => WatcherEvent::Progress {
101 kind: entry.entry_type.clone(),
102 data: serde_json::json!({
103 "uuid": entry.uuid,
104 "timestamp": entry.timestamp,
105 }),
106 },
107 }
108}
109
110impl ConversationProvider for ClaudeConvo {
113 fn list_conversations(&self, project: &str) -> toolpath_convo::Result<Vec<String>> {
114 crate::ClaudeConvo::list_conversations(self, project)
115 .map_err(|e| ConvoError::Provider(e.to_string()))
116 }
117
118 fn load_conversation(
119 &self,
120 project: &str,
121 conversation_id: &str,
122 ) -> toolpath_convo::Result<ConversationView> {
123 let convo = self
124 .read_conversation(project, conversation_id)
125 .map_err(|e| ConvoError::Provider(e.to_string()))?;
126 Ok(conversation_to_view(&convo))
127 }
128
129 fn load_metadata(
130 &self,
131 project: &str,
132 conversation_id: &str,
133 ) -> toolpath_convo::Result<ConversationMeta> {
134 let meta = self
135 .read_conversation_metadata(project, conversation_id)
136 .map_err(|e| ConvoError::Provider(e.to_string()))?;
137 Ok(ConversationMeta {
138 id: meta.session_id,
139 started_at: meta.started_at,
140 last_activity: meta.last_activity,
141 message_count: meta.message_count,
142 file_path: Some(meta.file_path),
143 })
144 }
145
146 fn list_metadata(&self, project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
147 let metas = self
148 .list_conversation_metadata(project)
149 .map_err(|e| ConvoError::Provider(e.to_string()))?;
150 Ok(metas
151 .into_iter()
152 .map(|m| ConversationMeta {
153 id: m.session_id,
154 started_at: m.started_at,
155 last_activity: m.last_activity,
156 message_count: m.message_count,
157 file_path: Some(m.file_path),
158 })
159 .collect())
160 }
161}
162
163#[cfg(feature = "watcher")]
166impl toolpath_convo::ConversationWatcher for crate::watcher::ConversationWatcher {
167 fn poll(&mut self) -> toolpath_convo::Result<Vec<WatcherEvent>> {
168 let entries = crate::watcher::ConversationWatcher::poll(self)
169 .map_err(|e| ConvoError::Provider(e.to_string()))?;
170 Ok(entries.iter().map(entry_to_watcher_event).collect())
171 }
172
173 fn seen_count(&self) -> usize {
174 crate::watcher::ConversationWatcher::seen_count(self)
175 }
176}
177
178pub fn to_view(convo: &Conversation) -> ConversationView {
185 conversation_to_view(convo)
186}
187
188pub fn to_turn(entry: &ConversationEntry) -> Option<Turn> {
191 entry_to_turn(entry)
192}
193
194#[cfg(test)]
197mod tests {
198 use super::*;
199 use crate::PathResolver;
200 use std::fs;
201 use tempfile::TempDir;
202
203 fn setup_provider() -> (TempDir, ClaudeConvo) {
204 let temp = TempDir::new().unwrap();
205 let claude_dir = temp.path().join(".claude");
206 let project_dir = claude_dir.join("projects/-test-project");
207 fs::create_dir_all(&project_dir).unwrap();
208
209 let entries = vec![
210 r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Fix the bug"}}"#,
211 r#"{"uuid":"uuid-2","type":"assistant","parentUuid":"uuid-1","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"I'll fix that."},{"type":"thinking","thinking":"The bug is in auth"},{"type":"tool_use","id":"t1","name":"Read","input":{"file":"src/main.rs"}}],"model":"claude-opus-4-6","stopReason":"end_turn","usage":{"inputTokens":100,"outputTokens":50}}}"#,
212 r#"{"uuid":"uuid-3","type":"user","parentUuid":"uuid-2","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":"Thanks!"}}"#,
213 ];
214 fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap();
215
216 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
217 (temp, ClaudeConvo::with_resolver(resolver))
218 }
219
220 #[test]
221 fn test_load_conversation() {
222 let (_temp, provider) = setup_provider();
223 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
224 .unwrap();
225
226 assert_eq!(view.id, "session-1");
227 assert_eq!(view.turns.len(), 3);
228
229 assert_eq!(view.turns[0].role, Role::User);
231 assert_eq!(view.turns[0].text, "Fix the bug");
232 assert!(view.turns[0].parent_id.is_none());
233
234 assert_eq!(view.turns[1].role, Role::Assistant);
236 assert_eq!(view.turns[1].text, "I'll fix that.");
237 assert_eq!(
238 view.turns[1].thinking.as_deref(),
239 Some("The bug is in auth")
240 );
241 assert_eq!(view.turns[1].tool_uses.len(), 1);
242 assert_eq!(view.turns[1].tool_uses[0].name, "Read");
243 assert_eq!(view.turns[1].model.as_deref(), Some("claude-opus-4-6"));
244 assert_eq!(view.turns[1].stop_reason.as_deref(), Some("end_turn"));
245 assert_eq!(view.turns[1].parent_id.as_deref(), Some("uuid-1"));
246
247 let usage = view.turns[1].token_usage.as_ref().unwrap();
249 assert_eq!(usage.input_tokens, Some(100));
250 assert_eq!(usage.output_tokens, Some(50));
251
252 assert_eq!(view.turns[2].role, Role::User);
254 assert_eq!(view.turns[2].text, "Thanks!");
255 }
256
257 #[test]
258 fn test_list_conversations() {
259 let (_temp, provider) = setup_provider();
260 let ids = ConversationProvider::list_conversations(&provider, "/test/project").unwrap();
261 assert_eq!(ids, vec!["session-1"]);
262 }
263
264 #[test]
265 fn test_load_metadata() {
266 let (_temp, provider) = setup_provider();
267 let meta =
268 ConversationProvider::load_metadata(&provider, "/test/project", "session-1").unwrap();
269 assert_eq!(meta.id, "session-1");
270 assert_eq!(meta.message_count, 3);
271 assert!(meta.file_path.is_some());
272 }
273
274 #[test]
275 fn test_list_metadata() {
276 let (_temp, provider) = setup_provider();
277 let metas = ConversationProvider::list_metadata(&provider, "/test/project").unwrap();
278 assert_eq!(metas.len(), 1);
279 assert_eq!(metas[0].id, "session-1");
280 }
281
282 #[test]
283 fn test_to_view() {
284 let (_temp, manager) = setup_provider();
285 let convo = manager
286 .read_conversation("/test/project", "session-1")
287 .unwrap();
288 let view = to_view(&convo);
289 assert_eq!(view.turns.len(), 3);
290 assert_eq!(view.title(20).unwrap(), "Fix the bug");
291 }
292
293 #[test]
294 fn test_to_turn_with_message() {
295 let entry: ConversationEntry = serde_json::from_str(
296 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
297 )
298 .unwrap();
299 let turn = to_turn(&entry).unwrap();
300 assert_eq!(turn.id, "u1");
301 assert_eq!(turn.text, "hello");
302 assert_eq!(turn.role, Role::User);
303 }
304
305 #[test]
306 fn test_to_turn_without_message() {
307 let entry: ConversationEntry = serde_json::from_str(
308 r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
309 )
310 .unwrap();
311 assert!(to_turn(&entry).is_none());
312 }
313
314 #[test]
315 fn test_entry_to_watcher_event_turn() {
316 let entry: ConversationEntry = serde_json::from_str(
317 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hi"}}"#,
318 )
319 .unwrap();
320 let event = entry_to_watcher_event(&entry);
321 assert!(matches!(event, WatcherEvent::Turn(_)));
322 }
323
324 #[test]
325 fn test_entry_to_watcher_event_progress() {
326 let entry: ConversationEntry = serde_json::from_str(
327 r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
328 )
329 .unwrap();
330 let event = entry_to_watcher_event(&entry);
331 assert!(matches!(event, WatcherEvent::Progress { .. }));
332 }
333
334 #[cfg(feature = "watcher")]
335 #[test]
336 fn test_watcher_trait() {
337 let temp = TempDir::new().unwrap();
338 let claude_dir = temp.path().join(".claude");
339 let project_dir = claude_dir.join("projects/-test-project");
340 fs::create_dir_all(&project_dir).unwrap();
341
342 let entries = vec![
343 r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
344 r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
345 ];
346 fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap();
347
348 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
349 let manager = ClaudeConvo::with_resolver(resolver);
350
351 let mut watcher = crate::watcher::ConversationWatcher::new(
352 manager,
353 "/test/project".to_string(),
354 "session-1".to_string(),
355 );
356
357 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
359 assert_eq!(events.len(), 2);
360 assert!(matches!(&events[0], WatcherEvent::Turn(t) if t.role == Role::User));
361 assert!(matches!(&events[1], WatcherEvent::Turn(t) if t.role == Role::Assistant));
362 assert_eq!(toolpath_convo::ConversationWatcher::seen_count(&watcher), 2);
363
364 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
366 assert!(events.is_empty());
367 }
368}