1use std::collections::HashMap;
9
10use crate::ClaudeConvo;
11use crate::types::{Conversation, ConversationEntry, Message, MessageContent, MessageRole};
12#[cfg(any(feature = "watcher", test))]
13use toolpath_convo::WatcherEvent;
14use toolpath_convo::{
15 ConversationMeta, ConversationProvider, ConversationView, ConvoError, DelegatedWork,
16 EnvironmentSnapshot, Role, TokenUsage, ToolCategory, ToolInvocation, ToolResult, Turn,
17};
18
19fn claude_role_to_role(role: &MessageRole) -> Role {
22 match role {
23 MessageRole::User => Role::User,
24 MessageRole::Assistant => Role::Assistant,
25 MessageRole::System => Role::System,
26 }
27}
28
29pub fn tool_category(name: &str) -> Option<ToolCategory> {
34 match name {
35 "Read" => Some(ToolCategory::FileRead),
36 "Glob" | "Grep" => Some(ToolCategory::FileSearch),
37 "Write" | "Edit" | "MultiEdit" | "NotebookEdit" => Some(ToolCategory::FileWrite),
38 "Bash" => Some(ToolCategory::Shell),
39 "WebFetch" | "WebSearch" => Some(ToolCategory::Network),
40 "Task" | "Agent" => Some(ToolCategory::Delegation),
41 _ => None,
42 }
43}
44
45pub fn native_name(category: ToolCategory, args: &serde_json::Value) -> Option<&'static str> {
54 let has = |k: &str| args.get(k).is_some();
55 match category {
56 ToolCategory::Shell => Some("Bash"),
57 ToolCategory::FileRead => Some("Read"),
58 ToolCategory::FileWrite => Some(if has("old_string") || has("oldString") {
59 "Edit"
60 } else {
61 "Write"
62 }),
63 ToolCategory::FileSearch => Some(
64 if has("output_mode") || has("path_pattern") || has("type") {
68 "Grep"
69 } else {
70 "Glob"
71 },
72 ),
73 ToolCategory::Network => Some(if has("url") { "WebFetch" } else { "WebSearch" }),
74 ToolCategory::Delegation => Some("Task"),
75 }
76}
77
78fn message_to_turn(entry: &ConversationEntry, msg: &Message) -> Turn {
81 let text = msg.text();
82
83 let thinking = msg.thinking().map(|parts| parts.join("\n"));
84
85 let tool_uses: Vec<ToolInvocation> = msg
86 .tool_uses()
87 .into_iter()
88 .map(|tu| {
89 let result = find_tool_result_in_parts(msg, tu.id);
90 let category = tool_category(tu.name);
91 ToolInvocation {
92 id: tu.id.to_string(),
93 name: tu.name.to_string(),
94 input: tu.input.clone(),
95 result,
96 category,
97 }
98 })
99 .collect();
100
101 let file_mutations = compute_file_mutations(&tool_uses, entry.cwd.as_deref());
102
103 let token_usage = msg.usage.as_ref().map(|u| TokenUsage {
104 input_tokens: u.input_tokens,
105 output_tokens: u.output_tokens,
106 cache_read_tokens: u.cache_read_input_tokens,
107 cache_write_tokens: u.cache_creation_input_tokens,
108 });
109
110 let environment = if entry.cwd.is_some() || entry.git_branch.is_some() {
111 Some(EnvironmentSnapshot {
112 working_dir: entry.cwd.clone(),
113 vcs_branch: entry.git_branch.clone(),
114 vcs_revision: None,
115 })
116 } else {
117 None
118 };
119
120 let delegations = extract_delegations(&tool_uses);
121
122 Turn {
123 id: entry.uuid.clone(),
124 parent_id: entry.parent_uuid.clone(),
125 role: claude_role_to_role(&msg.role),
126 timestamp: entry.timestamp.clone(),
127 text,
128 thinking,
129 tool_uses,
130 model: msg.model.clone(),
131 stop_reason: msg.stop_reason.clone(),
132 token_usage,
133 environment,
134 delegations,
135 file_mutations,
136 }
137}
138
139fn compute_file_mutations(
144 tool_uses: &[ToolInvocation],
145 cwd: Option<&str>,
146) -> Vec<toolpath_convo::FileMutation> {
147 let mut out = Vec::new();
148 for tu in tool_uses {
149 if tu.category != Some(ToolCategory::FileWrite) {
150 continue;
151 }
152 let Some(path) = extract_file_path_for_tool(&tu.input) else {
153 continue;
154 };
155 let before_state = if tu.name == "Write" {
160 cwd.and_then(|c| git_head_content(c, &path))
161 } else {
162 None
163 };
164 let raw_diff =
165 toolpath_convo::file_write_diff(&tu.name, &tu.input, &path, before_state.as_deref());
166 let operation = match tu.name.as_str() {
167 "Write" => Some("add".to_string()),
168 "Edit" | "MultiEdit" | "NotebookEdit" => Some("update".to_string()),
169 _ => None,
170 };
171 let after = match tu.name.as_str() {
172 "Write" => tu
173 .input
174 .get("content")
175 .and_then(|v| v.as_str())
176 .map(|s| s.to_string()),
177 _ => None,
178 };
179 out.push(toolpath_convo::FileMutation {
180 path,
181 tool_id: Some(tu.id.clone()),
182 operation,
183 raw_diff,
184 before: before_state,
185 after,
186 rename_to: None,
187 });
188 }
189 out
190}
191
192fn git_head_content(repo_dir: &str, path: &str) -> Option<String> {
198 use std::path::Path as FsPath;
199 use std::process::Command;
200 let repo = FsPath::new(repo_dir);
201 let file = FsPath::new(path);
202 let rel = if file.is_absolute() {
203 file.strip_prefix(repo).ok()?.to_path_buf()
204 } else {
205 file.to_path_buf()
206 };
207 let rel_str = rel.to_string_lossy().replace('\\', "/");
208 let output = Command::new("git")
209 .arg("-C")
210 .arg(repo)
211 .arg("show")
212 .arg(format!("HEAD:{rel_str}"))
213 .output()
214 .ok()?;
215 if !output.status.success() {
216 return None;
217 }
218 String::from_utf8(output.stdout).ok()
219}
220
221fn extract_file_path_for_tool(input: &serde_json::Value) -> Option<String> {
222 for k in ["file_path", "path", "filename", "file"] {
223 if let Some(s) = input.get(k).and_then(|v| v.as_str()) {
224 return Some(s.to_string());
225 }
226 }
227 None
228}
229
230fn extract_delegations(tool_uses: &[ToolInvocation]) -> Vec<DelegatedWork> {
232 tool_uses
233 .iter()
234 .filter(|tu| tu.category == Some(ToolCategory::Delegation))
235 .map(|tu| DelegatedWork {
236 agent_id: tu.id.clone(),
237 prompt: tu
238 .input
239 .get("prompt")
240 .and_then(|v| v.as_str())
241 .unwrap_or("")
242 .to_string(),
243 turns: vec![],
244 result: tu.result.as_ref().map(|r| r.content.clone()),
245 })
246 .collect()
247}
248
249fn find_tool_result_in_parts(msg: &Message, tool_use_id: &str) -> Option<ToolResult> {
250 let parts = match &msg.content {
251 Some(MessageContent::Parts(parts)) => parts,
252 _ => return None,
253 };
254 parts.iter().find_map(|p| match p {
255 crate::types::ContentPart::ToolResult {
256 tool_use_id: id,
257 content,
258 is_error,
259 } if id == tool_use_id => Some(ToolResult {
260 content: content.text(),
261 is_error: *is_error,
262 }),
263 _ => None,
264 })
265}
266
267fn is_tool_result_only(entry: &ConversationEntry) -> bool {
270 let Some(msg) = &entry.message else {
271 return false;
272 };
273 msg.role == MessageRole::User && msg.text().is_empty() && !msg.tool_results().is_empty()
274}
275
276fn merge_tool_results(turns: &mut [Turn], msg: &Message) -> bool {
285 let mut merged = false;
286 for tr in msg.tool_results() {
287 for turn in turns.iter_mut().rev() {
288 if let Some(invocation) = turn
289 .tool_uses
290 .iter_mut()
291 .find(|tu| tu.id == tr.tool_use_id && tu.result.is_none())
292 {
293 invocation.result = Some(ToolResult {
294 content: tr.content.text(),
295 is_error: tr.is_error,
296 });
297 merged = true;
298 break;
299 }
300 }
301 }
302 merged
303}
304
305fn entry_to_turn(entry: &ConversationEntry) -> Option<Turn> {
306 entry
307 .message
308 .as_ref()
309 .map(|msg| message_to_turn(entry, msg))
310}
311
312fn conversation_to_view(convo: &Conversation) -> ConversationView {
317 let mut turns: Vec<Turn> = Vec::new();
318 let mut events: Vec<toolpath_convo::ConversationEvent> = Vec::new();
319
320 for (idx, raw) in convo.preamble.iter().enumerate() {
324 events.push(preamble_to_event(idx, raw));
325 }
326
327 let mut parent_rewrites: HashMap<String, String> = HashMap::new();
335 let mut last_turn_uuid: Option<String> = None;
336
337 for entry in &convo.entries {
338 let Some(msg) = &entry.message else {
339 events.push(entry_to_event(entry));
342 if let Some(prev) = &last_turn_uuid {
343 parent_rewrites.insert(entry.uuid.clone(), prev.clone());
344 }
345 continue;
346 };
347
348 if is_tool_result_only(entry) {
358 merge_tool_results(&mut turns, msg);
359 if let Some(prev) = &last_turn_uuid {
360 parent_rewrites.insert(entry.uuid.clone(), prev.clone());
361 }
362 continue;
363 }
364
365 let mut turn = message_to_turn(entry, msg);
366 if let Some(pid) = turn.parent_id.as_ref()
367 && let Some(real) = parent_rewrites.get(pid)
368 {
369 turn.parent_id = Some(real.clone());
370 }
371 last_turn_uuid = Some(turn.id.clone());
372 turns.push(turn);
373 }
374
375 for turn in &mut turns {
377 for delegation in &mut turn.delegations {
378 if delegation.result.is_none()
379 && let Some(tu) = turn
380 .tool_uses
381 .iter()
382 .find(|tu| tu.id == delegation.agent_id)
383 {
384 delegation.result = tu.result.as_ref().map(|r| r.content.clone());
385 }
386 }
387 }
388
389 let total_usage = sum_usage(&turns);
390 let files_changed = extract_files_changed(&turns);
391
392 let mut base = toolpath_convo::SessionBase::default();
397 let mut producer_version: Option<String> = None;
398 for entry in &convo.entries {
399 if base.working_dir.is_none()
400 && let Some(cwd) = &entry.cwd
401 {
402 base.working_dir = Some(cwd.clone());
403 }
404 if base.vcs_branch.is_none()
405 && let Some(b) = &entry.git_branch
406 {
407 base.vcs_branch = Some(b.clone());
408 }
409 if producer_version.is_none()
410 && let Some(v) = &entry.version
411 {
412 producer_version = Some(v.clone());
413 }
414 if base.working_dir.is_some() && base.vcs_branch.is_some() && producer_version.is_some() {
415 break;
416 }
417 }
418 let view_base = if base.working_dir.is_some()
419 || base.vcs_branch.is_some()
420 || base.vcs_revision.is_some()
421 || base.vcs_remote.is_some()
422 {
423 Some(base)
424 } else {
425 None
426 };
427 let producer = producer_version.map(|v| toolpath_convo::ProducerInfo {
428 name: "claude-code".into(),
429 version: Some(v),
430 });
431
432 ConversationView {
433 id: convo.session_id.clone(),
434 started_at: convo.started_at,
435 last_activity: convo.last_activity,
436 turns,
437 total_usage,
438 provider_id: Some("claude-code".into()),
439 files_changed,
440 session_ids: vec![],
441 events,
442 base: view_base,
443 producer,
444 }
445}
446
447fn preamble_to_event(idx: usize, raw: &serde_json::Value) -> toolpath_convo::ConversationEvent {
457 let event_type = raw
458 .get("type")
459 .and_then(|v| v.as_str())
460 .unwrap_or("preamble")
461 .to_string();
462 let timestamp = raw
463 .get("timestamp")
464 .and_then(|v| v.as_str())
465 .unwrap_or("")
466 .to_string();
467 let mut data: HashMap<String, serde_json::Value> = HashMap::new();
468 data.insert("raw".to_string(), raw.clone());
469 toolpath_convo::ConversationEvent {
470 id: format!("claude-preamble-{idx}"),
471 timestamp,
472 parent_id: None,
473 event_type,
474 data,
475 }
476}
477
478fn entry_to_event(entry: &ConversationEntry) -> toolpath_convo::ConversationEvent {
484 let mut data = HashMap::new();
485 if let Some(v) = &entry.cwd {
486 data.insert("cwd".into(), serde_json::Value::String(v.clone()));
487 }
488 if let Some(v) = &entry.git_branch {
489 data.insert("git_branch".into(), serde_json::Value::String(v.clone()));
490 }
491 if let Some(v) = &entry.version {
492 data.insert("version".into(), serde_json::Value::String(v.clone()));
493 }
494 if let Some(v) = &entry.user_type {
495 data.insert("user_type".into(), serde_json::Value::String(v.clone()));
496 }
497 if let Some(v) = &entry.message_id {
498 data.insert("message_id".into(), serde_json::Value::String(v.clone()));
499 }
500 if let Some(v) = &entry.tool_use_result {
501 data.insert("tool_use_result".into(), v.clone());
502 }
503 if let Some(v) = &entry.snapshot {
504 data.insert("snapshot".into(), v.clone());
505 }
506 if !entry.extra.is_empty()
507 && let Ok(value) = serde_json::to_value(&entry.extra)
508 {
509 data.insert("entry_extra".into(), value);
510 }
511 toolpath_convo::ConversationEvent {
512 id: entry.uuid.clone(),
513 timestamp: entry.timestamp.clone(),
514 parent_id: entry.parent_uuid.clone(),
515 event_type: entry.entry_type.clone(),
516 data,
517 }
518}
519
520fn sum_usage(turns: &[Turn]) -> Option<TokenUsage> {
522 let mut total = TokenUsage::default();
523 let mut any = false;
524 for turn in turns {
525 if let Some(u) = &turn.token_usage {
526 any = true;
527 total.input_tokens =
528 Some(total.input_tokens.unwrap_or(0) + u.input_tokens.unwrap_or(0));
529 total.output_tokens =
530 Some(total.output_tokens.unwrap_or(0) + u.output_tokens.unwrap_or(0));
531 total.cache_read_tokens = match (total.cache_read_tokens, u.cache_read_tokens) {
532 (Some(a), Some(b)) => Some(a + b),
533 (Some(a), None) => Some(a),
534 (None, Some(b)) => Some(b),
535 (None, None) => None,
536 };
537 total.cache_write_tokens = match (total.cache_write_tokens, u.cache_write_tokens) {
538 (Some(a), Some(b)) => Some(a + b),
539 (Some(a), None) => Some(a),
540 (None, Some(b)) => Some(b),
541 (None, None) => None,
542 };
543 }
544 }
545 if any { Some(total) } else { None }
546}
547
548fn extract_files_changed(turns: &[Turn]) -> Vec<String> {
550 let mut seen = std::collections::HashSet::new();
551 let mut files = Vec::new();
552 for turn in turns {
553 for tool_use in &turn.tool_uses {
554 if tool_use.category == Some(ToolCategory::FileWrite)
555 && let Some(path) = tool_use.input.get("file_path").and_then(|v| v.as_str())
556 && seen.insert(path.to_string())
557 {
558 files.push(path.to_string());
559 }
560 }
561 }
562 files
563}
564
565#[cfg(any(feature = "watcher", test))]
566fn entry_to_watcher_event(entry: &ConversationEntry) -> WatcherEvent {
567 match entry_to_turn(entry) {
568 Some(turn) => WatcherEvent::Turn(Box::new(turn)),
569 None => {
570 let mut data = serde_json::json!({
571 "uuid": entry.uuid,
572 "timestamp": entry.timestamp,
573 });
574 if !entry.extra.is_empty() {
575 data["claude"] = serde_json::to_value(&entry.extra).unwrap_or_default();
576 }
577 WatcherEvent::Progress {
578 kind: entry.entry_type.clone(),
579 data,
580 }
581 }
582 }
583}
584
585impl ConversationProvider for ClaudeConvo {
588 fn list_conversations(&self, project: &str) -> toolpath_convo::Result<Vec<String>> {
589 crate::ClaudeConvo::list_conversations(self, project)
590 .map_err(|e| ConvoError::Provider(e.to_string()))
591 }
592
593 fn load_conversation(
594 &self,
595 project: &str,
596 conversation_id: &str,
597 ) -> toolpath_convo::Result<ConversationView> {
598 let convo = self
599 .read_conversation(project, conversation_id)
600 .map_err(|e| ConvoError::Provider(e.to_string()))?;
601 let mut view = conversation_to_view(&convo);
602 view.session_ids = convo.session_ids.clone();
603 Ok(view)
604 }
605
606 fn load_metadata(
607 &self,
608 project: &str,
609 conversation_id: &str,
610 ) -> toolpath_convo::Result<ConversationMeta> {
611 let meta = self
612 .read_conversation_metadata(project, conversation_id)
613 .map_err(|e| ConvoError::Provider(e.to_string()))?;
614
615 Ok(ConversationMeta {
616 id: meta.session_id,
617 started_at: meta.started_at,
618 last_activity: meta.last_activity,
619 message_count: meta.message_count,
620 file_path: Some(meta.file_path),
621 predecessor: None,
622 successor: None,
623 })
624 }
625
626 fn list_metadata(&self, project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
627 let metas = self
628 .list_conversation_metadata(project)
629 .map_err(|e| ConvoError::Provider(e.to_string()))?;
630
631 Ok(metas
632 .into_iter()
633 .map(|m| ConversationMeta {
634 id: m.session_id,
635 started_at: m.started_at,
636 last_activity: m.last_activity,
637 message_count: m.message_count,
638 file_path: Some(m.file_path),
639 predecessor: None,
640 successor: None,
641 })
642 .collect())
643 }
644}
645
646#[cfg(feature = "watcher")]
649impl toolpath_convo::ConversationWatcher for crate::watcher::ConversationWatcher {
650 fn poll(&mut self) -> toolpath_convo::Result<Vec<WatcherEvent>> {
651 let entries = crate::watcher::ConversationWatcher::poll(self)
652 .map_err(|e| ConvoError::Provider(e.to_string()))?;
653
654 let mut events: Vec<WatcherEvent> = Vec::new();
655
656 for (from, to) in self.take_pending_rotations() {
658 events.push(WatcherEvent::Progress {
659 kind: "session_rotated".into(),
660 data: serde_json::json!({
661 "from": from,
662 "to": to,
663 }),
664 });
665 }
666
667 for entry in &entries {
668 let Some(msg) = &entry.message else {
669 events.push(entry_to_watcher_event(entry));
670 continue;
671 };
672
673 if is_tool_result_only(entry) {
674 let mut updated_turn: Option<Turn> = None;
678
679 for event in events.iter_mut().rev() {
681 if let WatcherEvent::Turn(turn) | WatcherEvent::TurnUpdated(turn) = event
682 && turn.tool_uses.iter().any(|tu| {
683 tu.result.is_none()
684 && msg.tool_results().iter().any(|tr| tr.tool_use_id == tu.id)
685 })
686 {
687 let mut updated = (**turn).clone();
689 merge_tool_results(std::slice::from_mut(&mut updated), msg);
690 updated_turn = Some(updated.clone());
691 **turn = updated;
694 break;
695 }
696 }
697
698 if let Some(turn) = updated_turn {
699 events.push(WatcherEvent::TurnUpdated(Box::new(turn)));
700 }
701 continue;
705 }
706
707 events.push(entry_to_watcher_event(entry));
708 }
709
710 Ok(events)
711 }
712
713 fn seen_count(&self) -> usize {
714 crate::watcher::ConversationWatcher::seen_count(self)
715 }
716}
717
718pub fn to_view(convo: &Conversation) -> ConversationView {
726 conversation_to_view(convo)
727}
728
729pub fn to_turn(entry: &ConversationEntry) -> Option<Turn> {
735 entry_to_turn(entry)
736}
737
738#[cfg(test)]
741mod tests {
742 use super::*;
743 use crate::PathResolver;
744 use std::fs;
745 use tempfile::TempDir;
746
747 fn setup_provider() -> (TempDir, ClaudeConvo) {
748 let temp = TempDir::new().unwrap();
749 let claude_dir = temp.path().join(".claude");
750 let project_dir = claude_dir.join("projects/-test-project");
751 fs::create_dir_all(&project_dir).unwrap();
752
753 let entries = [
754 r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Fix the bug"}}"#,
755 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}}}"#,
756 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}]}}"#,
757 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}}}"#,
758 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}]}}"#,
759 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"}}"#,
760 r#"{"uuid":"uuid-7","type":"user","parentUuid":"uuid-6","timestamp":"2024-01-01T00:00:06Z","message":{"role":"user","content":"Thanks!"}}"#,
761 ];
762 fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap();
763
764 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
765 (temp, ClaudeConvo::with_resolver(resolver))
766 }
767
768 #[test]
769 fn test_load_conversation_assembles_tool_results() {
770 let (_temp, provider) = setup_provider();
771 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
772 .unwrap();
773
774 assert_eq!(view.id, "session-1");
775 assert_eq!(view.turns.len(), 5);
777
778 assert_eq!(view.turns[0].role, Role::User);
780 assert_eq!(view.turns[0].text, "Fix the bug");
781 assert!(view.turns[0].parent_id.is_none());
782
783 assert_eq!(view.turns[1].role, Role::Assistant);
785 assert_eq!(view.turns[1].text, "I'll fix that.");
786 assert_eq!(
787 view.turns[1].thinking.as_deref(),
788 Some("The bug is in auth")
789 );
790 assert_eq!(view.turns[1].tool_uses.len(), 1);
791 assert_eq!(view.turns[1].tool_uses[0].name, "Read");
792 assert_eq!(view.turns[1].tool_uses[0].id, "t1");
793 let result = view.turns[1].tool_uses[0].result.as_ref().unwrap();
795 assert!(!result.is_error);
796 assert!(result.content.contains("fn main()"));
797 assert_eq!(view.turns[1].model.as_deref(), Some("claude-opus-4-6"));
798 assert_eq!(view.turns[1].stop_reason.as_deref(), Some("tool_use"));
799 assert_eq!(view.turns[1].parent_id.as_deref(), Some("uuid-1"));
800
801 let usage = view.turns[1].token_usage.as_ref().unwrap();
803 assert_eq!(usage.input_tokens, Some(100));
804 assert_eq!(usage.output_tokens, Some(50));
805
806 assert_eq!(view.turns[2].role, Role::Assistant);
808 assert_eq!(view.turns[2].text, "I see the issue. Let me fix it.");
809 assert_eq!(view.turns[2].tool_uses[0].name, "Edit");
810 let result2 = view.turns[2].tool_uses[0].result.as_ref().unwrap();
811 assert_eq!(result2.content, "File written successfully");
812
813 assert_eq!(view.turns[3].role, Role::Assistant);
815 assert_eq!(view.turns[3].text, "Done! The bug is fixed.");
816 assert!(view.turns[3].tool_uses.is_empty());
817
818 assert_eq!(view.turns[4].role, Role::User);
820 assert_eq!(view.turns[4].text, "Thanks!");
821 }
822
823 #[test]
824 fn test_no_phantom_empty_turns() {
825 let (_temp, provider) = setup_provider();
826 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
827 .unwrap();
828
829 for turn in &view.turns {
831 if turn.role == Role::User {
832 assert!(
833 !turn.text.is_empty(),
834 "Found phantom empty user turn: {:?}",
835 turn.id
836 );
837 }
838 }
839 }
840
841 #[test]
842 fn test_tool_result_error_flag() {
843 let temp = TempDir::new().unwrap();
844 let claude_dir = temp.path().join(".claude");
845 let project_dir = claude_dir.join("projects/-test-project");
846 fs::create_dir_all(&project_dir).unwrap();
847
848 let entries = [
849 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read a file"}}"#,
850 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"}}"#,
851 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}]}}"#,
852 ];
853 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
854
855 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
856 let provider = ClaudeConvo::with_resolver(resolver);
857 let view =
858 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
859
860 assert_eq!(view.turns.len(), 2); let result = view.turns[1].tool_uses[0].result.as_ref().unwrap();
862 assert!(result.is_error);
863 assert_eq!(result.content, "File not found");
864 }
865
866 #[test]
867 fn test_multiple_tool_uses_single_result_entry() {
868 let temp = TempDir::new().unwrap();
869 let claude_dir = temp.path().join(".claude");
870 let project_dir = claude_dir.join("projects/-test-project");
871 fs::create_dir_all(&project_dir).unwrap();
872
873 let entries = [
874 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Check two files"}}"#,
875 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"}}]}}"#,
876 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}]}}"#,
877 ];
878 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
879
880 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
881 let provider = ClaudeConvo::with_resolver(resolver);
882 let view =
883 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
884
885 assert_eq!(view.turns.len(), 2);
886 assert_eq!(view.turns[1].tool_uses.len(), 2);
887
888 let r1 = view.turns[1].tool_uses[0].result.as_ref().unwrap();
889 assert_eq!(r1.content, "file a contents");
890
891 let r2 = view.turns[1].tool_uses[1].result.as_ref().unwrap();
892 assert_eq!(r2.content, "file b contents");
893 }
894
895 #[test]
896 fn test_conversation_without_tool_use_unchanged() {
897 let temp = TempDir::new().unwrap();
898 let claude_dir = temp.path().join(".claude");
899 let project_dir = claude_dir.join("projects/-test-project");
900 fs::create_dir_all(&project_dir).unwrap();
901
902 let entries = [
903 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
904 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi there!"}}"#,
905 ];
906 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
907
908 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
909 let provider = ClaudeConvo::with_resolver(resolver);
910 let view =
911 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
912
913 assert_eq!(view.turns.len(), 2);
914 assert_eq!(view.turns[0].text, "Hello");
915 assert_eq!(view.turns[1].text, "Hi there!");
916 }
917
918 #[test]
919 fn test_assistant_turn_without_result_has_none() {
920 let temp = TempDir::new().unwrap();
922 let claude_dir = temp.path().join(".claude");
923 let project_dir = claude_dir.join("projects/-test-project");
924 fs::create_dir_all(&project_dir).unwrap();
925
926 let entries = [
927 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read a file"}}"#,
928 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"}}]}}"#,
929 ];
930 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
931
932 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
933 let provider = ClaudeConvo::with_resolver(resolver);
934 let view =
935 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
936
937 assert_eq!(view.turns.len(), 2);
938 assert!(view.turns[1].tool_uses[0].result.is_none());
939 }
940
941 #[test]
942 fn test_list_conversations() {
943 let (_temp, provider) = setup_provider();
944 let ids = ConversationProvider::list_conversations(&provider, "/test/project").unwrap();
945 assert_eq!(ids, vec!["session-1"]);
946 }
947
948 #[test]
949 fn test_load_metadata() {
950 let (_temp, provider) = setup_provider();
951 let meta =
952 ConversationProvider::load_metadata(&provider, "/test/project", "session-1").unwrap();
953 assert_eq!(meta.id, "session-1");
954 assert_eq!(meta.message_count, 7);
955 assert!(meta.file_path.is_some());
956 }
957
958 #[test]
959 fn test_list_metadata() {
960 let (_temp, provider) = setup_provider();
961 let metas = ConversationProvider::list_metadata(&provider, "/test/project").unwrap();
962 assert_eq!(metas.len(), 1);
963 assert_eq!(metas[0].id, "session-1");
964 }
965
966 #[test]
967 fn test_to_view() {
968 let (_temp, manager) = setup_provider();
969 let convo = manager
970 .read_conversation("/test/project", "session-1")
971 .unwrap();
972 let view = to_view(&convo);
973 assert_eq!(view.turns.len(), 5);
974 assert_eq!(view.title(20).unwrap(), "Fix the bug");
975 }
976
977 #[test]
978 fn test_to_turn_with_message() {
979 let entry: ConversationEntry = serde_json::from_str(
980 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
981 )
982 .unwrap();
983 let turn = to_turn(&entry).unwrap();
984 assert_eq!(turn.id, "u1");
985 assert_eq!(turn.text, "hello");
986 assert_eq!(turn.role, Role::User);
987 }
988
989 #[test]
990 fn test_to_turn_without_message() {
991 let entry: ConversationEntry = serde_json::from_str(
992 r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
993 )
994 .unwrap();
995 assert!(to_turn(&entry).is_none());
996 }
997
998 #[test]
999 fn test_entry_to_watcher_event_turn() {
1000 let entry: ConversationEntry = serde_json::from_str(
1001 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hi"}}"#,
1002 )
1003 .unwrap();
1004 let event = entry_to_watcher_event(&entry);
1005 assert!(matches!(event, WatcherEvent::Turn(_)));
1006 }
1007
1008 #[test]
1009 fn test_entry_to_watcher_event_progress() {
1010 let entry: ConversationEntry = serde_json::from_str(
1011 r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
1012 )
1013 .unwrap();
1014 let event = entry_to_watcher_event(&entry);
1015 assert!(matches!(event, WatcherEvent::Progress { .. }));
1016 }
1017
1018 #[cfg(feature = "watcher")]
1019 #[test]
1020 fn test_watcher_trait_basic() {
1021 let temp = TempDir::new().unwrap();
1022 let claude_dir = temp.path().join(".claude");
1023 let project_dir = claude_dir.join("projects/-test-project");
1024 fs::create_dir_all(&project_dir).unwrap();
1025
1026 let entries = [
1027 r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
1028 r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
1029 ];
1030 fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap();
1031
1032 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1033 let manager = ClaudeConvo::with_resolver(resolver);
1034
1035 let mut watcher = crate::watcher::ConversationWatcher::new(
1036 manager,
1037 "/test/project".to_string(),
1038 "session-1".to_string(),
1039 );
1040
1041 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1043 assert_eq!(events.len(), 2);
1044 assert!(matches!(&events[0], WatcherEvent::Turn(t) if t.role == Role::User));
1045 assert!(matches!(&events[1], WatcherEvent::Turn(t) if t.role == Role::Assistant));
1046 assert_eq!(toolpath_convo::ConversationWatcher::seen_count(&watcher), 2);
1047
1048 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1050 assert!(events.is_empty());
1051 }
1052
1053 #[cfg(feature = "watcher")]
1054 #[test]
1055 fn test_watcher_trait_assembles_tool_results() {
1056 let temp = TempDir::new().unwrap();
1057 let claude_dir = temp.path().join(".claude");
1058 let project_dir = claude_dir.join("projects/-test-project");
1059 fs::create_dir_all(&project_dir).unwrap();
1060
1061 let entries = [
1062 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read the file"}}"#,
1063 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"}}]}}"#,
1064 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}]}}"#,
1065 r#"{"uuid":"u4","type":"assistant","timestamp":"2024-01-01T00:00:03Z","message":{"role":"assistant","content":"Done!"}}"#,
1066 ];
1067 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1068
1069 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1070 let manager = ClaudeConvo::with_resolver(resolver);
1071
1072 let mut watcher = crate::watcher::ConversationWatcher::new(
1073 manager,
1074 "/test/project".to_string(),
1075 "s1".to_string(),
1076 );
1077
1078 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1079
1080 assert_eq!(events.len(), 4);
1082
1083 assert!(matches!(&events[0], WatcherEvent::Turn(t) if t.role == Role::User));
1085
1086 assert!(matches!(&events[1], WatcherEvent::Turn(t) if t.role == Role::Assistant));
1088
1089 match &events[2] {
1091 WatcherEvent::TurnUpdated(turn) => {
1092 assert_eq!(turn.id, "u2");
1093 assert_eq!(turn.tool_uses.len(), 1);
1094 let result = turn.tool_uses[0].result.as_ref().unwrap();
1095 assert_eq!(result.content, "fn main() {}");
1096 assert!(!result.is_error);
1097 }
1098 other => panic!("Expected TurnUpdated, got {:?}", other),
1099 }
1100
1101 assert!(matches!(&events[3], WatcherEvent::Turn(t) if t.text == "Done!"));
1103 }
1104
1105 #[cfg(feature = "watcher")]
1106 #[test]
1107 fn test_watcher_trait_incremental_tool_results() {
1108 let temp = TempDir::new().unwrap();
1110 let claude_dir = temp.path().join(".claude");
1111 let project_dir = claude_dir.join("projects/-test-project");
1112 fs::create_dir_all(&project_dir).unwrap();
1113
1114 let entries_phase1 = [
1116 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read file"}}"#,
1117 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"}}]}}"#,
1118 ];
1119 fs::write(
1120 project_dir.join("s1.jsonl"),
1121 entries_phase1.join("\n") + "\n",
1122 )
1123 .unwrap();
1124
1125 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1126 let manager = ClaudeConvo::with_resolver(resolver);
1127
1128 let mut watcher = crate::watcher::ConversationWatcher::new(
1129 manager,
1130 "/test/project".to_string(),
1131 "s1".to_string(),
1132 );
1133
1134 let events1 = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1136 assert_eq!(events1.len(), 2);
1137 if let WatcherEvent::Turn(t) = &events1[1] {
1139 assert!(t.tool_uses[0].result.is_none());
1140 } else {
1141 panic!("Expected Turn");
1142 }
1143
1144 use std::io::Write;
1146 let mut file = fs::OpenOptions::new()
1147 .append(true)
1148 .open(project_dir.join("s1.jsonl"))
1149 .unwrap();
1150 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();
1151
1152 let events2 = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1154 assert!(events2.is_empty() || events2.iter().all(|e| !matches!(e, WatcherEvent::Turn(_))));
1161 }
1162
1163 #[test]
1164 fn test_merge_tool_results_by_id() {
1165 let mut turns = vec![Turn {
1167 id: "t1".into(),
1168 parent_id: None,
1169 role: Role::Assistant,
1170 timestamp: "2024-01-01T00:00:00Z".into(),
1171 text: "test".into(),
1172 thinking: None,
1173 tool_uses: vec![
1174 ToolInvocation {
1175 id: "tool-a".into(),
1176 name: "Read".into(),
1177 input: serde_json::json!({}),
1178 result: None,
1179 category: Some(ToolCategory::FileRead),
1180 },
1181 ToolInvocation {
1182 id: "tool-b".into(),
1183 name: "Write".into(),
1184 input: serde_json::json!({}),
1185 result: None,
1186 category: Some(ToolCategory::FileWrite),
1187 },
1188 ],
1189 model: None,
1190 stop_reason: None,
1191 token_usage: None,
1192 environment: None,
1193 delegations: vec![],
1194 file_mutations: Vec::new(),
1195 }];
1196
1197 let msg: Message = serde_json::from_str(
1199 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}]}"#,
1200 )
1201 .unwrap();
1202
1203 let merged = merge_tool_results(&mut turns, &msg);
1204 assert!(merged);
1205
1206 assert_eq!(
1208 turns[0].tool_uses[0].result.as_ref().unwrap().content,
1209 "read result"
1210 );
1211 assert!(turns[0].tool_uses[0].result.as_ref().unwrap().is_error);
1212
1213 assert_eq!(
1214 turns[0].tool_uses[1].result.as_ref().unwrap().content,
1215 "write result"
1216 );
1217 assert!(!turns[0].tool_uses[1].result.as_ref().unwrap().is_error);
1218 }
1219
1220 #[test]
1221 fn test_is_tool_result_only() {
1222 let entry: ConversationEntry = serde_json::from_str(
1224 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}]}}"#,
1225 )
1226 .unwrap();
1227 assert!(is_tool_result_only(&entry));
1228
1229 let entry: ConversationEntry = serde_json::from_str(
1231 r#"{"uuid":"u2","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
1232 )
1233 .unwrap();
1234 assert!(!is_tool_result_only(&entry));
1235
1236 let entry: ConversationEntry = serde_json::from_str(
1238 r#"{"uuid":"u3","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
1239 )
1240 .unwrap();
1241 assert!(!is_tool_result_only(&entry));
1242
1243 let entry: ConversationEntry = serde_json::from_str(
1245 r#"{"uuid":"u4","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"hi"}}"#,
1246 )
1247 .unwrap();
1248 assert!(!is_tool_result_only(&entry));
1249 }
1250
1251 #[test]
1254 fn test_tool_category_mapping() {
1255 assert_eq!(tool_category("Read"), Some(ToolCategory::FileRead));
1256 assert_eq!(tool_category("Glob"), Some(ToolCategory::FileSearch));
1257 assert_eq!(tool_category("Grep"), Some(ToolCategory::FileSearch));
1258 assert_eq!(tool_category("Write"), Some(ToolCategory::FileWrite));
1259 assert_eq!(tool_category("Edit"), Some(ToolCategory::FileWrite));
1260 assert_eq!(tool_category("NotebookEdit"), Some(ToolCategory::FileWrite));
1261 assert_eq!(tool_category("Bash"), Some(ToolCategory::Shell));
1262 assert_eq!(tool_category("WebFetch"), Some(ToolCategory::Network));
1263 assert_eq!(tool_category("WebSearch"), Some(ToolCategory::Network));
1264 assert_eq!(tool_category("Task"), Some(ToolCategory::Delegation));
1265 assert_eq!(tool_category("UnknownTool"), None);
1266 }
1267
1268 #[test]
1269 fn test_turn_has_tool_category() {
1270 let (_temp, provider) = setup_provider();
1271 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1272 .unwrap();
1273
1274 assert_eq!(
1276 view.turns[1].tool_uses[0].category,
1277 Some(ToolCategory::FileRead)
1278 );
1279 assert_eq!(
1281 view.turns[2].tool_uses[0].category,
1282 Some(ToolCategory::FileWrite)
1283 );
1284 }
1285
1286 #[test]
1287 fn test_environment_populated_from_entry() {
1288 let temp = TempDir::new().unwrap();
1289 let claude_dir = temp.path().join(".claude");
1290 let project_dir = claude_dir.join("projects/-test-project");
1291 fs::create_dir_all(&project_dir).unwrap();
1292
1293 let entries = [
1294 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","cwd":"/project/path","gitBranch":"feat/auth","message":{"role":"user","content":"Hello"}}"#,
1295 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
1296 ];
1297 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1298
1299 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1300 let provider = ClaudeConvo::with_resolver(resolver);
1301 let view =
1302 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1303
1304 let env = view.turns[0].environment.as_ref().unwrap();
1306 assert_eq!(env.working_dir.as_deref(), Some("/project/path"));
1307 assert_eq!(env.vcs_branch.as_deref(), Some("feat/auth"));
1308 assert!(env.vcs_revision.is_none());
1309
1310 assert!(view.turns[1].environment.is_none());
1312 }
1313
1314 #[test]
1315 fn test_cache_tokens_populated() {
1316 let temp = TempDir::new().unwrap();
1317 let claude_dir = temp.path().join(".claude");
1318 let project_dir = claude_dir.join("projects/-test-project");
1319 fs::create_dir_all(&project_dir).unwrap();
1320
1321 let entries = [
1322 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
1323 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}}}"#,
1324 ];
1325 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1326
1327 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1328 let provider = ClaudeConvo::with_resolver(resolver);
1329 let view =
1330 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1331
1332 let usage = view.turns[1].token_usage.as_ref().unwrap();
1333 assert_eq!(usage.cache_read_tokens, Some(500));
1334 assert_eq!(usage.cache_write_tokens, Some(200));
1335 }
1336
1337 #[test]
1338 fn test_total_usage_aggregated() {
1339 let (_temp, provider) = setup_provider();
1340 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1341 .unwrap();
1342
1343 let total = view.total_usage.as_ref().unwrap();
1344 assert_eq!(total.input_tokens, Some(300));
1346 assert_eq!(total.output_tokens, Some(150));
1347 }
1348
1349 #[test]
1350 fn test_provider_id_set() {
1351 let (_temp, provider) = setup_provider();
1352 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1353 .unwrap();
1354
1355 assert_eq!(view.provider_id.as_deref(), Some("claude-code"));
1356 }
1357
1358 #[test]
1359 fn test_files_changed_populated() {
1360 let temp = TempDir::new().unwrap();
1361 let claude_dir = temp.path().join(".claude");
1362 let project_dir = claude_dir.join("projects/-test-project");
1363 fs::create_dir_all(&project_dir).unwrap();
1364
1365 let entries = [
1366 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Edit files"}}"#,
1367 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"}}]}}"#,
1368 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}]}}"#,
1369 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"}}]}}"#,
1370 ];
1371 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1372
1373 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1374 let provider = ClaudeConvo::with_resolver(resolver);
1375 let view =
1376 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1377
1378 assert_eq!(view.files_changed, vec!["src/main.rs", "src/lib.rs"]);
1380 }
1381
1382 #[test]
1383 fn test_delegations_extracted() {
1384 let temp = TempDir::new().unwrap();
1385 let claude_dir = temp.path().join(".claude");
1386 let project_dir = claude_dir.join("projects/-test-project");
1387 fs::create_dir_all(&project_dir).unwrap();
1388
1389 let entries = [
1390 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Search for bugs"}}"#,
1391 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"}}]}}"#,
1392 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}]}}"#,
1393 ];
1394 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1395
1396 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1397 let provider = ClaudeConvo::with_resolver(resolver);
1398 let view =
1399 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1400
1401 assert_eq!(view.turns[1].delegations.len(), 1);
1403 let d = &view.turns[1].delegations[0];
1404 assert_eq!(d.agent_id, "task-1");
1405 assert_eq!(d.prompt, "Find the authentication bug");
1406 assert!(d.turns.is_empty()); assert_eq!(
1409 d.result.as_deref(),
1410 Some("Found the bug in auth.rs line 42")
1411 );
1412 }
1413
1414 #[test]
1415 fn test_progress_data_enriched_with_extras() {
1416 let entry: ConversationEntry = serde_json::from_str(
1417 r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z","data":{"type":"hook_progress","hookName":"pre-commit"}}"#,
1418 )
1419 .unwrap();
1420 let event = entry_to_watcher_event(&entry);
1421 match event {
1422 WatcherEvent::Progress { kind, data } => {
1423 assert_eq!(kind, "progress");
1424 assert_eq!(data["uuid"], "u1");
1425 assert_eq!(data["timestamp"], "2024-01-01T00:00:00Z");
1426 let claude = &data["claude"];
1427 assert_eq!(claude["data"]["type"], "hook_progress");
1428 assert_eq!(claude["data"]["hookName"], "pre-commit");
1429 }
1430 other => panic!(
1431 "Expected Progress, got {:?}",
1432 std::mem::discriminant(&other)
1433 ),
1434 }
1435 }
1436
1437 #[test]
1438 fn test_progress_data_no_claude_key_when_no_extras() {
1439 let entry: ConversationEntry = serde_json::from_str(
1440 r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
1441 )
1442 .unwrap();
1443 let event = entry_to_watcher_event(&entry);
1444 match event {
1445 WatcherEvent::Progress { data, .. } => {
1446 assert!(data.get("claude").is_none());
1447 }
1448 other => panic!(
1449 "Expected Progress, got {:?}",
1450 std::mem::discriminant(&other)
1451 ),
1452 }
1453 }
1454
1455 #[test]
1456 fn test_no_delegations_for_non_task_tools() {
1457 let (_temp, provider) = setup_provider();
1458 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1459 .unwrap();
1460
1461 for turn in &view.turns {
1463 assert!(turn.delegations.is_empty());
1464 }
1465 }
1466
1467 fn setup_chained_provider() -> (TempDir, ClaudeConvo) {
1470 let temp = TempDir::new().unwrap();
1471 let claude_dir = temp.path().join(".claude");
1472 let project_dir = claude_dir.join("projects/-test-project");
1473 fs::create_dir_all(&project_dir).unwrap();
1474
1475 let entries_a = [
1477 r#"{"uuid":"a1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Fix the bug"}}"#,
1478 r#"{"uuid":"a2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","sessionId":"session-a","message":{"role":"assistant","content":"I'll fix that.","model":"claude-opus-4-6","usage":{"input_tokens":100,"output_tokens":50}}}"#,
1479 ];
1480 fs::write(project_dir.join("session-a.jsonl"), entries_a.join("\n")).unwrap();
1481
1482 let entries_b = [
1484 r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Continue the fix"}}"#,
1486 r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"What about the tests?"}}"#,
1488 r#"{"uuid":"b2","type":"assistant","timestamp":"2024-01-01T01:00:02Z","sessionId":"session-b","message":{"role":"assistant","content":"Tests pass now.","model":"claude-opus-4-6","usage":{"input_tokens":200,"output_tokens":100}}}"#,
1489 ];
1490 fs::write(project_dir.join("session-b.jsonl"), entries_b.join("\n")).unwrap();
1491
1492 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1493 (temp, ClaudeConvo::with_resolver(resolver))
1494 }
1495
1496 #[test]
1497 fn test_load_conversation_merges_chain() {
1498 let (_temp, provider) = setup_chained_provider();
1499
1500 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-a")
1502 .unwrap();
1503
1504 assert_eq!(view.turns.len(), 4);
1508 assert_eq!(view.turns[0].text, "Fix the bug");
1509 assert_eq!(view.turns[1].text, "I'll fix that.");
1510 assert_eq!(view.turns[2].text, "What about the tests?");
1511 assert_eq!(view.turns[3].text, "Tests pass now.");
1512
1513 assert_eq!(view.session_ids, vec!["session-a", "session-b"]);
1515 }
1516
1517 #[test]
1518 fn test_load_conversation_skips_bridge_entries() {
1519 let (_temp, provider) = setup_chained_provider();
1520
1521 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-a")
1522 .unwrap();
1523
1524 for turn in &view.turns {
1526 assert_ne!(turn.text, "Continue the fix");
1527 }
1528 }
1529
1530 #[test]
1531 fn test_load_conversation_single_segment_unchanged() {
1532 let temp = TempDir::new().unwrap();
1533 let claude_dir = temp.path().join(".claude");
1534 let project_dir = claude_dir.join("projects/-test-project");
1535 fs::create_dir_all(&project_dir).unwrap();
1536
1537 let entries = [
1538 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"solo","message":{"role":"user","content":"Hello"}}"#,
1539 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","sessionId":"solo","message":{"role":"assistant","content":"Hi there!"}}"#,
1540 ];
1541 fs::write(project_dir.join("solo.jsonl"), entries.join("\n")).unwrap();
1542
1543 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1544 let provider = ClaudeConvo::with_resolver(resolver);
1545 let view =
1546 ConversationProvider::load_conversation(&provider, "/test/project", "solo").unwrap();
1547
1548 assert_eq!(view.turns.len(), 2);
1549 assert_eq!(view.turns[0].text, "Hello");
1550 assert_eq!(view.turns[1].text, "Hi there!");
1551 assert!(view.session_ids.is_empty());
1553 }
1554
1555 #[test]
1556 fn test_list_metadata_chain_transparent() {
1557 let (_temp, provider) = setup_chained_provider();
1558
1559 let metas = ConversationProvider::list_metadata(&provider, "/test/project").unwrap();
1560
1561 assert_eq!(metas.len(), 1);
1563 assert_eq!(metas[0].id, "session-a");
1564
1565 assert!(metas[0].predecessor.is_none());
1567 assert!(metas[0].successor.is_none());
1568 }
1569
1570 #[cfg(feature = "watcher")]
1571 #[test]
1572 fn test_watcher_emits_rotation_progress() {
1573 let temp = TempDir::new().unwrap();
1574 let claude_dir = temp.path().join(".claude");
1575 let project_dir = claude_dir.join("projects/-test-project");
1576 fs::create_dir_all(&project_dir).unwrap();
1577
1578 let entry_a = r#"{"uuid":"a1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Hello"}}"#;
1580 fs::write(
1581 project_dir.join("session-a.jsonl"),
1582 format!("{}\n", entry_a),
1583 )
1584 .unwrap();
1585
1586 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1587 let manager = ClaudeConvo::with_resolver(resolver);
1588
1589 let mut watcher = crate::watcher::ConversationWatcher::new(
1590 manager,
1591 "/test/project".to_string(),
1592 "session-a".to_string(),
1593 );
1594
1595 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1597 assert_eq!(events.len(), 1);
1598 assert!(matches!(&events[0], WatcherEvent::Turn(_)));
1599
1600 let entries_b = [
1602 r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Bridge"}}"#,
1603 r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"New"}}"#,
1604 ];
1605 fs::write(project_dir.join("session-b.jsonl"), entries_b.join("\n")).unwrap();
1606
1607 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1609
1610 assert!(
1612 events.len() >= 2,
1613 "Expected Progress + Turn, got {} events",
1614 events.len()
1615 );
1616 match &events[0] {
1617 WatcherEvent::Progress { kind, data } => {
1618 assert_eq!(kind, "session_rotated");
1619 assert_eq!(data["from"], "session-a");
1620 assert_eq!(data["to"], "session-b");
1621 }
1622 other => panic!("Expected Progress, got {:?}", std::mem::discriminant(other)),
1623 }
1624
1625 match &events[1] {
1627 WatcherEvent::Turn(turn) => {
1628 assert_eq!(turn.id, "b1");
1629 assert_eq!(turn.text, "New");
1630 }
1631 other => panic!("Expected Turn(b1), got {:?}", std::mem::discriminant(other)),
1632 }
1633
1634 for event in &events {
1636 if let WatcherEvent::Turn(t) = event {
1637 assert_ne!(t.id, "b0", "Bridge entry should not appear as a Turn");
1638 }
1639 }
1640 }
1641
1642 #[test]
1643 fn test_load_metadata_chain_transparent() {
1644 let (_temp, provider) = setup_chained_provider();
1645
1646 let meta_a =
1648 ConversationProvider::load_metadata(&provider, "/test/project", "session-a").unwrap();
1649 assert_eq!(meta_a.id, "session-a");
1650 assert_eq!(meta_a.message_count, 5);
1652 assert!(meta_a.predecessor.is_none());
1654 assert!(meta_a.successor.is_none());
1655
1656 let meta_b =
1658 ConversationProvider::load_metadata(&provider, "/test/project", "session-b").unwrap();
1659 assert_eq!(meta_b.id, "session-a"); assert_eq!(meta_b.message_count, 5);
1661 assert!(meta_b.predecessor.is_none());
1662 assert!(meta_b.successor.is_none());
1663 }
1664}