1use crate::ClaudeConvo;
9use crate::types::{Conversation, ConversationEntry, Message, MessageContent, MessageRole};
10use toolpath_convo::{
11 ConversationMeta, ConversationProvider, ConversationView, ConvoError, DelegatedWork,
12 EnvironmentSnapshot, Role, TokenUsage, ToolCategory, ToolInvocation, ToolResult, Turn,
13 WatcherEvent,
14};
15
16fn claude_role_to_role(role: &MessageRole) -> Role {
19 match role {
20 MessageRole::User => Role::User,
21 MessageRole::Assistant => Role::Assistant,
22 MessageRole::System => Role::System,
23 }
24}
25
26fn tool_category(name: &str) -> Option<ToolCategory> {
31 match name {
32 "Read" => Some(ToolCategory::FileRead),
33 "Glob" | "Grep" => Some(ToolCategory::FileSearch),
34 "Write" | "Edit" | "NotebookEdit" => Some(ToolCategory::FileWrite),
35 "Bash" => Some(ToolCategory::Shell),
36 "WebFetch" | "WebSearch" => Some(ToolCategory::Network),
37 "Task" => Some(ToolCategory::Delegation),
38 _ => None,
39 }
40}
41
42fn message_to_turn(entry: &ConversationEntry, msg: &Message) -> Turn {
45 let text = msg.text();
46
47 let thinking = msg.thinking().map(|parts| parts.join("\n"));
48
49 let tool_uses: Vec<ToolInvocation> = msg
50 .tool_uses()
51 .into_iter()
52 .map(|tu| {
53 let result = find_tool_result_in_parts(msg, tu.id);
54 let category = tool_category(tu.name);
55 ToolInvocation {
56 id: tu.id.to_string(),
57 name: tu.name.to_string(),
58 input: tu.input.clone(),
59 result,
60 category,
61 }
62 })
63 .collect();
64
65 let token_usage = msg.usage.as_ref().map(|u| TokenUsage {
66 input_tokens: u.input_tokens,
67 output_tokens: u.output_tokens,
68 cache_read_tokens: u.cache_read_input_tokens,
69 cache_write_tokens: u.cache_creation_input_tokens,
70 });
71
72 let environment = if entry.cwd.is_some() || entry.git_branch.is_some() {
73 Some(EnvironmentSnapshot {
74 working_dir: entry.cwd.clone(),
75 vcs_branch: entry.git_branch.clone(),
76 vcs_revision: None,
77 })
78 } else {
79 None
80 };
81
82 let delegations = extract_delegations(&tool_uses);
83
84 Turn {
85 id: entry.uuid.clone(),
86 parent_id: entry.parent_uuid.clone(),
87 role: claude_role_to_role(&msg.role),
88 timestamp: entry.timestamp.clone(),
89 text,
90 thinking,
91 tool_uses,
92 model: msg.model.clone(),
93 stop_reason: msg.stop_reason.clone(),
94 token_usage,
95 environment,
96 delegations,
97 extra: Default::default(),
98 }
99}
100
101fn extract_delegations(tool_uses: &[ToolInvocation]) -> Vec<DelegatedWork> {
103 tool_uses
104 .iter()
105 .filter(|tu| tu.category == Some(ToolCategory::Delegation))
106 .map(|tu| DelegatedWork {
107 agent_id: tu.id.clone(),
108 prompt: tu
109 .input
110 .get("prompt")
111 .and_then(|v| v.as_str())
112 .unwrap_or("")
113 .to_string(),
114 turns: vec![],
115 result: tu.result.as_ref().map(|r| r.content.clone()),
116 })
117 .collect()
118}
119
120fn find_tool_result_in_parts(msg: &Message, tool_use_id: &str) -> Option<ToolResult> {
121 let parts = match &msg.content {
122 Some(MessageContent::Parts(parts)) => parts,
123 _ => return None,
124 };
125 parts.iter().find_map(|p| match p {
126 crate::types::ContentPart::ToolResult {
127 tool_use_id: id,
128 content,
129 is_error,
130 } if id == tool_use_id => Some(ToolResult {
131 content: content.text(),
132 is_error: *is_error,
133 }),
134 _ => None,
135 })
136}
137
138fn is_tool_result_only(entry: &ConversationEntry) -> bool {
141 let Some(msg) = &entry.message else {
142 return false;
143 };
144 msg.role == MessageRole::User && msg.text().is_empty() && !msg.tool_results().is_empty()
145}
146
147fn merge_tool_results(turns: &mut [Turn], msg: &Message) -> bool {
156 let mut merged = false;
157 for tr in msg.tool_results() {
158 for turn in turns.iter_mut().rev() {
159 if let Some(invocation) = turn
160 .tool_uses
161 .iter_mut()
162 .find(|tu| tu.id == tr.tool_use_id && tu.result.is_none())
163 {
164 invocation.result = Some(ToolResult {
165 content: tr.content.text(),
166 is_error: tr.is_error,
167 });
168 merged = true;
169 break;
170 }
171 }
172 }
173 merged
174}
175
176fn entry_to_turn(entry: &ConversationEntry) -> Option<Turn> {
177 entry
178 .message
179 .as_ref()
180 .map(|msg| message_to_turn(entry, msg))
181}
182
183fn conversation_to_view(convo: &Conversation) -> ConversationView {
188 let mut turns: Vec<Turn> = Vec::new();
189
190 for entry in &convo.entries {
191 let Some(msg) = &entry.message else {
192 continue;
193 };
194
195 if is_tool_result_only(entry) {
197 merge_tool_results(&mut turns, msg);
198 continue;
199 }
200
201 turns.push(message_to_turn(entry, msg));
202 }
203
204 for turn in &mut turns {
206 for delegation in &mut turn.delegations {
207 if delegation.result.is_none()
208 && let Some(tu) = turn
209 .tool_uses
210 .iter()
211 .find(|tu| tu.id == delegation.agent_id)
212 {
213 delegation.result = tu.result.as_ref().map(|r| r.content.clone());
214 }
215 }
216 }
217
218 let total_usage = sum_usage(&turns);
219 let files_changed = extract_files_changed(&turns);
220
221 ConversationView {
222 id: convo.session_id.clone(),
223 started_at: convo.started_at,
224 last_activity: convo.last_activity,
225 turns,
226 total_usage,
227 provider_id: Some("claude-code".into()),
228 files_changed,
229 }
230}
231
232fn sum_usage(turns: &[Turn]) -> Option<TokenUsage> {
234 let mut total = TokenUsage::default();
235 let mut any = false;
236 for turn in turns {
237 if let Some(u) = &turn.token_usage {
238 any = true;
239 total.input_tokens =
240 Some(total.input_tokens.unwrap_or(0) + u.input_tokens.unwrap_or(0));
241 total.output_tokens =
242 Some(total.output_tokens.unwrap_or(0) + u.output_tokens.unwrap_or(0));
243 total.cache_read_tokens = match (total.cache_read_tokens, u.cache_read_tokens) {
244 (Some(a), Some(b)) => Some(a + b),
245 (Some(a), None) => Some(a),
246 (None, Some(b)) => Some(b),
247 (None, None) => None,
248 };
249 total.cache_write_tokens = match (total.cache_write_tokens, u.cache_write_tokens) {
250 (Some(a), Some(b)) => Some(a + b),
251 (Some(a), None) => Some(a),
252 (None, Some(b)) => Some(b),
253 (None, None) => None,
254 };
255 }
256 }
257 if any { Some(total) } else { None }
258}
259
260fn extract_files_changed(turns: &[Turn]) -> Vec<String> {
262 let mut seen = std::collections::HashSet::new();
263 let mut files = Vec::new();
264 for turn in turns {
265 for tool_use in &turn.tool_uses {
266 if tool_use.category == Some(ToolCategory::FileWrite)
267 && let Some(path) = tool_use.input.get("file_path").and_then(|v| v.as_str())
268 && seen.insert(path.to_string())
269 {
270 files.push(path.to_string());
271 }
272 }
273 }
274 files
275}
276
277fn entry_to_watcher_event(entry: &ConversationEntry) -> WatcherEvent {
278 match entry_to_turn(entry) {
279 Some(turn) => WatcherEvent::Turn(Box::new(turn)),
280 None => WatcherEvent::Progress {
281 kind: entry.entry_type.clone(),
282 data: serde_json::json!({
283 "uuid": entry.uuid,
284 "timestamp": entry.timestamp,
285 }),
286 },
287 }
288}
289
290impl ConversationProvider for ClaudeConvo {
293 fn list_conversations(&self, project: &str) -> toolpath_convo::Result<Vec<String>> {
294 crate::ClaudeConvo::list_conversations(self, project)
295 .map_err(|e| ConvoError::Provider(e.to_string()))
296 }
297
298 fn load_conversation(
299 &self,
300 project: &str,
301 conversation_id: &str,
302 ) -> toolpath_convo::Result<ConversationView> {
303 let convo = self
304 .read_conversation(project, conversation_id)
305 .map_err(|e| ConvoError::Provider(e.to_string()))?;
306 Ok(conversation_to_view(&convo))
307 }
308
309 fn load_metadata(
310 &self,
311 project: &str,
312 conversation_id: &str,
313 ) -> toolpath_convo::Result<ConversationMeta> {
314 let meta = self
315 .read_conversation_metadata(project, conversation_id)
316 .map_err(|e| ConvoError::Provider(e.to_string()))?;
317 Ok(ConversationMeta {
318 id: meta.session_id,
319 started_at: meta.started_at,
320 last_activity: meta.last_activity,
321 message_count: meta.message_count,
322 file_path: Some(meta.file_path),
323 })
324 }
325
326 fn list_metadata(&self, project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
327 let metas = self
328 .list_conversation_metadata(project)
329 .map_err(|e| ConvoError::Provider(e.to_string()))?;
330 Ok(metas
331 .into_iter()
332 .map(|m| ConversationMeta {
333 id: m.session_id,
334 started_at: m.started_at,
335 last_activity: m.last_activity,
336 message_count: m.message_count,
337 file_path: Some(m.file_path),
338 })
339 .collect())
340 }
341}
342
343#[cfg(feature = "watcher")]
346impl toolpath_convo::ConversationWatcher for crate::watcher::ConversationWatcher {
347 fn poll(&mut self) -> toolpath_convo::Result<Vec<WatcherEvent>> {
348 let entries = crate::watcher::ConversationWatcher::poll(self)
349 .map_err(|e| ConvoError::Provider(e.to_string()))?;
350
351 let mut events: Vec<WatcherEvent> = Vec::new();
352
353 for entry in &entries {
354 let Some(msg) = &entry.message else {
355 events.push(entry_to_watcher_event(entry));
356 continue;
357 };
358
359 if is_tool_result_only(entry) {
360 let mut updated_turn: Option<Turn> = None;
364
365 for event in events.iter_mut().rev() {
367 if let WatcherEvent::Turn(turn) | WatcherEvent::TurnUpdated(turn) = event
368 && turn.tool_uses.iter().any(|tu| {
369 tu.result.is_none()
370 && msg.tool_results().iter().any(|tr| tr.tool_use_id == tu.id)
371 })
372 {
373 let mut updated = (**turn).clone();
375 merge_tool_results(std::slice::from_mut(&mut updated), msg);
376 updated_turn = Some(updated.clone());
377 **turn = updated;
380 break;
381 }
382 }
383
384 if let Some(turn) = updated_turn {
385 events.push(WatcherEvent::TurnUpdated(Box::new(turn)));
386 }
387 continue;
391 }
392
393 events.push(entry_to_watcher_event(entry));
394 }
395
396 Ok(events)
397 }
398
399 fn seen_count(&self) -> usize {
400 crate::watcher::ConversationWatcher::seen_count(self)
401 }
402}
403
404pub fn to_view(convo: &Conversation) -> ConversationView {
412 conversation_to_view(convo)
413}
414
415pub fn to_turn(entry: &ConversationEntry) -> Option<Turn> {
421 entry_to_turn(entry)
422}
423
424#[cfg(test)]
427mod tests {
428 use super::*;
429 use crate::PathResolver;
430 use std::fs;
431 use tempfile::TempDir;
432
433 fn setup_provider() -> (TempDir, ClaudeConvo) {
434 let temp = TempDir::new().unwrap();
435 let claude_dir = temp.path().join(".claude");
436 let project_dir = claude_dir.join("projects/-test-project");
437 fs::create_dir_all(&project_dir).unwrap();
438
439 let entries = vec![
440 r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Fix the bug"}}"#,
441 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_path":"src/main.rs"}}],"model":"claude-opus-4-6","stop_reason":"tool_use","usage":{"input_tokens":100,"output_tokens":50}}}"#,
442 r#"{"uuid":"uuid-3","type":"user","parentUuid":"uuid-2","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"fn main() { println!(\"hello\"); }","is_error":false}]}}"#,
443 r#"{"uuid":"uuid-4","type":"assistant","parentUuid":"uuid-3","timestamp":"2024-01-01T00:00:03Z","message":{"role":"assistant","content":[{"type":"text","text":"I see the issue. Let me fix it."},{"type":"tool_use","id":"t2","name":"Edit","input":{"file_path":"src/main.rs","old_string":"hello","new_string":"fixed"}}],"model":"claude-opus-4-6","stop_reason":"tool_use","usage":{"input_tokens":200,"output_tokens":100}}}"#,
444 r#"{"uuid":"uuid-5","type":"user","parentUuid":"uuid-4","timestamp":"2024-01-01T00:00:04Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t2","content":"File written successfully","is_error":false}]}}"#,
445 r#"{"uuid":"uuid-6","type":"assistant","parentUuid":"uuid-5","timestamp":"2024-01-01T00:00:05Z","message":{"role":"assistant","content":"Done! The bug is fixed.","model":"claude-opus-4-6","stop_reason":"end_turn"}}"#,
446 r#"{"uuid":"uuid-7","type":"user","parentUuid":"uuid-6","timestamp":"2024-01-01T00:00:06Z","message":{"role":"user","content":"Thanks!"}}"#,
447 ];
448 fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap();
449
450 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
451 (temp, ClaudeConvo::with_resolver(resolver))
452 }
453
454 #[test]
455 fn test_load_conversation_assembles_tool_results() {
456 let (_temp, provider) = setup_provider();
457 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
458 .unwrap();
459
460 assert_eq!(view.id, "session-1");
461 assert_eq!(view.turns.len(), 5);
463
464 assert_eq!(view.turns[0].role, Role::User);
466 assert_eq!(view.turns[0].text, "Fix the bug");
467 assert!(view.turns[0].parent_id.is_none());
468
469 assert_eq!(view.turns[1].role, Role::Assistant);
471 assert_eq!(view.turns[1].text, "I'll fix that.");
472 assert_eq!(
473 view.turns[1].thinking.as_deref(),
474 Some("The bug is in auth")
475 );
476 assert_eq!(view.turns[1].tool_uses.len(), 1);
477 assert_eq!(view.turns[1].tool_uses[0].name, "Read");
478 assert_eq!(view.turns[1].tool_uses[0].id, "t1");
479 let result = view.turns[1].tool_uses[0].result.as_ref().unwrap();
481 assert!(!result.is_error);
482 assert!(result.content.contains("fn main()"));
483 assert_eq!(view.turns[1].model.as_deref(), Some("claude-opus-4-6"));
484 assert_eq!(view.turns[1].stop_reason.as_deref(), Some("tool_use"));
485 assert_eq!(view.turns[1].parent_id.as_deref(), Some("uuid-1"));
486
487 let usage = view.turns[1].token_usage.as_ref().unwrap();
489 assert_eq!(usage.input_tokens, Some(100));
490 assert_eq!(usage.output_tokens, Some(50));
491
492 assert_eq!(view.turns[2].role, Role::Assistant);
494 assert_eq!(view.turns[2].text, "I see the issue. Let me fix it.");
495 assert_eq!(view.turns[2].tool_uses[0].name, "Edit");
496 let result2 = view.turns[2].tool_uses[0].result.as_ref().unwrap();
497 assert_eq!(result2.content, "File written successfully");
498
499 assert_eq!(view.turns[3].role, Role::Assistant);
501 assert_eq!(view.turns[3].text, "Done! The bug is fixed.");
502 assert!(view.turns[3].tool_uses.is_empty());
503
504 assert_eq!(view.turns[4].role, Role::User);
506 assert_eq!(view.turns[4].text, "Thanks!");
507 }
508
509 #[test]
510 fn test_no_phantom_empty_turns() {
511 let (_temp, provider) = setup_provider();
512 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
513 .unwrap();
514
515 for turn in &view.turns {
517 if turn.role == Role::User {
518 assert!(
519 !turn.text.is_empty(),
520 "Found phantom empty user turn: {:?}",
521 turn.id
522 );
523 }
524 }
525 }
526
527 #[test]
528 fn test_tool_result_error_flag() {
529 let temp = TempDir::new().unwrap();
530 let claude_dir = temp.path().join(".claude");
531 let project_dir = claude_dir.join("projects/-test-project");
532 fs::create_dir_all(&project_dir).unwrap();
533
534 let entries = vec![
535 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read a file"}}"#,
536 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"Reading..."},{"type":"tool_use","id":"t1","name":"Read","input":{"path":"/nonexistent"}}],"stop_reason":"tool_use"}}"#,
537 r#"{"uuid":"u3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"File not found","is_error":true}]}}"#,
538 ];
539 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
540
541 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
542 let provider = ClaudeConvo::with_resolver(resolver);
543 let view =
544 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
545
546 assert_eq!(view.turns.len(), 2); let result = view.turns[1].tool_uses[0].result.as_ref().unwrap();
548 assert!(result.is_error);
549 assert_eq!(result.content, "File not found");
550 }
551
552 #[test]
553 fn test_multiple_tool_uses_single_result_entry() {
554 let temp = TempDir::new().unwrap();
555 let claude_dir = temp.path().join(".claude");
556 let project_dir = claude_dir.join("projects/-test-project");
557 fs::create_dir_all(&project_dir).unwrap();
558
559 let entries = vec![
560 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Check two files"}}"#,
561 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"Reading both..."},{"type":"tool_use","id":"t1","name":"Read","input":{"path":"a.rs"}},{"type":"tool_use","id":"t2","name":"Read","input":{"path":"b.rs"}}]}}"#,
562 r#"{"uuid":"u3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"file a contents","is_error":false},{"type":"tool_result","tool_use_id":"t2","content":"file b contents","is_error":false}]}}"#,
563 ];
564 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
565
566 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
567 let provider = ClaudeConvo::with_resolver(resolver);
568 let view =
569 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
570
571 assert_eq!(view.turns.len(), 2);
572 assert_eq!(view.turns[1].tool_uses.len(), 2);
573
574 let r1 = view.turns[1].tool_uses[0].result.as_ref().unwrap();
575 assert_eq!(r1.content, "file a contents");
576
577 let r2 = view.turns[1].tool_uses[1].result.as_ref().unwrap();
578 assert_eq!(r2.content, "file b contents");
579 }
580
581 #[test]
582 fn test_conversation_without_tool_use_unchanged() {
583 let temp = TempDir::new().unwrap();
584 let claude_dir = temp.path().join(".claude");
585 let project_dir = claude_dir.join("projects/-test-project");
586 fs::create_dir_all(&project_dir).unwrap();
587
588 let entries = vec![
589 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
590 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi there!"}}"#,
591 ];
592 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
593
594 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
595 let provider = ClaudeConvo::with_resolver(resolver);
596 let view =
597 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
598
599 assert_eq!(view.turns.len(), 2);
600 assert_eq!(view.turns[0].text, "Hello");
601 assert_eq!(view.turns[1].text, "Hi there!");
602 }
603
604 #[test]
605 fn test_assistant_turn_without_result_has_none() {
606 let temp = TempDir::new().unwrap();
608 let claude_dir = temp.path().join(".claude");
609 let project_dir = claude_dir.join("projects/-test-project");
610 fs::create_dir_all(&project_dir).unwrap();
611
612 let entries = vec![
613 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read a file"}}"#,
614 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"Reading..."},{"type":"tool_use","id":"t1","name":"Read","input":{"path":"test.rs"}}]}}"#,
615 ];
616 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
617
618 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
619 let provider = ClaudeConvo::with_resolver(resolver);
620 let view =
621 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
622
623 assert_eq!(view.turns.len(), 2);
624 assert!(view.turns[1].tool_uses[0].result.is_none());
625 }
626
627 #[test]
628 fn test_list_conversations() {
629 let (_temp, provider) = setup_provider();
630 let ids = ConversationProvider::list_conversations(&provider, "/test/project").unwrap();
631 assert_eq!(ids, vec!["session-1"]);
632 }
633
634 #[test]
635 fn test_load_metadata() {
636 let (_temp, provider) = setup_provider();
637 let meta =
638 ConversationProvider::load_metadata(&provider, "/test/project", "session-1").unwrap();
639 assert_eq!(meta.id, "session-1");
640 assert_eq!(meta.message_count, 7);
641 assert!(meta.file_path.is_some());
642 }
643
644 #[test]
645 fn test_list_metadata() {
646 let (_temp, provider) = setup_provider();
647 let metas = ConversationProvider::list_metadata(&provider, "/test/project").unwrap();
648 assert_eq!(metas.len(), 1);
649 assert_eq!(metas[0].id, "session-1");
650 }
651
652 #[test]
653 fn test_to_view() {
654 let (_temp, manager) = setup_provider();
655 let convo = manager
656 .read_conversation("/test/project", "session-1")
657 .unwrap();
658 let view = to_view(&convo);
659 assert_eq!(view.turns.len(), 5);
660 assert_eq!(view.title(20).unwrap(), "Fix the bug");
661 }
662
663 #[test]
664 fn test_to_turn_with_message() {
665 let entry: ConversationEntry = serde_json::from_str(
666 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
667 )
668 .unwrap();
669 let turn = to_turn(&entry).unwrap();
670 assert_eq!(turn.id, "u1");
671 assert_eq!(turn.text, "hello");
672 assert_eq!(turn.role, Role::User);
673 }
674
675 #[test]
676 fn test_to_turn_without_message() {
677 let entry: ConversationEntry = serde_json::from_str(
678 r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
679 )
680 .unwrap();
681 assert!(to_turn(&entry).is_none());
682 }
683
684 #[test]
685 fn test_entry_to_watcher_event_turn() {
686 let entry: ConversationEntry = serde_json::from_str(
687 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hi"}}"#,
688 )
689 .unwrap();
690 let event = entry_to_watcher_event(&entry);
691 assert!(matches!(event, WatcherEvent::Turn(_)));
692 }
693
694 #[test]
695 fn test_entry_to_watcher_event_progress() {
696 let entry: ConversationEntry = serde_json::from_str(
697 r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
698 )
699 .unwrap();
700 let event = entry_to_watcher_event(&entry);
701 assert!(matches!(event, WatcherEvent::Progress { .. }));
702 }
703
704 #[cfg(feature = "watcher")]
705 #[test]
706 fn test_watcher_trait_basic() {
707 let temp = TempDir::new().unwrap();
708 let claude_dir = temp.path().join(".claude");
709 let project_dir = claude_dir.join("projects/-test-project");
710 fs::create_dir_all(&project_dir).unwrap();
711
712 let entries = vec![
713 r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
714 r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
715 ];
716 fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap();
717
718 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
719 let manager = ClaudeConvo::with_resolver(resolver);
720
721 let mut watcher = crate::watcher::ConversationWatcher::new(
722 manager,
723 "/test/project".to_string(),
724 "session-1".to_string(),
725 );
726
727 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
729 assert_eq!(events.len(), 2);
730 assert!(matches!(&events[0], WatcherEvent::Turn(t) if t.role == Role::User));
731 assert!(matches!(&events[1], WatcherEvent::Turn(t) if t.role == Role::Assistant));
732 assert_eq!(toolpath_convo::ConversationWatcher::seen_count(&watcher), 2);
733
734 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
736 assert!(events.is_empty());
737 }
738
739 #[cfg(feature = "watcher")]
740 #[test]
741 fn test_watcher_trait_assembles_tool_results() {
742 let temp = TempDir::new().unwrap();
743 let claude_dir = temp.path().join(".claude");
744 let project_dir = claude_dir.join("projects/-test-project");
745 fs::create_dir_all(&project_dir).unwrap();
746
747 let entries = vec![
748 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read the file"}}"#,
749 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"Reading..."},{"type":"tool_use","id":"t1","name":"Read","input":{"path":"test.rs"}}]}}"#,
750 r#"{"uuid":"u3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"fn main() {}","is_error":false}]}}"#,
751 r#"{"uuid":"u4","type":"assistant","timestamp":"2024-01-01T00:00:03Z","message":{"role":"assistant","content":"Done!"}}"#,
752 ];
753 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
754
755 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
756 let manager = ClaudeConvo::with_resolver(resolver);
757
758 let mut watcher = crate::watcher::ConversationWatcher::new(
759 manager,
760 "/test/project".to_string(),
761 "s1".to_string(),
762 );
763
764 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
765
766 assert_eq!(events.len(), 4);
768
769 assert!(matches!(&events[0], WatcherEvent::Turn(t) if t.role == Role::User));
771
772 assert!(matches!(&events[1], WatcherEvent::Turn(t) if t.role == Role::Assistant));
774
775 match &events[2] {
777 WatcherEvent::TurnUpdated(turn) => {
778 assert_eq!(turn.id, "u2");
779 assert_eq!(turn.tool_uses.len(), 1);
780 let result = turn.tool_uses[0].result.as_ref().unwrap();
781 assert_eq!(result.content, "fn main() {}");
782 assert!(!result.is_error);
783 }
784 other => panic!("Expected TurnUpdated, got {:?}", other),
785 }
786
787 assert!(matches!(&events[3], WatcherEvent::Turn(t) if t.text == "Done!"));
789 }
790
791 #[cfg(feature = "watcher")]
792 #[test]
793 fn test_watcher_trait_incremental_tool_results() {
794 let temp = TempDir::new().unwrap();
796 let claude_dir = temp.path().join(".claude");
797 let project_dir = claude_dir.join("projects/-test-project");
798 fs::create_dir_all(&project_dir).unwrap();
799
800 let entries_phase1 = vec![
802 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read file"}}"#,
803 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"Reading..."},{"type":"tool_use","id":"t1","name":"Read","input":{"path":"test.rs"}}]}}"#,
804 ];
805 fs::write(
806 project_dir.join("s1.jsonl"),
807 entries_phase1.join("\n") + "\n",
808 )
809 .unwrap();
810
811 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
812 let manager = ClaudeConvo::with_resolver(resolver);
813
814 let mut watcher = crate::watcher::ConversationWatcher::new(
815 manager,
816 "/test/project".to_string(),
817 "s1".to_string(),
818 );
819
820 let events1 = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
822 assert_eq!(events1.len(), 2);
823 if let WatcherEvent::Turn(t) = &events1[1] {
825 assert!(t.tool_uses[0].result.is_none());
826 } else {
827 panic!("Expected Turn");
828 }
829
830 use std::io::Write;
832 let mut file = fs::OpenOptions::new()
833 .append(true)
834 .open(project_dir.join("s1.jsonl"))
835 .unwrap();
836 writeln!(file, r#"{{"uuid":"u3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t1","content":"fn main() {{}}","is_error":false}}]}}}}"#).unwrap();
837
838 let events2 = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
840 assert!(events2.is_empty() || events2.iter().all(|e| !matches!(e, WatcherEvent::Turn(_))));
847 }
848
849 #[test]
850 fn test_merge_tool_results_by_id() {
851 let mut turns = vec![Turn {
853 id: "t1".into(),
854 parent_id: None,
855 role: Role::Assistant,
856 timestamp: "2024-01-01T00:00:00Z".into(),
857 text: "test".into(),
858 thinking: None,
859 tool_uses: vec![
860 ToolInvocation {
861 id: "tool-a".into(),
862 name: "Read".into(),
863 input: serde_json::json!({}),
864 result: None,
865 category: Some(ToolCategory::FileRead),
866 },
867 ToolInvocation {
868 id: "tool-b".into(),
869 name: "Write".into(),
870 input: serde_json::json!({}),
871 result: None,
872 category: Some(ToolCategory::FileWrite),
873 },
874 ],
875 model: None,
876 stop_reason: None,
877 token_usage: None,
878 environment: None,
879 delegations: vec![],
880 extra: Default::default(),
881 }];
882
883 let msg: Message = serde_json::from_str(
885 r#"{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-b","content":"write result","is_error":false},{"type":"tool_result","tool_use_id":"tool-a","content":"read result","is_error":true}]}"#,
886 )
887 .unwrap();
888
889 let merged = merge_tool_results(&mut turns, &msg);
890 assert!(merged);
891
892 assert_eq!(
894 turns[0].tool_uses[0].result.as_ref().unwrap().content,
895 "read result"
896 );
897 assert!(turns[0].tool_uses[0].result.as_ref().unwrap().is_error);
898
899 assert_eq!(
900 turns[0].tool_uses[1].result.as_ref().unwrap().content,
901 "write result"
902 );
903 assert!(!turns[0].tool_uses[1].result.as_ref().unwrap().is_error);
904 }
905
906 #[test]
907 fn test_is_tool_result_only() {
908 let entry: ConversationEntry = serde_json::from_str(
910 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"ok","is_error":false}]}}"#,
911 )
912 .unwrap();
913 assert!(is_tool_result_only(&entry));
914
915 let entry: ConversationEntry = serde_json::from_str(
917 r#"{"uuid":"u2","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
918 )
919 .unwrap();
920 assert!(!is_tool_result_only(&entry));
921
922 let entry: ConversationEntry = serde_json::from_str(
924 r#"{"uuid":"u3","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
925 )
926 .unwrap();
927 assert!(!is_tool_result_only(&entry));
928
929 let entry: ConversationEntry = serde_json::from_str(
931 r#"{"uuid":"u4","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"hi"}}"#,
932 )
933 .unwrap();
934 assert!(!is_tool_result_only(&entry));
935 }
936
937 #[test]
940 fn test_tool_category_mapping() {
941 assert_eq!(tool_category("Read"), Some(ToolCategory::FileRead));
942 assert_eq!(tool_category("Glob"), Some(ToolCategory::FileSearch));
943 assert_eq!(tool_category("Grep"), Some(ToolCategory::FileSearch));
944 assert_eq!(tool_category("Write"), Some(ToolCategory::FileWrite));
945 assert_eq!(tool_category("Edit"), Some(ToolCategory::FileWrite));
946 assert_eq!(tool_category("NotebookEdit"), Some(ToolCategory::FileWrite));
947 assert_eq!(tool_category("Bash"), Some(ToolCategory::Shell));
948 assert_eq!(tool_category("WebFetch"), Some(ToolCategory::Network));
949 assert_eq!(tool_category("WebSearch"), Some(ToolCategory::Network));
950 assert_eq!(tool_category("Task"), Some(ToolCategory::Delegation));
951 assert_eq!(tool_category("UnknownTool"), None);
952 }
953
954 #[test]
955 fn test_turn_has_tool_category() {
956 let (_temp, provider) = setup_provider();
957 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
958 .unwrap();
959
960 assert_eq!(
962 view.turns[1].tool_uses[0].category,
963 Some(ToolCategory::FileRead)
964 );
965 assert_eq!(
967 view.turns[2].tool_uses[0].category,
968 Some(ToolCategory::FileWrite)
969 );
970 }
971
972 #[test]
973 fn test_environment_populated_from_entry() {
974 let temp = TempDir::new().unwrap();
975 let claude_dir = temp.path().join(".claude");
976 let project_dir = claude_dir.join("projects/-test-project");
977 fs::create_dir_all(&project_dir).unwrap();
978
979 let entries = vec![
980 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","cwd":"/project/path","gitBranch":"feat/auth","message":{"role":"user","content":"Hello"}}"#,
981 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
982 ];
983 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
984
985 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
986 let provider = ClaudeConvo::with_resolver(resolver);
987 let view =
988 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
989
990 let env = view.turns[0].environment.as_ref().unwrap();
992 assert_eq!(env.working_dir.as_deref(), Some("/project/path"));
993 assert_eq!(env.vcs_branch.as_deref(), Some("feat/auth"));
994 assert!(env.vcs_revision.is_none());
995
996 assert!(view.turns[1].environment.is_none());
998 }
999
1000 #[test]
1001 fn test_cache_tokens_populated() {
1002 let temp = TempDir::new().unwrap();
1003 let claude_dir = temp.path().join(".claude");
1004 let project_dir = claude_dir.join("projects/-test-project");
1005 fs::create_dir_all(&project_dir).unwrap();
1006
1007 let entries = vec![
1008 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
1009 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":200,"cache_read_input_tokens":500}}}"#,
1010 ];
1011 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1012
1013 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1014 let provider = ClaudeConvo::with_resolver(resolver);
1015 let view =
1016 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1017
1018 let usage = view.turns[1].token_usage.as_ref().unwrap();
1019 assert_eq!(usage.cache_read_tokens, Some(500));
1020 assert_eq!(usage.cache_write_tokens, Some(200));
1021 }
1022
1023 #[test]
1024 fn test_total_usage_aggregated() {
1025 let (_temp, provider) = setup_provider();
1026 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1027 .unwrap();
1028
1029 let total = view.total_usage.as_ref().unwrap();
1030 assert_eq!(total.input_tokens, Some(300));
1032 assert_eq!(total.output_tokens, Some(150));
1033 }
1034
1035 #[test]
1036 fn test_provider_id_set() {
1037 let (_temp, provider) = setup_provider();
1038 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1039 .unwrap();
1040
1041 assert_eq!(view.provider_id.as_deref(), Some("claude-code"));
1042 }
1043
1044 #[test]
1045 fn test_files_changed_populated() {
1046 let temp = TempDir::new().unwrap();
1047 let claude_dir = temp.path().join(".claude");
1048 let project_dir = claude_dir.join("projects/-test-project");
1049 fs::create_dir_all(&project_dir).unwrap();
1050
1051 let entries = vec![
1052 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Edit files"}}"#,
1053 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"Editing..."},{"type":"tool_use","id":"t1","name":"Write","input":{"file_path":"src/main.rs","content":"fn main() {}"}},{"type":"tool_use","id":"t2","name":"Edit","input":{"file_path":"src/lib.rs","old_string":"a","new_string":"b"}}]}}"#,
1054 r#"{"uuid":"u3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"ok","is_error":false},{"type":"tool_result","tool_use_id":"t2","content":"ok","is_error":false}]}}"#,
1055 r#"{"uuid":"u4","type":"assistant","timestamp":"2024-01-01T00:00:03Z","message":{"role":"assistant","content":[{"type":"text","text":"More edits..."},{"type":"tool_use","id":"t3","name":"Write","input":{"file_path":"src/main.rs","content":"updated"}}]}}"#,
1056 ];
1057 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1058
1059 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1060 let provider = ClaudeConvo::with_resolver(resolver);
1061 let view =
1062 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1063
1064 assert_eq!(view.files_changed, vec!["src/main.rs", "src/lib.rs"]);
1066 }
1067
1068 #[test]
1069 fn test_delegations_extracted() {
1070 let temp = TempDir::new().unwrap();
1071 let claude_dir = temp.path().join(".claude");
1072 let project_dir = claude_dir.join("projects/-test-project");
1073 fs::create_dir_all(&project_dir).unwrap();
1074
1075 let entries = vec![
1076 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Search for bugs"}}"#,
1077 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"Delegating..."},{"type":"tool_use","id":"task-1","name":"Task","input":{"prompt":"Find the authentication bug","subagent_type":"Explore"}}]}}"#,
1078 r#"{"uuid":"u3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"task-1","content":"Found the bug in auth.rs line 42","is_error":false}]}}"#,
1079 ];
1080 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1081
1082 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1083 let provider = ClaudeConvo::with_resolver(resolver);
1084 let view =
1085 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1086
1087 assert_eq!(view.turns[1].delegations.len(), 1);
1089 let d = &view.turns[1].delegations[0];
1090 assert_eq!(d.agent_id, "task-1");
1091 assert_eq!(d.prompt, "Find the authentication bug");
1092 assert!(d.turns.is_empty()); assert_eq!(
1095 d.result.as_deref(),
1096 Some("Found the bug in auth.rs line 42")
1097 );
1098 }
1099
1100 #[test]
1101 fn test_no_delegations_for_non_task_tools() {
1102 let (_temp, provider) = setup_provider();
1103 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1104 .unwrap();
1105
1106 for turn in &view.turns {
1108 assert!(turn.delegations.is_empty());
1109 }
1110 }
1111}