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 ..Default::default()
109 });
110
111 let environment = if entry.cwd.is_some() || entry.git_branch.is_some() {
112 Some(EnvironmentSnapshot {
113 working_dir: entry.cwd.clone(),
114 vcs_branch: entry.git_branch.clone(),
115 vcs_revision: None,
116 })
117 } else {
118 None
119 };
120
121 let delegations = extract_delegations(&tool_uses);
122
123 Turn {
124 id: entry.uuid.clone(),
125 parent_id: entry.parent_uuid.clone(),
126 group_id: msg.id.clone(),
131 role: claude_role_to_role(&msg.role),
132 timestamp: entry.timestamp.clone(),
133 text,
134 thinking,
135 tool_uses,
136 model: msg.model.clone(),
137 stop_reason: msg.stop_reason.clone(),
138 token_usage,
139 attributed_token_usage: None,
140 environment,
141 delegations,
142 file_mutations,
143 }
144}
145
146fn compute_file_mutations(
151 tool_uses: &[ToolInvocation],
152 cwd: Option<&str>,
153) -> Vec<toolpath_convo::FileMutation> {
154 let mut out = Vec::new();
155 for tu in tool_uses {
156 if tu.category != Some(ToolCategory::FileWrite) {
157 continue;
158 }
159 let Some(path) = extract_file_path_for_tool(&tu.input) else {
160 continue;
161 };
162 let before_state = if tu.name == "Write" {
167 cwd.and_then(|c| git_head_content(c, &path))
168 } else {
169 None
170 };
171 let raw_diff =
172 toolpath_convo::file_write_diff(&tu.name, &tu.input, &path, before_state.as_deref());
173 let operation = match tu.name.as_str() {
174 "Write" => Some("add".to_string()),
175 "Edit" | "MultiEdit" | "NotebookEdit" => Some("update".to_string()),
176 _ => None,
177 };
178 let after = match tu.name.as_str() {
179 "Write" => tu
180 .input
181 .get("content")
182 .and_then(|v| v.as_str())
183 .map(|s| s.to_string()),
184 _ => None,
185 };
186 out.push(toolpath_convo::FileMutation {
187 path,
188 tool_id: Some(tu.id.clone()),
189 operation,
190 raw_diff,
191 before: before_state,
192 after,
193 rename_to: None,
194 });
195 }
196 out
197}
198
199fn git_head_content(repo_dir: &str, path: &str) -> Option<String> {
205 use std::path::Path as FsPath;
206 use std::process::Command;
207 let repo = FsPath::new(repo_dir);
208 let file = FsPath::new(path);
209 let rel = if file.is_absolute() {
210 file.strip_prefix(repo).ok()?.to_path_buf()
211 } else {
212 file.to_path_buf()
213 };
214 let rel_str = rel.to_string_lossy().replace('\\', "/");
215 let output = Command::new("git")
216 .arg("-C")
217 .arg(repo)
218 .arg("show")
219 .arg(format!("HEAD:{rel_str}"))
220 .output()
221 .ok()?;
222 if !output.status.success() {
223 return None;
224 }
225 String::from_utf8(output.stdout).ok()
226}
227
228fn extract_file_path_for_tool(input: &serde_json::Value) -> Option<String> {
229 for k in ["file_path", "path", "filename", "file"] {
230 if let Some(s) = input.get(k).and_then(|v| v.as_str()) {
231 return Some(s.to_string());
232 }
233 }
234 None
235}
236
237fn extract_delegations(tool_uses: &[ToolInvocation]) -> Vec<DelegatedWork> {
239 tool_uses
240 .iter()
241 .filter(|tu| tu.category == Some(ToolCategory::Delegation))
242 .map(|tu| DelegatedWork {
243 agent_id: tu.id.clone(),
244 prompt: tu
245 .input
246 .get("prompt")
247 .and_then(|v| v.as_str())
248 .unwrap_or("")
249 .to_string(),
250 turns: vec![],
251 result: tu.result.as_ref().map(|r| r.content.clone()),
252 })
253 .collect()
254}
255
256fn find_tool_result_in_parts(msg: &Message, tool_use_id: &str) -> Option<ToolResult> {
257 let parts = match &msg.content {
258 Some(MessageContent::Parts(parts)) => parts,
259 _ => return None,
260 };
261 parts.iter().find_map(|p| match p {
262 crate::types::ContentPart::ToolResult {
263 tool_use_id: id,
264 content,
265 is_error,
266 } if id == tool_use_id => Some(ToolResult {
267 content: content.text(),
268 is_error: *is_error,
269 }),
270 _ => None,
271 })
272}
273
274fn is_tool_result_only(entry: &ConversationEntry) -> bool {
277 let Some(msg) = &entry.message else {
278 return false;
279 };
280 msg.role == MessageRole::User && msg.text().is_empty() && !msg.tool_results().is_empty()
281}
282
283fn merge_tool_results(turns: &mut [Turn], msg: &Message) -> bool {
292 let mut merged = false;
293 for tr in msg.tool_results() {
294 for turn in turns.iter_mut().rev() {
295 if let Some(invocation) = turn
296 .tool_uses
297 .iter_mut()
298 .find(|tu| tu.id == tr.tool_use_id && tu.result.is_none())
299 {
300 invocation.result = Some(ToolResult {
301 content: tr.content.text(),
302 is_error: tr.is_error,
303 });
304 merged = true;
305 break;
306 }
307 }
308 }
309 merged
310}
311
312fn entry_to_turn(entry: &ConversationEntry) -> Option<Turn> {
313 entry
314 .message
315 .as_ref()
316 .map(|msg| message_to_turn(entry, msg))
317}
318
319fn conversation_to_view(convo: &Conversation) -> ConversationView {
324 let mut turns: Vec<Turn> = Vec::new();
325 let mut events: Vec<toolpath_convo::ConversationEvent> = Vec::new();
326
327 for (idx, raw) in convo.preamble.iter().enumerate() {
331 events.push(preamble_to_event(idx, raw));
332 }
333
334 let mut parent_rewrites: HashMap<String, String> = HashMap::new();
342 let mut last_turn_uuid: Option<String> = None;
343
344 for entry in &convo.entries {
345 let Some(msg) = &entry.message else {
346 events.push(entry_to_event(entry));
349 if let Some(prev) = &last_turn_uuid {
350 parent_rewrites.insert(entry.uuid.clone(), prev.clone());
351 }
352 continue;
353 };
354
355 if is_tool_result_only(entry) {
365 merge_tool_results(&mut turns, msg);
366 if let Some(prev) = &last_turn_uuid {
367 parent_rewrites.insert(entry.uuid.clone(), prev.clone());
368 }
369 continue;
370 }
371
372 let mut turn = message_to_turn(entry, msg);
373 if let Some(pid) = turn.parent_id.as_ref()
374 && let Some(real) = parent_rewrites.get(pid)
375 {
376 turn.parent_id = Some(real.clone());
377 }
378 last_turn_uuid = Some(turn.id.clone());
379 turns.push(turn);
380 }
381
382 canonicalize_message_usage(&mut turns);
383
384 for turn in &mut turns {
386 for delegation in &mut turn.delegations {
387 if delegation.result.is_none()
388 && let Some(tu) = turn
389 .tool_uses
390 .iter()
391 .find(|tu| tu.id == delegation.agent_id)
392 {
393 delegation.result = tu.result.as_ref().map(|r| r.content.clone());
394 }
395 }
396 }
397
398 let total_usage = sum_usage(&turns);
399 let files_changed = extract_files_changed(&turns);
400
401 let mut base = toolpath_convo::SessionBase::default();
406 let mut producer_version: Option<String> = None;
407 for entry in &convo.entries {
408 if base.working_dir.is_none()
409 && let Some(cwd) = &entry.cwd
410 {
411 base.working_dir = Some(cwd.clone());
412 }
413 if base.vcs_branch.is_none()
414 && let Some(b) = &entry.git_branch
415 {
416 base.vcs_branch = Some(b.clone());
417 }
418 if producer_version.is_none()
419 && let Some(v) = &entry.version
420 {
421 producer_version = Some(v.clone());
422 }
423 if base.working_dir.is_some() && base.vcs_branch.is_some() && producer_version.is_some() {
424 break;
425 }
426 }
427 let view_base = if base.working_dir.is_some()
428 || base.vcs_branch.is_some()
429 || base.vcs_revision.is_some()
430 || base.vcs_remote.is_some()
431 {
432 Some(base)
433 } else {
434 None
435 };
436 let producer = producer_version.map(|v| toolpath_convo::ProducerInfo {
437 name: "claude-code".into(),
438 version: Some(v),
439 });
440
441 ConversationView {
442 id: convo.session_id.clone(),
443 started_at: convo.started_at,
444 last_activity: convo.last_activity,
445 turns,
446 total_usage,
447 provider_id: Some("claude-code".into()),
448 files_changed,
449 session_ids: vec![],
450 events,
451 base: view_base,
452 producer,
453 }
454}
455
456fn preamble_to_event(idx: usize, raw: &serde_json::Value) -> toolpath_convo::ConversationEvent {
466 let event_type = raw
467 .get("type")
468 .and_then(|v| v.as_str())
469 .unwrap_or("preamble")
470 .to_string();
471 let timestamp = raw
472 .get("timestamp")
473 .and_then(|v| v.as_str())
474 .unwrap_or("")
475 .to_string();
476 let mut data: HashMap<String, serde_json::Value> = HashMap::new();
477 data.insert("raw".to_string(), raw.clone());
478 toolpath_convo::ConversationEvent {
479 id: format!("claude-preamble-{idx}"),
480 timestamp,
481 parent_id: None,
482 event_type,
483 data,
484 }
485}
486
487fn entry_to_event(entry: &ConversationEntry) -> toolpath_convo::ConversationEvent {
493 let mut data = HashMap::new();
494 if let Some(v) = &entry.cwd {
495 data.insert(
496 crate::constants::CWD.into(),
497 serde_json::Value::String(v.clone()),
498 );
499 }
500 if let Some(v) = &entry.git_branch {
501 data.insert("git_branch".into(), serde_json::Value::String(v.clone()));
502 }
503 if let Some(v) = &entry.version {
504 data.insert("version".into(), serde_json::Value::String(v.clone()));
505 }
506 if let Some(v) = &entry.user_type {
507 data.insert("user_type".into(), serde_json::Value::String(v.clone()));
508 }
509 if let Some(v) = &entry.message_id {
510 data.insert("message_id".into(), serde_json::Value::String(v.clone()));
511 }
512 if let Some(v) = &entry.tool_use_result {
513 data.insert("tool_use_result".into(), v.clone());
514 }
515 if let Some(v) = &entry.snapshot {
516 data.insert("snapshot".into(), v.clone());
517 }
518 if !entry.extra.is_empty()
519 && let Ok(value) = serde_json::to_value(&entry.extra)
520 {
521 data.insert("entry_extra".into(), value);
522 }
523 toolpath_convo::ConversationEvent {
524 id: entry.uuid.clone(),
525 timestamp: entry.timestamp.clone(),
526 parent_id: entry.parent_uuid.clone(),
527 event_type: entry.entry_type.clone(),
528 data,
529 }
530}
531
532pub(crate) fn max_usage(a: &TokenUsage, b: &TokenUsage) -> TokenUsage {
535 fn m(x: Option<u32>, y: Option<u32>) -> Option<u32> {
536 match (x, y) {
537 (Some(a), Some(b)) => Some(a.max(b)),
538 (Some(v), None) | (None, Some(v)) => Some(v),
539 (None, None) => None,
540 }
541 }
542 TokenUsage {
543 input_tokens: m(a.input_tokens, b.input_tokens),
544 output_tokens: m(a.output_tokens, b.output_tokens),
545 cache_read_tokens: m(a.cache_read_tokens, b.cache_read_tokens),
546 cache_write_tokens: m(a.cache_write_tokens, b.cache_write_tokens),
547 ..Default::default()
548 }
549}
550
551fn canonicalize_message_usage(turns: &mut [Turn]) {
572 let mut i = 0;
573 while i < turns.len() {
574 let Some(mid) = turns[i].group_id.clone() else {
575 i += 1;
576 continue;
577 };
578 let mut j = i;
579 while j < turns.len() && turns[j].group_id.as_deref() == Some(mid.as_str()) {
580 j += 1;
581 }
582
583 let mut total: Option<TokenUsage> = None;
586 for t in &turns[i..j] {
587 if let Some(u) = &t.token_usage {
588 total = Some(match total {
589 Some(acc) => max_usage(&acc, u),
590 None => u.clone(),
591 });
592 }
593 }
594
595 for t in &mut turns[i..j] {
596 t.token_usage = None;
597 }
598 if let Some(total) = total {
599 turns[j - 1].token_usage = Some(total);
600 }
601 i = j;
602 }
603}
604
605fn sum_usage(turns: &[Turn]) -> Option<TokenUsage> {
607 let mut total = TokenUsage::default();
608 let mut any = false;
609 for (idx, turn) in turns.iter().enumerate() {
610 if let Some(mid) = &turn.group_id
613 && turns
614 .get(idx + 1)
615 .is_some_and(|next| next.group_id.as_ref() == Some(mid))
616 {
617 continue;
618 }
619 if let Some(u) = &turn.token_usage {
620 any = true;
621 total.input_tokens =
622 Some(total.input_tokens.unwrap_or(0) + u.input_tokens.unwrap_or(0));
623 total.output_tokens =
624 Some(total.output_tokens.unwrap_or(0) + u.output_tokens.unwrap_or(0));
625 total.cache_read_tokens = match (total.cache_read_tokens, u.cache_read_tokens) {
626 (Some(a), Some(b)) => Some(a + b),
627 (Some(a), None) => Some(a),
628 (None, Some(b)) => Some(b),
629 (None, None) => None,
630 };
631 total.cache_write_tokens = match (total.cache_write_tokens, u.cache_write_tokens) {
632 (Some(a), Some(b)) => Some(a + b),
633 (Some(a), None) => Some(a),
634 (None, Some(b)) => Some(b),
635 (None, None) => None,
636 };
637 }
638 }
639 if any { Some(total) } else { None }
640}
641
642fn extract_files_changed(turns: &[Turn]) -> Vec<String> {
644 let mut seen = std::collections::HashSet::new();
645 let mut files = Vec::new();
646 for turn in turns {
647 for tool_use in &turn.tool_uses {
648 if tool_use.category == Some(ToolCategory::FileWrite)
649 && let Some(path) = tool_use.input.get("file_path").and_then(|v| v.as_str())
650 && seen.insert(path.to_string())
651 {
652 files.push(path.to_string());
653 }
654 }
655 }
656 files
657}
658
659#[cfg(any(feature = "watcher", test))]
660fn entry_to_watcher_event(entry: &ConversationEntry) -> WatcherEvent {
661 match entry_to_turn(entry) {
662 Some(turn) => WatcherEvent::Turn(Box::new(turn)),
663 None => {
664 let mut data = serde_json::json!({
665 "uuid": entry.uuid,
666 "timestamp": entry.timestamp,
667 });
668 if !entry.extra.is_empty() {
669 data["claude"] = serde_json::to_value(&entry.extra).unwrap_or_default();
670 }
671 WatcherEvent::Progress {
672 kind: entry.entry_type.clone(),
673 data,
674 }
675 }
676 }
677}
678
679impl ConversationProvider for ClaudeConvo {
682 fn list_conversations(&self, project: &str) -> toolpath_convo::Result<Vec<String>> {
683 crate::ClaudeConvo::list_conversations(self, project)
684 .map_err(|e| ConvoError::Provider(e.to_string()))
685 }
686
687 fn load_conversation(
688 &self,
689 project: &str,
690 conversation_id: &str,
691 ) -> toolpath_convo::Result<ConversationView> {
692 let convo = self
693 .read_conversation(project, conversation_id)
694 .map_err(|e| ConvoError::Provider(e.to_string()))?;
695 let mut view = conversation_to_view(&convo);
696 view.session_ids = convo.segment_ids.clone();
697 Ok(view)
698 }
699
700 fn load_metadata(
701 &self,
702 project: &str,
703 conversation_id: &str,
704 ) -> toolpath_convo::Result<ConversationMeta> {
705 let meta = self
706 .read_conversation_metadata(project, conversation_id)
707 .map_err(|e| ConvoError::Provider(e.to_string()))?;
708
709 Ok(ConversationMeta {
710 id: meta.session_id,
711 started_at: meta.started_at,
712 last_activity: meta.last_activity,
713 message_count: meta.message_count,
714 file_path: Some(meta.file_path),
715 predecessor: None,
716 successor: None,
717 })
718 }
719
720 fn list_metadata(&self, project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
721 let metas = self
722 .list_conversation_metadata(project)
723 .map_err(|e| ConvoError::Provider(e.to_string()))?;
724
725 Ok(metas
726 .into_iter()
727 .map(|m| ConversationMeta {
728 id: m.session_id,
729 started_at: m.started_at,
730 last_activity: m.last_activity,
731 message_count: m.message_count,
732 file_path: Some(m.file_path),
733 predecessor: None,
734 successor: None,
735 })
736 .collect())
737 }
738}
739
740#[cfg(feature = "watcher")]
743impl toolpath_convo::ConversationWatcher for crate::watcher::ConversationWatcher {
744 fn poll(&mut self) -> toolpath_convo::Result<Vec<WatcherEvent>> {
745 let entries = crate::watcher::ConversationWatcher::poll(self)
746 .map_err(|e| ConvoError::Provider(e.to_string()))?;
747
748 let mut events: Vec<WatcherEvent> = Vec::new();
749
750 for (from, to) in self.take_pending_rotations() {
752 events.push(WatcherEvent::Progress {
753 kind: "session_rotated".into(),
754 data: serde_json::json!({
755 "from": from,
756 "to": to,
757 }),
758 });
759 }
760
761 for entry in &entries {
762 let Some(msg) = &entry.message else {
763 events.push(entry_to_watcher_event(entry));
764 continue;
765 };
766
767 if is_tool_result_only(entry) {
768 let mut updated_turn: Option<Turn> = None;
772
773 for event in events.iter_mut().rev() {
775 if let WatcherEvent::Turn(turn) | WatcherEvent::TurnUpdated(turn) = event
776 && turn.tool_uses.iter().any(|tu| {
777 tu.result.is_none()
778 && msg.tool_results().iter().any(|tr| tr.tool_use_id == tu.id)
779 })
780 {
781 let mut updated = (**turn).clone();
783 merge_tool_results(std::slice::from_mut(&mut updated), msg);
784 updated_turn = Some(updated.clone());
785 **turn = updated;
788 break;
789 }
790 }
791
792 if let Some(turn) = updated_turn {
793 events.push(WatcherEvent::TurnUpdated(Box::new(turn)));
794 }
795 continue;
799 }
800
801 events.push(entry_to_watcher_event(entry));
802 }
803
804 Ok(events)
805 }
806
807 fn seen_count(&self) -> usize {
808 crate::watcher::ConversationWatcher::seen_count(self)
809 }
810}
811
812pub fn to_view(convo: &Conversation) -> ConversationView {
820 conversation_to_view(convo)
821}
822
823pub fn to_turn(entry: &ConversationEntry) -> Option<Turn> {
829 entry_to_turn(entry)
830}
831
832#[cfg(test)]
835mod tests {
836 use super::*;
837 use crate::PathResolver;
838 use std::fs;
839 use tempfile::TempDir;
840
841 fn grp_turn(id: &str, mid: &str, output: u32) -> Turn {
844 let mut t = message_turn_stub(id);
845 t.group_id = Some(mid.into());
846 t.token_usage = Some(TokenUsage {
847 input_tokens: Some(6),
848 output_tokens: Some(output),
849 cache_read_tokens: Some(14_842),
850 cache_write_tokens: Some(429_831),
851 ..Default::default()
852 });
853 t
854 }
855
856 fn message_turn_stub(id: &str) -> Turn {
857 Turn {
858 id: id.into(),
859 parent_id: None,
860 group_id: None,
861 role: Role::Assistant,
862 timestamp: "2024-01-01T00:00:00Z".into(),
863 text: String::new(),
864 thinking: None,
865 tool_uses: vec![],
866 model: None,
867 stop_reason: None,
868 token_usage: None,
869 attributed_token_usage: None,
870 environment: None,
871 delegations: vec![],
872 file_mutations: vec![],
873 }
874 }
875
876 #[test]
877 fn canonicalize_streamed_group_keeps_total_only_on_final_turn() {
878 let mut turns = vec![grp_turn("t1", "msg_A", 55), grp_turn("t2", "msg_A", 164)];
884 canonicalize_message_usage(&mut turns);
885
886 assert!(turns[0].token_usage.is_none(), "total only on final turn");
887 assert_eq!(
888 turns[1].token_usage.as_ref().unwrap().output_tokens,
889 Some(164)
890 );
891 assert_eq!(turns[1].token_usage.as_ref().unwrap().input_tokens, Some(6));
892 for t in &turns {
893 assert!(
894 t.attributed_token_usage.is_none(),
895 "Claude per-line snapshots are not per-step attribution"
896 );
897 }
898 }
899
900 #[test]
901 fn canonicalize_does_not_trust_line_order() {
902 let mut turns = vec![grp_turn("t1", "msg_A", 164), grp_turn("t2", "msg_A", 55)];
906 canonicalize_message_usage(&mut turns);
907
908 assert_eq!(
909 turns[1].token_usage.as_ref().unwrap().output_tokens,
910 Some(164),
911 "field-wise max, not the last line"
912 );
913 }
914
915 #[test]
916 fn canonicalize_collapses_repeated_total_to_one_turn() {
917 let mut turns = vec![
920 grp_turn("t1", "msg_A", 997),
921 grp_turn("t2", "msg_A", 997),
922 grp_turn("t3", "msg_A", 997),
923 ];
924 canonicalize_message_usage(&mut turns);
925
926 assert!(turns[0].token_usage.is_none());
927 assert!(turns[1].token_usage.is_none());
928 assert_eq!(
929 turns[2].token_usage.as_ref().unwrap().output_tokens,
930 Some(997)
931 );
932 for t in &turns {
933 assert!(t.attributed_token_usage.is_none());
934 }
935 }
936
937 fn setup_provider() -> (TempDir, ClaudeConvo) {
938 let temp = TempDir::new().unwrap();
939 let claude_dir = temp.path().join(".claude");
940 let project_dir = claude_dir.join("projects/-test-project");
941 fs::create_dir_all(&project_dir).unwrap();
942
943 let entries = [
944 r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Fix the bug"}}"#,
945 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}}}"#,
946 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}]}}"#,
947 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}}}"#,
948 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}]}}"#,
949 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"}}"#,
950 r#"{"uuid":"uuid-7","type":"user","parentUuid":"uuid-6","timestamp":"2024-01-01T00:00:06Z","message":{"role":"user","content":"Thanks!"}}"#,
951 ];
952 fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap();
953
954 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
955 (temp, ClaudeConvo::with_resolver(resolver))
956 }
957
958 fn setup_split_message_provider() -> (TempDir, ClaudeConvo) {
963 let temp = TempDir::new().unwrap();
964 let claude_dir = temp.path().join(".claude");
965 let project_dir = claude_dir.join("projects/-test-project");
966 fs::create_dir_all(&project_dir).unwrap();
967
968 let usage_a = r#"{"input_tokens":6,"output_tokens":997,"cache_read_input_tokens":14842,"cache_creation_input_tokens":429831}"#;
969 let entries = [
970 r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Fix the bug"}}"#.to_string(),
971 format!(
972 r#"{{"uuid":"uuid-2","type":"assistant","parentUuid":"uuid-1","timestamp":"2024-01-01T00:00:01Z","message":{{"id":"msg_A","role":"assistant","content":[{{"type":"text","text":"Working on it."}}],"model":"claude-opus-4-7","stop_reason":null,"usage":{usage_a}}}}}"#
973 ),
974 format!(
975 r#"{{"uuid":"uuid-3","type":"assistant","parentUuid":"uuid-2","timestamp":"2024-01-01T00:00:02Z","message":{{"id":"msg_A","role":"assistant","content":[{{"type":"tool_use","id":"t1","name":"Read","input":{{"file_path":"a.rs"}}}}],"model":"claude-opus-4-7","stop_reason":null,"usage":{usage_a}}}}}"#
976 ),
977 format!(
978 r#"{{"uuid":"uuid-4","type":"assistant","parentUuid":"uuid-3","timestamp":"2024-01-01T00:00:03Z","message":{{"id":"msg_A","role":"assistant","content":[{{"type":"tool_use","id":"t2","name":"Read","input":{{"file_path":"b.rs"}}}}],"model":"claude-opus-4-7","stop_reason":"tool_use","usage":{usage_a}}}}}"#
979 ),
980 r#"{"uuid":"uuid-5","type":"assistant","parentUuid":"uuid-4","timestamp":"2024-01-01T00:00:04Z","message":{"id":"msg_B","role":"assistant","content":[{"type":"text","text":"Done."}],"model":"claude-opus-4-7","stop_reason":"end_turn","usage":{"input_tokens":5,"output_tokens":11}}}"#.to_string(),
981 ];
982 fs::write(project_dir.join("session-2.jsonl"), entries.join("\n")).unwrap();
983
984 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
985 (temp, ClaudeConvo::with_resolver(resolver))
986 }
987
988 #[test]
989 fn test_split_message_turns_share_group_id() {
990 let (_temp, provider) = setup_split_message_provider();
991 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-2")
992 .unwrap();
993
994 assert_eq!(view.turns.len(), 5);
995 assert!(view.turns[0].group_id.is_none(), "user lines carry no ID");
996 for turn in &view.turns[1..=3] {
997 assert_eq!(turn.group_id.as_deref(), Some("msg_A"));
998 }
999 assert_eq!(view.turns[4].group_id.as_deref(), Some("msg_B"));
1000 }
1001
1002 #[test]
1003 fn test_view_usage_is_canonical_total_on_group_final_turn() {
1004 let (_temp, provider) = setup_split_message_provider();
1008 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-2")
1009 .unwrap();
1010
1011 assert!(view.turns[1].token_usage.is_none());
1012 assert!(view.turns[2].token_usage.is_none());
1013 assert_eq!(
1014 view.turns[3].token_usage.as_ref().unwrap().output_tokens,
1015 Some(997)
1016 );
1017 assert_eq!(
1018 view.turns[4].token_usage.as_ref().unwrap().output_tokens,
1019 Some(11)
1020 );
1021 }
1022
1023 #[test]
1024 fn test_total_usage_counts_each_message_once() {
1025 let (_temp, provider) = setup_split_message_provider();
1026 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-2")
1027 .unwrap();
1028
1029 let total = view.total_usage.as_ref().unwrap();
1032 assert_eq!(total.output_tokens, Some(997 + 11));
1033 assert_eq!(total.input_tokens, Some(6 + 5));
1034 assert_eq!(total.cache_read_tokens, Some(14_842));
1035 assert_eq!(total.cache_write_tokens, Some(429_831));
1036 }
1037
1038 #[test]
1039 fn test_load_conversation_assembles_tool_results() {
1040 let (_temp, provider) = setup_provider();
1041 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1042 .unwrap();
1043
1044 assert_eq!(view.id, "session-1");
1045 assert_eq!(view.turns.len(), 5);
1047
1048 assert_eq!(view.turns[0].role, Role::User);
1050 assert_eq!(view.turns[0].text, "Fix the bug");
1051 assert!(view.turns[0].parent_id.is_none());
1052
1053 assert_eq!(view.turns[1].role, Role::Assistant);
1055 assert_eq!(view.turns[1].text, "I'll fix that.");
1056 assert_eq!(
1057 view.turns[1].thinking.as_deref(),
1058 Some("The bug is in auth")
1059 );
1060 assert_eq!(view.turns[1].tool_uses.len(), 1);
1061 assert_eq!(view.turns[1].tool_uses[0].name, "Read");
1062 assert_eq!(view.turns[1].tool_uses[0].id, "t1");
1063 let result = view.turns[1].tool_uses[0].result.as_ref().unwrap();
1065 assert!(!result.is_error);
1066 assert!(result.content.contains("fn main()"));
1067 assert_eq!(view.turns[1].model.as_deref(), Some("claude-opus-4-6"));
1068 assert_eq!(view.turns[1].stop_reason.as_deref(), Some("tool_use"));
1069 assert_eq!(view.turns[1].parent_id.as_deref(), Some("uuid-1"));
1070
1071 let usage = view.turns[1].token_usage.as_ref().unwrap();
1073 assert_eq!(usage.input_tokens, Some(100));
1074 assert_eq!(usage.output_tokens, Some(50));
1075
1076 assert_eq!(view.turns[2].role, Role::Assistant);
1078 assert_eq!(view.turns[2].text, "I see the issue. Let me fix it.");
1079 assert_eq!(view.turns[2].tool_uses[0].name, "Edit");
1080 let result2 = view.turns[2].tool_uses[0].result.as_ref().unwrap();
1081 assert_eq!(result2.content, "File written successfully");
1082
1083 assert_eq!(view.turns[3].role, Role::Assistant);
1085 assert_eq!(view.turns[3].text, "Done! The bug is fixed.");
1086 assert!(view.turns[3].tool_uses.is_empty());
1087
1088 assert_eq!(view.turns[4].role, Role::User);
1090 assert_eq!(view.turns[4].text, "Thanks!");
1091 }
1092
1093 #[test]
1094 fn test_no_phantom_empty_turns() {
1095 let (_temp, provider) = setup_provider();
1096 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1097 .unwrap();
1098
1099 for turn in &view.turns {
1101 if turn.role == Role::User {
1102 assert!(
1103 !turn.text.is_empty(),
1104 "Found phantom empty user turn: {:?}",
1105 turn.id
1106 );
1107 }
1108 }
1109 }
1110
1111 #[test]
1112 fn test_tool_result_error_flag() {
1113 let temp = TempDir::new().unwrap();
1114 let claude_dir = temp.path().join(".claude");
1115 let project_dir = claude_dir.join("projects/-test-project");
1116 fs::create_dir_all(&project_dir).unwrap();
1117
1118 let entries = [
1119 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read a file"}}"#,
1120 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"}}"#,
1121 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}]}}"#,
1122 ];
1123 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1124
1125 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1126 let provider = ClaudeConvo::with_resolver(resolver);
1127 let view =
1128 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1129
1130 assert_eq!(view.turns.len(), 2); let result = view.turns[1].tool_uses[0].result.as_ref().unwrap();
1132 assert!(result.is_error);
1133 assert_eq!(result.content, "File not found");
1134 }
1135
1136 #[test]
1137 fn test_multiple_tool_uses_single_result_entry() {
1138 let temp = TempDir::new().unwrap();
1139 let claude_dir = temp.path().join(".claude");
1140 let project_dir = claude_dir.join("projects/-test-project");
1141 fs::create_dir_all(&project_dir).unwrap();
1142
1143 let entries = [
1144 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Check two files"}}"#,
1145 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"}}]}}"#,
1146 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}]}}"#,
1147 ];
1148 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1149
1150 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1151 let provider = ClaudeConvo::with_resolver(resolver);
1152 let view =
1153 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1154
1155 assert_eq!(view.turns.len(), 2);
1156 assert_eq!(view.turns[1].tool_uses.len(), 2);
1157
1158 let r1 = view.turns[1].tool_uses[0].result.as_ref().unwrap();
1159 assert_eq!(r1.content, "file a contents");
1160
1161 let r2 = view.turns[1].tool_uses[1].result.as_ref().unwrap();
1162 assert_eq!(r2.content, "file b contents");
1163 }
1164
1165 #[test]
1166 fn test_conversation_without_tool_use_unchanged() {
1167 let temp = TempDir::new().unwrap();
1168 let claude_dir = temp.path().join(".claude");
1169 let project_dir = claude_dir.join("projects/-test-project");
1170 fs::create_dir_all(&project_dir).unwrap();
1171
1172 let entries = [
1173 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
1174 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi there!"}}"#,
1175 ];
1176 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1177
1178 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1179 let provider = ClaudeConvo::with_resolver(resolver);
1180 let view =
1181 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1182
1183 assert_eq!(view.turns.len(), 2);
1184 assert_eq!(view.turns[0].text, "Hello");
1185 assert_eq!(view.turns[1].text, "Hi there!");
1186 }
1187
1188 #[test]
1189 fn test_assistant_turn_without_result_has_none() {
1190 let temp = TempDir::new().unwrap();
1192 let claude_dir = temp.path().join(".claude");
1193 let project_dir = claude_dir.join("projects/-test-project");
1194 fs::create_dir_all(&project_dir).unwrap();
1195
1196 let entries = [
1197 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read a file"}}"#,
1198 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"}}]}}"#,
1199 ];
1200 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1201
1202 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1203 let provider = ClaudeConvo::with_resolver(resolver);
1204 let view =
1205 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1206
1207 assert_eq!(view.turns.len(), 2);
1208 assert!(view.turns[1].tool_uses[0].result.is_none());
1209 }
1210
1211 #[test]
1212 fn test_list_conversations() {
1213 let (_temp, provider) = setup_provider();
1214 let ids = ConversationProvider::list_conversations(&provider, "/test/project").unwrap();
1215 assert_eq!(ids, vec!["session-1"]);
1216 }
1217
1218 #[test]
1219 fn test_load_metadata() {
1220 let (_temp, provider) = setup_provider();
1221 let meta =
1222 ConversationProvider::load_metadata(&provider, "/test/project", "session-1").unwrap();
1223 assert_eq!(meta.id, "session-1");
1224 assert_eq!(meta.message_count, 7);
1225 assert!(meta.file_path.is_some());
1226 }
1227
1228 #[test]
1229 fn test_list_metadata() {
1230 let (_temp, provider) = setup_provider();
1231 let metas = ConversationProvider::list_metadata(&provider, "/test/project").unwrap();
1232 assert_eq!(metas.len(), 1);
1233 assert_eq!(metas[0].id, "session-1");
1234 }
1235
1236 #[test]
1237 fn test_to_view() {
1238 let (_temp, manager) = setup_provider();
1239 let convo = manager
1240 .read_conversation("/test/project", "session-1")
1241 .unwrap();
1242 let view = to_view(&convo);
1243 assert_eq!(view.turns.len(), 5);
1244 assert_eq!(view.title(20).unwrap(), "Fix the bug");
1245 }
1246
1247 #[test]
1248 fn test_to_turn_with_message() {
1249 let entry: ConversationEntry = serde_json::from_str(
1250 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
1251 )
1252 .unwrap();
1253 let turn = to_turn(&entry).unwrap();
1254 assert_eq!(turn.id, "u1");
1255 assert_eq!(turn.text, "hello");
1256 assert_eq!(turn.role, Role::User);
1257 }
1258
1259 #[test]
1260 fn test_to_turn_without_message() {
1261 let entry: ConversationEntry = serde_json::from_str(
1262 r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
1263 )
1264 .unwrap();
1265 assert!(to_turn(&entry).is_none());
1266 }
1267
1268 #[test]
1269 fn test_entry_to_watcher_event_turn() {
1270 let entry: ConversationEntry = serde_json::from_str(
1271 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hi"}}"#,
1272 )
1273 .unwrap();
1274 let event = entry_to_watcher_event(&entry);
1275 assert!(matches!(event, WatcherEvent::Turn(_)));
1276 }
1277
1278 #[test]
1279 fn test_entry_to_watcher_event_progress() {
1280 let entry: ConversationEntry = serde_json::from_str(
1281 r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
1282 )
1283 .unwrap();
1284 let event = entry_to_watcher_event(&entry);
1285 assert!(matches!(event, WatcherEvent::Progress { .. }));
1286 }
1287
1288 #[cfg(feature = "watcher")]
1289 #[test]
1290 fn test_watcher_trait_basic() {
1291 let temp = TempDir::new().unwrap();
1292 let claude_dir = temp.path().join(".claude");
1293 let project_dir = claude_dir.join("projects/-test-project");
1294 fs::create_dir_all(&project_dir).unwrap();
1295
1296 let entries = [
1297 r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
1298 r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
1299 ];
1300 fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap();
1301
1302 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1303 let manager = ClaudeConvo::with_resolver(resolver);
1304
1305 let mut watcher = crate::watcher::ConversationWatcher::new(
1306 manager,
1307 "/test/project".to_string(),
1308 "session-1".to_string(),
1309 );
1310
1311 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1313 assert_eq!(events.len(), 2);
1314 assert!(matches!(&events[0], WatcherEvent::Turn(t) if t.role == Role::User));
1315 assert!(matches!(&events[1], WatcherEvent::Turn(t) if t.role == Role::Assistant));
1316 assert_eq!(toolpath_convo::ConversationWatcher::seen_count(&watcher), 2);
1317
1318 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1320 assert!(events.is_empty());
1321 }
1322
1323 #[cfg(feature = "watcher")]
1324 #[test]
1325 fn test_watcher_trait_assembles_tool_results() {
1326 let temp = TempDir::new().unwrap();
1327 let claude_dir = temp.path().join(".claude");
1328 let project_dir = claude_dir.join("projects/-test-project");
1329 fs::create_dir_all(&project_dir).unwrap();
1330
1331 let entries = [
1332 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read the file"}}"#,
1333 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"}}]}}"#,
1334 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}]}}"#,
1335 r#"{"uuid":"u4","type":"assistant","timestamp":"2024-01-01T00:00:03Z","message":{"role":"assistant","content":"Done!"}}"#,
1336 ];
1337 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1338
1339 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1340 let manager = ClaudeConvo::with_resolver(resolver);
1341
1342 let mut watcher = crate::watcher::ConversationWatcher::new(
1343 manager,
1344 "/test/project".to_string(),
1345 "s1".to_string(),
1346 );
1347
1348 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1349
1350 assert_eq!(events.len(), 4);
1352
1353 assert!(matches!(&events[0], WatcherEvent::Turn(t) if t.role == Role::User));
1355
1356 assert!(matches!(&events[1], WatcherEvent::Turn(t) if t.role == Role::Assistant));
1358
1359 match &events[2] {
1361 WatcherEvent::TurnUpdated(turn) => {
1362 assert_eq!(turn.id, "u2");
1363 assert_eq!(turn.tool_uses.len(), 1);
1364 let result = turn.tool_uses[0].result.as_ref().unwrap();
1365 assert_eq!(result.content, "fn main() {}");
1366 assert!(!result.is_error);
1367 }
1368 other => panic!("Expected TurnUpdated, got {:?}", other),
1369 }
1370
1371 assert!(matches!(&events[3], WatcherEvent::Turn(t) if t.text == "Done!"));
1373 }
1374
1375 #[cfg(feature = "watcher")]
1376 #[test]
1377 fn test_watcher_trait_incremental_tool_results() {
1378 let temp = TempDir::new().unwrap();
1380 let claude_dir = temp.path().join(".claude");
1381 let project_dir = claude_dir.join("projects/-test-project");
1382 fs::create_dir_all(&project_dir).unwrap();
1383
1384 let entries_phase1 = [
1386 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read file"}}"#,
1387 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"}}]}}"#,
1388 ];
1389 fs::write(
1390 project_dir.join("s1.jsonl"),
1391 entries_phase1.join("\n") + "\n",
1392 )
1393 .unwrap();
1394
1395 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1396 let manager = ClaudeConvo::with_resolver(resolver);
1397
1398 let mut watcher = crate::watcher::ConversationWatcher::new(
1399 manager,
1400 "/test/project".to_string(),
1401 "s1".to_string(),
1402 );
1403
1404 let events1 = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1406 assert_eq!(events1.len(), 2);
1407 if let WatcherEvent::Turn(t) = &events1[1] {
1409 assert!(t.tool_uses[0].result.is_none());
1410 } else {
1411 panic!("Expected Turn");
1412 }
1413
1414 use std::io::Write;
1416 let mut file = fs::OpenOptions::new()
1417 .append(true)
1418 .open(project_dir.join("s1.jsonl"))
1419 .unwrap();
1420 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();
1421
1422 let events2 = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1424 assert!(events2.is_empty() || events2.iter().all(|e| !matches!(e, WatcherEvent::Turn(_))));
1431 }
1432
1433 #[test]
1434 fn test_merge_tool_results_by_id() {
1435 let mut turns = vec![Turn {
1437 id: "t1".into(),
1438 parent_id: None,
1439 group_id: None,
1440 role: Role::Assistant,
1441 timestamp: "2024-01-01T00:00:00Z".into(),
1442 text: "test".into(),
1443 thinking: None,
1444 tool_uses: vec![
1445 ToolInvocation {
1446 id: "tool-a".into(),
1447 name: "Read".into(),
1448 input: serde_json::json!({}),
1449 result: None,
1450 category: Some(ToolCategory::FileRead),
1451 },
1452 ToolInvocation {
1453 id: "tool-b".into(),
1454 name: "Write".into(),
1455 input: serde_json::json!({}),
1456 result: None,
1457 category: Some(ToolCategory::FileWrite),
1458 },
1459 ],
1460 model: None,
1461 stop_reason: None,
1462 token_usage: None,
1463 attributed_token_usage: None,
1464 environment: None,
1465 delegations: vec![],
1466 file_mutations: Vec::new(),
1467 }];
1468
1469 let msg: Message = serde_json::from_str(
1471 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}]}"#,
1472 )
1473 .unwrap();
1474
1475 let merged = merge_tool_results(&mut turns, &msg);
1476 assert!(merged);
1477
1478 assert_eq!(
1480 turns[0].tool_uses[0].result.as_ref().unwrap().content,
1481 "read result"
1482 );
1483 assert!(turns[0].tool_uses[0].result.as_ref().unwrap().is_error);
1484
1485 assert_eq!(
1486 turns[0].tool_uses[1].result.as_ref().unwrap().content,
1487 "write result"
1488 );
1489 assert!(!turns[0].tool_uses[1].result.as_ref().unwrap().is_error);
1490 }
1491
1492 #[test]
1493 fn test_is_tool_result_only() {
1494 let entry: ConversationEntry = serde_json::from_str(
1496 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}]}}"#,
1497 )
1498 .unwrap();
1499 assert!(is_tool_result_only(&entry));
1500
1501 let entry: ConversationEntry = serde_json::from_str(
1503 r#"{"uuid":"u2","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
1504 )
1505 .unwrap();
1506 assert!(!is_tool_result_only(&entry));
1507
1508 let entry: ConversationEntry = serde_json::from_str(
1510 r#"{"uuid":"u3","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
1511 )
1512 .unwrap();
1513 assert!(!is_tool_result_only(&entry));
1514
1515 let entry: ConversationEntry = serde_json::from_str(
1517 r#"{"uuid":"u4","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"hi"}}"#,
1518 )
1519 .unwrap();
1520 assert!(!is_tool_result_only(&entry));
1521 }
1522
1523 #[test]
1526 fn test_tool_category_mapping() {
1527 assert_eq!(tool_category("Read"), Some(ToolCategory::FileRead));
1528 assert_eq!(tool_category("Glob"), Some(ToolCategory::FileSearch));
1529 assert_eq!(tool_category("Grep"), Some(ToolCategory::FileSearch));
1530 assert_eq!(tool_category("Write"), Some(ToolCategory::FileWrite));
1531 assert_eq!(tool_category("Edit"), Some(ToolCategory::FileWrite));
1532 assert_eq!(tool_category("NotebookEdit"), Some(ToolCategory::FileWrite));
1533 assert_eq!(tool_category("Bash"), Some(ToolCategory::Shell));
1534 assert_eq!(tool_category("WebFetch"), Some(ToolCategory::Network));
1535 assert_eq!(tool_category("WebSearch"), Some(ToolCategory::Network));
1536 assert_eq!(tool_category("Task"), Some(ToolCategory::Delegation));
1537 assert_eq!(tool_category("UnknownTool"), None);
1538 }
1539
1540 #[test]
1541 fn test_turn_has_tool_category() {
1542 let (_temp, provider) = setup_provider();
1543 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1544 .unwrap();
1545
1546 assert_eq!(
1548 view.turns[1].tool_uses[0].category,
1549 Some(ToolCategory::FileRead)
1550 );
1551 assert_eq!(
1553 view.turns[2].tool_uses[0].category,
1554 Some(ToolCategory::FileWrite)
1555 );
1556 }
1557
1558 #[test]
1559 fn test_environment_populated_from_entry() {
1560 let temp = TempDir::new().unwrap();
1561 let claude_dir = temp.path().join(".claude");
1562 let project_dir = claude_dir.join("projects/-test-project");
1563 fs::create_dir_all(&project_dir).unwrap();
1564
1565 let entries = [
1566 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","cwd":"/project/path","gitBranch":"feat/auth","message":{"role":"user","content":"Hello"}}"#,
1567 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
1568 ];
1569 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1570
1571 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1572 let provider = ClaudeConvo::with_resolver(resolver);
1573 let view =
1574 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1575
1576 let env = view.turns[0].environment.as_ref().unwrap();
1578 assert_eq!(env.working_dir.as_deref(), Some("/project/path"));
1579 assert_eq!(env.vcs_branch.as_deref(), Some("feat/auth"));
1580 assert!(env.vcs_revision.is_none());
1581
1582 assert!(view.turns[1].environment.is_none());
1584 }
1585
1586 #[test]
1587 fn test_cache_tokens_populated() {
1588 let temp = TempDir::new().unwrap();
1589 let claude_dir = temp.path().join(".claude");
1590 let project_dir = claude_dir.join("projects/-test-project");
1591 fs::create_dir_all(&project_dir).unwrap();
1592
1593 let entries = [
1594 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
1595 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}}}"#,
1596 ];
1597 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1598
1599 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1600 let provider = ClaudeConvo::with_resolver(resolver);
1601 let view =
1602 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1603
1604 let usage = view.turns[1].token_usage.as_ref().unwrap();
1605 assert_eq!(usage.cache_read_tokens, Some(500));
1606 assert_eq!(usage.cache_write_tokens, Some(200));
1607 }
1608
1609 #[test]
1610 fn test_total_usage_aggregated() {
1611 let (_temp, provider) = setup_provider();
1612 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1613 .unwrap();
1614
1615 let total = view.total_usage.as_ref().unwrap();
1616 assert_eq!(total.input_tokens, Some(300));
1618 assert_eq!(total.output_tokens, Some(150));
1619 }
1620
1621 #[test]
1622 fn test_provider_id_set() {
1623 let (_temp, provider) = setup_provider();
1624 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1625 .unwrap();
1626
1627 assert_eq!(view.provider_id.as_deref(), Some("claude-code"));
1628 }
1629
1630 #[test]
1631 fn test_files_changed_populated() {
1632 let temp = TempDir::new().unwrap();
1633 let claude_dir = temp.path().join(".claude");
1634 let project_dir = claude_dir.join("projects/-test-project");
1635 fs::create_dir_all(&project_dir).unwrap();
1636
1637 let entries = [
1638 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Edit files"}}"#,
1639 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"}}]}}"#,
1640 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}]}}"#,
1641 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"}}]}}"#,
1642 ];
1643 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1644
1645 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1646 let provider = ClaudeConvo::with_resolver(resolver);
1647 let view =
1648 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1649
1650 assert_eq!(view.files_changed, vec!["src/main.rs", "src/lib.rs"]);
1652 }
1653
1654 #[test]
1655 fn test_delegations_extracted() {
1656 let temp = TempDir::new().unwrap();
1657 let claude_dir = temp.path().join(".claude");
1658 let project_dir = claude_dir.join("projects/-test-project");
1659 fs::create_dir_all(&project_dir).unwrap();
1660
1661 let entries = [
1662 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Search for bugs"}}"#,
1663 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"}}]}}"#,
1664 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}]}}"#,
1665 ];
1666 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1667
1668 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1669 let provider = ClaudeConvo::with_resolver(resolver);
1670 let view =
1671 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1672
1673 assert_eq!(view.turns[1].delegations.len(), 1);
1675 let d = &view.turns[1].delegations[0];
1676 assert_eq!(d.agent_id, "task-1");
1677 assert_eq!(d.prompt, "Find the authentication bug");
1678 assert!(d.turns.is_empty()); assert_eq!(
1681 d.result.as_deref(),
1682 Some("Found the bug in auth.rs line 42")
1683 );
1684 }
1685
1686 #[test]
1687 fn test_progress_data_enriched_with_extras() {
1688 let entry: ConversationEntry = serde_json::from_str(
1689 r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z","data":{"type":"hook_progress","hookName":"pre-commit"}}"#,
1690 )
1691 .unwrap();
1692 let event = entry_to_watcher_event(&entry);
1693 match event {
1694 WatcherEvent::Progress { kind, data } => {
1695 assert_eq!(kind, "progress");
1696 assert_eq!(data["uuid"], "u1");
1697 assert_eq!(data["timestamp"], "2024-01-01T00:00:00Z");
1698 let claude = &data["claude"];
1699 assert_eq!(claude["data"]["type"], "hook_progress");
1700 assert_eq!(claude["data"]["hookName"], "pre-commit");
1701 }
1702 other => panic!(
1703 "Expected Progress, got {:?}",
1704 std::mem::discriminant(&other)
1705 ),
1706 }
1707 }
1708
1709 #[test]
1710 fn test_progress_data_no_claude_key_when_no_extras() {
1711 let entry: ConversationEntry = serde_json::from_str(
1712 r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
1713 )
1714 .unwrap();
1715 let event = entry_to_watcher_event(&entry);
1716 match event {
1717 WatcherEvent::Progress { data, .. } => {
1718 assert!(data.get("claude").is_none());
1719 }
1720 other => panic!(
1721 "Expected Progress, got {:?}",
1722 std::mem::discriminant(&other)
1723 ),
1724 }
1725 }
1726
1727 #[test]
1728 fn test_no_delegations_for_non_task_tools() {
1729 let (_temp, provider) = setup_provider();
1730 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1731 .unwrap();
1732
1733 for turn in &view.turns {
1735 assert!(turn.delegations.is_empty());
1736 }
1737 }
1738
1739 fn setup_chained_provider() -> (TempDir, ClaudeConvo) {
1742 let temp = TempDir::new().unwrap();
1743 let claude_dir = temp.path().join(".claude");
1744 let project_dir = claude_dir.join("projects/-test-project");
1745 fs::create_dir_all(&project_dir).unwrap();
1746
1747 let entries_a = [
1749 r#"{"uuid":"a1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Fix the bug"}}"#,
1750 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}}}"#,
1751 ];
1752 fs::write(project_dir.join("session-a.jsonl"), entries_a.join("\n")).unwrap();
1753
1754 let entries_b = [
1756 r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Continue the fix"}}"#,
1758 r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"What about the tests?"}}"#,
1760 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}}}"#,
1761 ];
1762 fs::write(project_dir.join("session-b.jsonl"), entries_b.join("\n")).unwrap();
1763
1764 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1765 (temp, ClaudeConvo::with_resolver(resolver))
1766 }
1767
1768 #[test]
1769 fn test_load_conversation_merges_chain() {
1770 let (_temp, provider) = setup_chained_provider();
1771
1772 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-a")
1774 .unwrap();
1775
1776 assert_eq!(view.turns.len(), 4);
1780 assert_eq!(view.turns[0].text, "Fix the bug");
1781 assert_eq!(view.turns[1].text, "I'll fix that.");
1782 assert_eq!(view.turns[2].text, "What about the tests?");
1783 assert_eq!(view.turns[3].text, "Tests pass now.");
1784
1785 assert_eq!(view.session_ids, vec!["session-a", "session-b"]);
1787 }
1788
1789 #[test]
1790 fn test_load_conversation_skips_bridge_entries() {
1791 let (_temp, provider) = setup_chained_provider();
1792
1793 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-a")
1794 .unwrap();
1795
1796 for turn in &view.turns {
1798 assert_ne!(turn.text, "Continue the fix");
1799 }
1800 }
1801
1802 #[test]
1803 fn test_load_conversation_single_segment_unchanged() {
1804 let temp = TempDir::new().unwrap();
1805 let claude_dir = temp.path().join(".claude");
1806 let project_dir = claude_dir.join("projects/-test-project");
1807 fs::create_dir_all(&project_dir).unwrap();
1808
1809 let entries = [
1810 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"solo","message":{"role":"user","content":"Hello"}}"#,
1811 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","sessionId":"solo","message":{"role":"assistant","content":"Hi there!"}}"#,
1812 ];
1813 fs::write(project_dir.join("solo.jsonl"), entries.join("\n")).unwrap();
1814
1815 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1816 let provider = ClaudeConvo::with_resolver(resolver);
1817 let view =
1818 ConversationProvider::load_conversation(&provider, "/test/project", "solo").unwrap();
1819
1820 assert_eq!(view.turns.len(), 2);
1821 assert_eq!(view.turns[0].text, "Hello");
1822 assert_eq!(view.turns[1].text, "Hi there!");
1823 assert!(view.session_ids.is_empty());
1825 }
1826
1827 #[test]
1828 fn test_list_metadata_chain_transparent() {
1829 let (_temp, provider) = setup_chained_provider();
1830
1831 let metas = ConversationProvider::list_metadata(&provider, "/test/project").unwrap();
1832
1833 assert_eq!(metas.len(), 1);
1835 assert_eq!(metas[0].id, "session-a");
1836
1837 assert!(metas[0].predecessor.is_none());
1839 assert!(metas[0].successor.is_none());
1840 }
1841
1842 #[cfg(feature = "watcher")]
1843 #[test]
1844 fn test_watcher_emits_rotation_progress() {
1845 let temp = TempDir::new().unwrap();
1846 let claude_dir = temp.path().join(".claude");
1847 let project_dir = claude_dir.join("projects/-test-project");
1848 fs::create_dir_all(&project_dir).unwrap();
1849
1850 let entry_a = r#"{"uuid":"a1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Hello"}}"#;
1852 fs::write(
1853 project_dir.join("session-a.jsonl"),
1854 format!("{}\n", entry_a),
1855 )
1856 .unwrap();
1857
1858 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1859 let manager = ClaudeConvo::with_resolver(resolver);
1860
1861 let mut watcher = crate::watcher::ConversationWatcher::new(
1862 manager,
1863 "/test/project".to_string(),
1864 "session-a".to_string(),
1865 );
1866
1867 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1869 assert_eq!(events.len(), 1);
1870 assert!(matches!(&events[0], WatcherEvent::Turn(_)));
1871
1872 let entries_b = [
1874 r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Bridge"}}"#,
1875 r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"New"}}"#,
1876 ];
1877 fs::write(project_dir.join("session-b.jsonl"), entries_b.join("\n")).unwrap();
1878
1879 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1881
1882 assert!(
1884 events.len() >= 2,
1885 "Expected Progress + Turn, got {} events",
1886 events.len()
1887 );
1888 match &events[0] {
1889 WatcherEvent::Progress { kind, data } => {
1890 assert_eq!(kind, "session_rotated");
1891 assert_eq!(data["from"], "session-a");
1892 assert_eq!(data["to"], "session-b");
1893 }
1894 other => panic!("Expected Progress, got {:?}", std::mem::discriminant(other)),
1895 }
1896
1897 match &events[1] {
1899 WatcherEvent::Turn(turn) => {
1900 assert_eq!(turn.id, "b1");
1901 assert_eq!(turn.text, "New");
1902 }
1903 other => panic!("Expected Turn(b1), got {:?}", std::mem::discriminant(other)),
1904 }
1905
1906 for event in &events {
1908 if let WatcherEvent::Turn(t) = event {
1909 assert_ne!(t.id, "b0", "Bridge entry should not appear as a Turn");
1910 }
1911 }
1912 }
1913
1914 #[test]
1915 fn test_load_metadata_chain_transparent() {
1916 let (_temp, provider) = setup_chained_provider();
1917
1918 let meta_a =
1920 ConversationProvider::load_metadata(&provider, "/test/project", "session-a").unwrap();
1921 assert_eq!(meta_a.id, "session-a");
1922 assert_eq!(meta_a.message_count, 5);
1924 assert!(meta_a.predecessor.is_none());
1926 assert!(meta_a.successor.is_none());
1927
1928 let meta_b =
1930 ConversationProvider::load_metadata(&provider, "/test/project", "session-b").unwrap();
1931 assert_eq!(meta_b.id, "session-a"); assert_eq!(meta_b.message_count, 5);
1933 assert!(meta_b.predecessor.is_none());
1934 assert!(meta_b.successor.is_none());
1935 }
1936}