1use crate::ClaudeConvo;
9use crate::types::{Conversation, ConversationEntry, Message, MessageContent, MessageRole};
10use toolpath_convo::{
11 ConversationMeta, ConversationProvider, ConversationView, ConvoError, Role, TokenUsage,
12 ToolInvocation, ToolResult, Turn, WatcherEvent,
13};
14
15fn claude_role_to_role(role: &MessageRole) -> Role {
18 match role {
19 MessageRole::User => Role::User,
20 MessageRole::Assistant => Role::Assistant,
21 MessageRole::System => Role::System,
22 }
23}
24
25fn message_to_turn(entry: &ConversationEntry, msg: &Message) -> Turn {
28 let text = msg.text();
29
30 let thinking = msg.thinking().map(|parts| parts.join("\n"));
31
32 let tool_uses = msg
33 .tool_uses()
34 .into_iter()
35 .map(|tu| {
36 let result = find_tool_result_in_parts(msg, tu.id);
37 ToolInvocation {
38 id: tu.id.to_string(),
39 name: tu.name.to_string(),
40 input: tu.input.clone(),
41 result,
42 }
43 })
44 .collect();
45
46 let token_usage = msg.usage.as_ref().map(|u| TokenUsage {
47 input_tokens: u.input_tokens,
48 output_tokens: u.output_tokens,
49 });
50
51 Turn {
52 id: entry.uuid.clone(),
53 parent_id: entry.parent_uuid.clone(),
54 role: claude_role_to_role(&msg.role),
55 timestamp: entry.timestamp.clone(),
56 text,
57 thinking,
58 tool_uses,
59 model: msg.model.clone(),
60 stop_reason: msg.stop_reason.clone(),
61 token_usage,
62 extra: Default::default(),
63 }
64}
65
66fn find_tool_result_in_parts(msg: &Message, tool_use_id: &str) -> Option<ToolResult> {
67 let parts = match &msg.content {
68 Some(MessageContent::Parts(parts)) => parts,
69 _ => return None,
70 };
71 parts.iter().find_map(|p| match p {
72 crate::types::ContentPart::ToolResult {
73 tool_use_id: id,
74 content,
75 is_error,
76 } if id == tool_use_id => Some(ToolResult {
77 content: content.text(),
78 is_error: *is_error,
79 }),
80 _ => None,
81 })
82}
83
84fn is_tool_result_only(entry: &ConversationEntry) -> bool {
87 let Some(msg) = &entry.message else {
88 return false;
89 };
90 msg.role == MessageRole::User && msg.text().is_empty() && !msg.tool_results().is_empty()
91}
92
93fn merge_tool_results(turns: &mut [Turn], msg: &Message) -> bool {
102 let mut merged = false;
103 for tr in msg.tool_results() {
104 for turn in turns.iter_mut().rev() {
105 if let Some(invocation) = turn
106 .tool_uses
107 .iter_mut()
108 .find(|tu| tu.id == tr.tool_use_id && tu.result.is_none())
109 {
110 invocation.result = Some(ToolResult {
111 content: tr.content.text(),
112 is_error: tr.is_error,
113 });
114 merged = true;
115 break;
116 }
117 }
118 }
119 merged
120}
121
122fn entry_to_turn(entry: &ConversationEntry) -> Option<Turn> {
123 entry
124 .message
125 .as_ref()
126 .map(|msg| message_to_turn(entry, msg))
127}
128
129fn conversation_to_view(convo: &Conversation) -> ConversationView {
134 let mut turns: Vec<Turn> = Vec::new();
135
136 for entry in &convo.entries {
137 let Some(msg) = &entry.message else {
138 continue;
139 };
140
141 if is_tool_result_only(entry) {
143 merge_tool_results(&mut turns, msg);
144 continue;
145 }
146
147 turns.push(message_to_turn(entry, msg));
148 }
149
150 ConversationView {
151 id: convo.session_id.clone(),
152 started_at: convo.started_at,
153 last_activity: convo.last_activity,
154 turns,
155 }
156}
157
158fn entry_to_watcher_event(entry: &ConversationEntry) -> WatcherEvent {
159 match entry_to_turn(entry) {
160 Some(turn) => WatcherEvent::Turn(Box::new(turn)),
161 None => WatcherEvent::Progress {
162 kind: entry.entry_type.clone(),
163 data: serde_json::json!({
164 "uuid": entry.uuid,
165 "timestamp": entry.timestamp,
166 }),
167 },
168 }
169}
170
171impl ConversationProvider for ClaudeConvo {
174 fn list_conversations(&self, project: &str) -> toolpath_convo::Result<Vec<String>> {
175 crate::ClaudeConvo::list_conversations(self, project)
176 .map_err(|e| ConvoError::Provider(e.to_string()))
177 }
178
179 fn load_conversation(
180 &self,
181 project: &str,
182 conversation_id: &str,
183 ) -> toolpath_convo::Result<ConversationView> {
184 let convo = self
185 .read_conversation(project, conversation_id)
186 .map_err(|e| ConvoError::Provider(e.to_string()))?;
187 Ok(conversation_to_view(&convo))
188 }
189
190 fn load_metadata(
191 &self,
192 project: &str,
193 conversation_id: &str,
194 ) -> toolpath_convo::Result<ConversationMeta> {
195 let meta = self
196 .read_conversation_metadata(project, conversation_id)
197 .map_err(|e| ConvoError::Provider(e.to_string()))?;
198 Ok(ConversationMeta {
199 id: meta.session_id,
200 started_at: meta.started_at,
201 last_activity: meta.last_activity,
202 message_count: meta.message_count,
203 file_path: Some(meta.file_path),
204 })
205 }
206
207 fn list_metadata(&self, project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
208 let metas = self
209 .list_conversation_metadata(project)
210 .map_err(|e| ConvoError::Provider(e.to_string()))?;
211 Ok(metas
212 .into_iter()
213 .map(|m| ConversationMeta {
214 id: m.session_id,
215 started_at: m.started_at,
216 last_activity: m.last_activity,
217 message_count: m.message_count,
218 file_path: Some(m.file_path),
219 })
220 .collect())
221 }
222}
223
224#[cfg(feature = "watcher")]
227impl toolpath_convo::ConversationWatcher for crate::watcher::ConversationWatcher {
228 fn poll(&mut self) -> toolpath_convo::Result<Vec<WatcherEvent>> {
229 let entries = crate::watcher::ConversationWatcher::poll(self)
230 .map_err(|e| ConvoError::Provider(e.to_string()))?;
231
232 let mut events: Vec<WatcherEvent> = Vec::new();
233
234 for entry in &entries {
235 let Some(msg) = &entry.message else {
236 events.push(entry_to_watcher_event(entry));
237 continue;
238 };
239
240 if is_tool_result_only(entry) {
241 let mut updated_turn: Option<Turn> = None;
245
246 for event in events.iter_mut().rev() {
248 if let WatcherEvent::Turn(turn) | WatcherEvent::TurnUpdated(turn) = event
249 && turn.tool_uses.iter().any(|tu| {
250 tu.result.is_none()
251 && msg.tool_results().iter().any(|tr| tr.tool_use_id == tu.id)
252 })
253 {
254 let mut updated = (**turn).clone();
256 merge_tool_results(std::slice::from_mut(&mut updated), msg);
257 updated_turn = Some(updated.clone());
258 **turn = updated;
261 break;
262 }
263 }
264
265 if let Some(turn) = updated_turn {
266 events.push(WatcherEvent::TurnUpdated(Box::new(turn)));
267 }
268 continue;
272 }
273
274 events.push(entry_to_watcher_event(entry));
275 }
276
277 Ok(events)
278 }
279
280 fn seen_count(&self) -> usize {
281 crate::watcher::ConversationWatcher::seen_count(self)
282 }
283}
284
285pub fn to_view(convo: &Conversation) -> ConversationView {
293 conversation_to_view(convo)
294}
295
296pub fn to_turn(entry: &ConversationEntry) -> Option<Turn> {
302 entry_to_turn(entry)
303}
304
305#[cfg(test)]
308mod tests {
309 use super::*;
310 use crate::PathResolver;
311 use std::fs;
312 use tempfile::TempDir;
313
314 fn setup_provider() -> (TempDir, ClaudeConvo) {
315 let temp = TempDir::new().unwrap();
316 let claude_dir = temp.path().join(".claude");
317 let project_dir = claude_dir.join("projects/-test-project");
318 fs::create_dir_all(&project_dir).unwrap();
319
320 let entries = vec![
321 r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Fix the bug"}}"#,
322 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","stop_reason":"tool_use","usage":{"input_tokens":100,"output_tokens":50}}}"#,
323 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}]}}"#,
324 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":"src/main.rs","content":"fixed"}}],"model":"claude-opus-4-6","stop_reason":"tool_use","usage":{"input_tokens":200,"output_tokens":100}}}"#,
325 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}]}}"#,
326 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"}}"#,
327 r#"{"uuid":"uuid-7","type":"user","parentUuid":"uuid-6","timestamp":"2024-01-01T00:00:06Z","message":{"role":"user","content":"Thanks!"}}"#,
328 ];
329 fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap();
330
331 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
332 (temp, ClaudeConvo::with_resolver(resolver))
333 }
334
335 #[test]
336 fn test_load_conversation_assembles_tool_results() {
337 let (_temp, provider) = setup_provider();
338 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
339 .unwrap();
340
341 assert_eq!(view.id, "session-1");
342 assert_eq!(view.turns.len(), 5);
344
345 assert_eq!(view.turns[0].role, Role::User);
347 assert_eq!(view.turns[0].text, "Fix the bug");
348 assert!(view.turns[0].parent_id.is_none());
349
350 assert_eq!(view.turns[1].role, Role::Assistant);
352 assert_eq!(view.turns[1].text, "I'll fix that.");
353 assert_eq!(
354 view.turns[1].thinking.as_deref(),
355 Some("The bug is in auth")
356 );
357 assert_eq!(view.turns[1].tool_uses.len(), 1);
358 assert_eq!(view.turns[1].tool_uses[0].name, "Read");
359 assert_eq!(view.turns[1].tool_uses[0].id, "t1");
360 let result = view.turns[1].tool_uses[0].result.as_ref().unwrap();
362 assert!(!result.is_error);
363 assert!(result.content.contains("fn main()"));
364 assert_eq!(view.turns[1].model.as_deref(), Some("claude-opus-4-6"));
365 assert_eq!(view.turns[1].stop_reason.as_deref(), Some("tool_use"));
366 assert_eq!(view.turns[1].parent_id.as_deref(), Some("uuid-1"));
367
368 let usage = view.turns[1].token_usage.as_ref().unwrap();
370 assert_eq!(usage.input_tokens, Some(100));
371 assert_eq!(usage.output_tokens, Some(50));
372
373 assert_eq!(view.turns[2].role, Role::Assistant);
375 assert_eq!(view.turns[2].text, "I see the issue. Let me fix it.");
376 assert_eq!(view.turns[2].tool_uses[0].name, "Edit");
377 let result2 = view.turns[2].tool_uses[0].result.as_ref().unwrap();
378 assert_eq!(result2.content, "File written successfully");
379
380 assert_eq!(view.turns[3].role, Role::Assistant);
382 assert_eq!(view.turns[3].text, "Done! The bug is fixed.");
383 assert!(view.turns[3].tool_uses.is_empty());
384
385 assert_eq!(view.turns[4].role, Role::User);
387 assert_eq!(view.turns[4].text, "Thanks!");
388 }
389
390 #[test]
391 fn test_no_phantom_empty_turns() {
392 let (_temp, provider) = setup_provider();
393 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
394 .unwrap();
395
396 for turn in &view.turns {
398 if turn.role == Role::User {
399 assert!(
400 !turn.text.is_empty(),
401 "Found phantom empty user turn: {:?}",
402 turn.id
403 );
404 }
405 }
406 }
407
408 #[test]
409 fn test_tool_result_error_flag() {
410 let temp = TempDir::new().unwrap();
411 let claude_dir = temp.path().join(".claude");
412 let project_dir = claude_dir.join("projects/-test-project");
413 fs::create_dir_all(&project_dir).unwrap();
414
415 let entries = vec![
416 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read a file"}}"#,
417 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"}}"#,
418 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}]}}"#,
419 ];
420 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
421
422 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
423 let provider = ClaudeConvo::with_resolver(resolver);
424 let view =
425 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
426
427 assert_eq!(view.turns.len(), 2); let result = view.turns[1].tool_uses[0].result.as_ref().unwrap();
429 assert!(result.is_error);
430 assert_eq!(result.content, "File not found");
431 }
432
433 #[test]
434 fn test_multiple_tool_uses_single_result_entry() {
435 let temp = TempDir::new().unwrap();
436 let claude_dir = temp.path().join(".claude");
437 let project_dir = claude_dir.join("projects/-test-project");
438 fs::create_dir_all(&project_dir).unwrap();
439
440 let entries = vec![
441 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Check two files"}}"#,
442 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"}}]}}"#,
443 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}]}}"#,
444 ];
445 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
446
447 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
448 let provider = ClaudeConvo::with_resolver(resolver);
449 let view =
450 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
451
452 assert_eq!(view.turns.len(), 2);
453 assert_eq!(view.turns[1].tool_uses.len(), 2);
454
455 let r1 = view.turns[1].tool_uses[0].result.as_ref().unwrap();
456 assert_eq!(r1.content, "file a contents");
457
458 let r2 = view.turns[1].tool_uses[1].result.as_ref().unwrap();
459 assert_eq!(r2.content, "file b contents");
460 }
461
462 #[test]
463 fn test_conversation_without_tool_use_unchanged() {
464 let temp = TempDir::new().unwrap();
465 let claude_dir = temp.path().join(".claude");
466 let project_dir = claude_dir.join("projects/-test-project");
467 fs::create_dir_all(&project_dir).unwrap();
468
469 let entries = vec![
470 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
471 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi there!"}}"#,
472 ];
473 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
474
475 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
476 let provider = ClaudeConvo::with_resolver(resolver);
477 let view =
478 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
479
480 assert_eq!(view.turns.len(), 2);
481 assert_eq!(view.turns[0].text, "Hello");
482 assert_eq!(view.turns[1].text, "Hi there!");
483 }
484
485 #[test]
486 fn test_assistant_turn_without_result_has_none() {
487 let temp = TempDir::new().unwrap();
489 let claude_dir = temp.path().join(".claude");
490 let project_dir = claude_dir.join("projects/-test-project");
491 fs::create_dir_all(&project_dir).unwrap();
492
493 let entries = vec![
494 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read a file"}}"#,
495 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"}}]}}"#,
496 ];
497 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
498
499 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
500 let provider = ClaudeConvo::with_resolver(resolver);
501 let view =
502 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
503
504 assert_eq!(view.turns.len(), 2);
505 assert!(view.turns[1].tool_uses[0].result.is_none());
506 }
507
508 #[test]
509 fn test_list_conversations() {
510 let (_temp, provider) = setup_provider();
511 let ids = ConversationProvider::list_conversations(&provider, "/test/project").unwrap();
512 assert_eq!(ids, vec!["session-1"]);
513 }
514
515 #[test]
516 fn test_load_metadata() {
517 let (_temp, provider) = setup_provider();
518 let meta =
519 ConversationProvider::load_metadata(&provider, "/test/project", "session-1").unwrap();
520 assert_eq!(meta.id, "session-1");
521 assert_eq!(meta.message_count, 7);
522 assert!(meta.file_path.is_some());
523 }
524
525 #[test]
526 fn test_list_metadata() {
527 let (_temp, provider) = setup_provider();
528 let metas = ConversationProvider::list_metadata(&provider, "/test/project").unwrap();
529 assert_eq!(metas.len(), 1);
530 assert_eq!(metas[0].id, "session-1");
531 }
532
533 #[test]
534 fn test_to_view() {
535 let (_temp, manager) = setup_provider();
536 let convo = manager
537 .read_conversation("/test/project", "session-1")
538 .unwrap();
539 let view = to_view(&convo);
540 assert_eq!(view.turns.len(), 5);
541 assert_eq!(view.title(20).unwrap(), "Fix the bug");
542 }
543
544 #[test]
545 fn test_to_turn_with_message() {
546 let entry: ConversationEntry = serde_json::from_str(
547 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
548 )
549 .unwrap();
550 let turn = to_turn(&entry).unwrap();
551 assert_eq!(turn.id, "u1");
552 assert_eq!(turn.text, "hello");
553 assert_eq!(turn.role, Role::User);
554 }
555
556 #[test]
557 fn test_to_turn_without_message() {
558 let entry: ConversationEntry = serde_json::from_str(
559 r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
560 )
561 .unwrap();
562 assert!(to_turn(&entry).is_none());
563 }
564
565 #[test]
566 fn test_entry_to_watcher_event_turn() {
567 let entry: ConversationEntry = serde_json::from_str(
568 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hi"}}"#,
569 )
570 .unwrap();
571 let event = entry_to_watcher_event(&entry);
572 assert!(matches!(event, WatcherEvent::Turn(_)));
573 }
574
575 #[test]
576 fn test_entry_to_watcher_event_progress() {
577 let entry: ConversationEntry = serde_json::from_str(
578 r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
579 )
580 .unwrap();
581 let event = entry_to_watcher_event(&entry);
582 assert!(matches!(event, WatcherEvent::Progress { .. }));
583 }
584
585 #[cfg(feature = "watcher")]
586 #[test]
587 fn test_watcher_trait_basic() {
588 let temp = TempDir::new().unwrap();
589 let claude_dir = temp.path().join(".claude");
590 let project_dir = claude_dir.join("projects/-test-project");
591 fs::create_dir_all(&project_dir).unwrap();
592
593 let entries = vec![
594 r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
595 r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
596 ];
597 fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap();
598
599 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
600 let manager = ClaudeConvo::with_resolver(resolver);
601
602 let mut watcher = crate::watcher::ConversationWatcher::new(
603 manager,
604 "/test/project".to_string(),
605 "session-1".to_string(),
606 );
607
608 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
610 assert_eq!(events.len(), 2);
611 assert!(matches!(&events[0], WatcherEvent::Turn(t) if t.role == Role::User));
612 assert!(matches!(&events[1], WatcherEvent::Turn(t) if t.role == Role::Assistant));
613 assert_eq!(toolpath_convo::ConversationWatcher::seen_count(&watcher), 2);
614
615 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
617 assert!(events.is_empty());
618 }
619
620 #[cfg(feature = "watcher")]
621 #[test]
622 fn test_watcher_trait_assembles_tool_results() {
623 let temp = TempDir::new().unwrap();
624 let claude_dir = temp.path().join(".claude");
625 let project_dir = claude_dir.join("projects/-test-project");
626 fs::create_dir_all(&project_dir).unwrap();
627
628 let entries = vec![
629 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read the file"}}"#,
630 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"}}]}}"#,
631 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}]}}"#,
632 r#"{"uuid":"u4","type":"assistant","timestamp":"2024-01-01T00:00:03Z","message":{"role":"assistant","content":"Done!"}}"#,
633 ];
634 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
635
636 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
637 let manager = ClaudeConvo::with_resolver(resolver);
638
639 let mut watcher = crate::watcher::ConversationWatcher::new(
640 manager,
641 "/test/project".to_string(),
642 "s1".to_string(),
643 );
644
645 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
646
647 assert_eq!(events.len(), 4);
649
650 assert!(matches!(&events[0], WatcherEvent::Turn(t) if t.role == Role::User));
652
653 assert!(matches!(&events[1], WatcherEvent::Turn(t) if t.role == Role::Assistant));
655
656 match &events[2] {
658 WatcherEvent::TurnUpdated(turn) => {
659 assert_eq!(turn.id, "u2");
660 assert_eq!(turn.tool_uses.len(), 1);
661 let result = turn.tool_uses[0].result.as_ref().unwrap();
662 assert_eq!(result.content, "fn main() {}");
663 assert!(!result.is_error);
664 }
665 other => panic!("Expected TurnUpdated, got {:?}", other),
666 }
667
668 assert!(matches!(&events[3], WatcherEvent::Turn(t) if t.text == "Done!"));
670 }
671
672 #[cfg(feature = "watcher")]
673 #[test]
674 fn test_watcher_trait_incremental_tool_results() {
675 let temp = TempDir::new().unwrap();
677 let claude_dir = temp.path().join(".claude");
678 let project_dir = claude_dir.join("projects/-test-project");
679 fs::create_dir_all(&project_dir).unwrap();
680
681 let entries_phase1 = vec![
683 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read file"}}"#,
684 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"}}]}}"#,
685 ];
686 fs::write(
687 project_dir.join("s1.jsonl"),
688 entries_phase1.join("\n") + "\n",
689 )
690 .unwrap();
691
692 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
693 let manager = ClaudeConvo::with_resolver(resolver);
694
695 let mut watcher = crate::watcher::ConversationWatcher::new(
696 manager,
697 "/test/project".to_string(),
698 "s1".to_string(),
699 );
700
701 let events1 = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
703 assert_eq!(events1.len(), 2);
704 if let WatcherEvent::Turn(t) = &events1[1] {
706 assert!(t.tool_uses[0].result.is_none());
707 } else {
708 panic!("Expected Turn");
709 }
710
711 use std::io::Write;
713 let mut file = fs::OpenOptions::new()
714 .append(true)
715 .open(project_dir.join("s1.jsonl"))
716 .unwrap();
717 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();
718
719 let events2 = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
721 assert!(events2.is_empty() || events2.iter().all(|e| !matches!(e, WatcherEvent::Turn(_))));
728 }
729
730 #[test]
731 fn test_merge_tool_results_by_id() {
732 let mut turns = vec![Turn {
734 id: "t1".into(),
735 parent_id: None,
736 role: Role::Assistant,
737 timestamp: "2024-01-01T00:00:00Z".into(),
738 text: "test".into(),
739 thinking: None,
740 tool_uses: vec![
741 ToolInvocation {
742 id: "tool-a".into(),
743 name: "Read".into(),
744 input: serde_json::json!({}),
745 result: None,
746 },
747 ToolInvocation {
748 id: "tool-b".into(),
749 name: "Write".into(),
750 input: serde_json::json!({}),
751 result: None,
752 },
753 ],
754 model: None,
755 stop_reason: None,
756 token_usage: None,
757 extra: Default::default(),
758 }];
759
760 let msg: Message = serde_json::from_str(
762 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}]}"#,
763 )
764 .unwrap();
765
766 let merged = merge_tool_results(&mut turns, &msg);
767 assert!(merged);
768
769 assert_eq!(
771 turns[0].tool_uses[0].result.as_ref().unwrap().content,
772 "read result"
773 );
774 assert!(turns[0].tool_uses[0].result.as_ref().unwrap().is_error);
775
776 assert_eq!(
777 turns[0].tool_uses[1].result.as_ref().unwrap().content,
778 "write result"
779 );
780 assert!(!turns[0].tool_uses[1].result.as_ref().unwrap().is_error);
781 }
782
783 #[test]
784 fn test_is_tool_result_only() {
785 let entry: ConversationEntry = serde_json::from_str(
787 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}]}}"#,
788 )
789 .unwrap();
790 assert!(is_tool_result_only(&entry));
791
792 let entry: ConversationEntry = serde_json::from_str(
794 r#"{"uuid":"u2","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
795 )
796 .unwrap();
797 assert!(!is_tool_result_only(&entry));
798
799 let entry: ConversationEntry = serde_json::from_str(
801 r#"{"uuid":"u3","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
802 )
803 .unwrap();
804 assert!(!is_tool_result_only(&entry));
805
806 let entry: ConversationEntry = serde_json::from_str(
808 r#"{"uuid":"u4","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"hi"}}"#,
809 )
810 .unwrap();
811 assert!(!is_tool_result_only(&entry));
812 }
813}