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
29fn tool_category(name: &str) -> Option<ToolCategory> {
34 match name {
35 "Read" => Some(ToolCategory::FileRead),
36 "Glob" | "Grep" => Some(ToolCategory::FileSearch),
37 "Write" | "Edit" | "NotebookEdit" => Some(ToolCategory::FileWrite),
38 "Bash" => Some(ToolCategory::Shell),
39 "WebFetch" | "WebSearch" => Some(ToolCategory::Network),
40 "Task" => Some(ToolCategory::Delegation),
41 _ => None,
42 }
43}
44
45fn message_to_turn(entry: &ConversationEntry, msg: &Message) -> Turn {
48 let text = msg.text();
49
50 let thinking = msg.thinking().map(|parts| parts.join("\n"));
51
52 let tool_uses: Vec<ToolInvocation> = msg
53 .tool_uses()
54 .into_iter()
55 .map(|tu| {
56 let result = find_tool_result_in_parts(msg, tu.id);
57 let category = tool_category(tu.name);
58 ToolInvocation {
59 id: tu.id.to_string(),
60 name: tu.name.to_string(),
61 input: tu.input.clone(),
62 result,
63 category,
64 }
65 })
66 .collect();
67
68 let token_usage = msg.usage.as_ref().map(|u| TokenUsage {
69 input_tokens: u.input_tokens,
70 output_tokens: u.output_tokens,
71 cache_read_tokens: u.cache_read_input_tokens,
72 cache_write_tokens: u.cache_creation_input_tokens,
73 });
74
75 let environment = if entry.cwd.is_some() || entry.git_branch.is_some() {
76 Some(EnvironmentSnapshot {
77 working_dir: entry.cwd.clone(),
78 vcs_branch: entry.git_branch.clone(),
79 vcs_revision: None,
80 })
81 } else {
82 None
83 };
84
85 let delegations = extract_delegations(&tool_uses);
86
87 let extra = if entry.extra.is_empty() {
88 HashMap::new()
89 } else {
90 let mut map = HashMap::new();
91 map.insert(
92 "claude".to_string(),
93 serde_json::to_value(&entry.extra).unwrap_or_default(),
94 );
95 map
96 };
97
98 Turn {
99 id: entry.uuid.clone(),
100 parent_id: entry.parent_uuid.clone(),
101 role: claude_role_to_role(&msg.role),
102 timestamp: entry.timestamp.clone(),
103 text,
104 thinking,
105 tool_uses,
106 model: msg.model.clone(),
107 stop_reason: msg.stop_reason.clone(),
108 token_usage,
109 environment,
110 delegations,
111 extra,
112 }
113}
114
115fn extract_delegations(tool_uses: &[ToolInvocation]) -> Vec<DelegatedWork> {
117 tool_uses
118 .iter()
119 .filter(|tu| tu.category == Some(ToolCategory::Delegation))
120 .map(|tu| DelegatedWork {
121 agent_id: tu.id.clone(),
122 prompt: tu
123 .input
124 .get("prompt")
125 .and_then(|v| v.as_str())
126 .unwrap_or("")
127 .to_string(),
128 turns: vec![],
129 result: tu.result.as_ref().map(|r| r.content.clone()),
130 })
131 .collect()
132}
133
134fn find_tool_result_in_parts(msg: &Message, tool_use_id: &str) -> Option<ToolResult> {
135 let parts = match &msg.content {
136 Some(MessageContent::Parts(parts)) => parts,
137 _ => return None,
138 };
139 parts.iter().find_map(|p| match p {
140 crate::types::ContentPart::ToolResult {
141 tool_use_id: id,
142 content,
143 is_error,
144 } if id == tool_use_id => Some(ToolResult {
145 content: content.text(),
146 is_error: *is_error,
147 }),
148 _ => None,
149 })
150}
151
152fn is_tool_result_only(entry: &ConversationEntry) -> bool {
155 let Some(msg) = &entry.message else {
156 return false;
157 };
158 msg.role == MessageRole::User && msg.text().is_empty() && !msg.tool_results().is_empty()
159}
160
161fn merge_tool_results(turns: &mut [Turn], msg: &Message) -> bool {
170 let mut merged = false;
171 for tr in msg.tool_results() {
172 for turn in turns.iter_mut().rev() {
173 if let Some(invocation) = turn
174 .tool_uses
175 .iter_mut()
176 .find(|tu| tu.id == tr.tool_use_id && tu.result.is_none())
177 {
178 invocation.result = Some(ToolResult {
179 content: tr.content.text(),
180 is_error: tr.is_error,
181 });
182 merged = true;
183 break;
184 }
185 }
186 }
187 merged
188}
189
190fn entry_to_turn(entry: &ConversationEntry) -> Option<Turn> {
191 entry
192 .message
193 .as_ref()
194 .map(|msg| message_to_turn(entry, msg))
195}
196
197fn conversation_to_view(convo: &Conversation) -> ConversationView {
202 let mut turns: Vec<Turn> = Vec::new();
203
204 for entry in &convo.entries {
205 let Some(msg) = &entry.message else {
206 continue;
207 };
208
209 if is_tool_result_only(entry) {
211 merge_tool_results(&mut turns, msg);
212 continue;
213 }
214
215 turns.push(message_to_turn(entry, msg));
216 }
217
218 for turn in &mut turns {
220 for delegation in &mut turn.delegations {
221 if delegation.result.is_none()
222 && let Some(tu) = turn
223 .tool_uses
224 .iter()
225 .find(|tu| tu.id == delegation.agent_id)
226 {
227 delegation.result = tu.result.as_ref().map(|r| r.content.clone());
228 }
229 }
230 }
231
232 let total_usage = sum_usage(&turns);
233 let files_changed = extract_files_changed(&turns);
234
235 ConversationView {
236 id: convo.session_id.clone(),
237 started_at: convo.started_at,
238 last_activity: convo.last_activity,
239 turns,
240 total_usage,
241 provider_id: Some("claude-code".into()),
242 files_changed,
243 session_ids: vec![],
244 }
245}
246
247fn sum_usage(turns: &[Turn]) -> Option<TokenUsage> {
249 let mut total = TokenUsage::default();
250 let mut any = false;
251 for turn in turns {
252 if let Some(u) = &turn.token_usage {
253 any = true;
254 total.input_tokens =
255 Some(total.input_tokens.unwrap_or(0) + u.input_tokens.unwrap_or(0));
256 total.output_tokens =
257 Some(total.output_tokens.unwrap_or(0) + u.output_tokens.unwrap_or(0));
258 total.cache_read_tokens = match (total.cache_read_tokens, u.cache_read_tokens) {
259 (Some(a), Some(b)) => Some(a + b),
260 (Some(a), None) => Some(a),
261 (None, Some(b)) => Some(b),
262 (None, None) => None,
263 };
264 total.cache_write_tokens = match (total.cache_write_tokens, u.cache_write_tokens) {
265 (Some(a), Some(b)) => Some(a + b),
266 (Some(a), None) => Some(a),
267 (None, Some(b)) => Some(b),
268 (None, None) => None,
269 };
270 }
271 }
272 if any { Some(total) } else { None }
273}
274
275fn extract_files_changed(turns: &[Turn]) -> Vec<String> {
277 let mut seen = std::collections::HashSet::new();
278 let mut files = Vec::new();
279 for turn in turns {
280 for tool_use in &turn.tool_uses {
281 if tool_use.category == Some(ToolCategory::FileWrite)
282 && let Some(path) = tool_use.input.get("file_path").and_then(|v| v.as_str())
283 && seen.insert(path.to_string())
284 {
285 files.push(path.to_string());
286 }
287 }
288 }
289 files
290}
291
292#[cfg(any(feature = "watcher", test))]
293fn entry_to_watcher_event(entry: &ConversationEntry) -> WatcherEvent {
294 match entry_to_turn(entry) {
295 Some(turn) => WatcherEvent::Turn(Box::new(turn)),
296 None => {
297 let mut data = serde_json::json!({
298 "uuid": entry.uuid,
299 "timestamp": entry.timestamp,
300 });
301 if !entry.extra.is_empty() {
302 data["claude"] = serde_json::to_value(&entry.extra).unwrap_or_default();
303 }
304 WatcherEvent::Progress {
305 kind: entry.entry_type.clone(),
306 data,
307 }
308 }
309 }
310}
311
312impl ConversationProvider for ClaudeConvo {
315 fn list_conversations(&self, project: &str) -> toolpath_convo::Result<Vec<String>> {
316 crate::ClaudeConvo::list_conversations(self, project)
317 .map_err(|e| ConvoError::Provider(e.to_string()))
318 }
319
320 fn load_conversation(
321 &self,
322 project: &str,
323 conversation_id: &str,
324 ) -> toolpath_convo::Result<ConversationView> {
325 let convo = self
326 .read_conversation(project, conversation_id)
327 .map_err(|e| ConvoError::Provider(e.to_string()))?;
328 let mut view = conversation_to_view(&convo);
329 view.session_ids = convo.session_ids.clone();
330 Ok(view)
331 }
332
333 fn load_metadata(
334 &self,
335 project: &str,
336 conversation_id: &str,
337 ) -> toolpath_convo::Result<ConversationMeta> {
338 let meta = self
339 .read_conversation_metadata(project, conversation_id)
340 .map_err(|e| ConvoError::Provider(e.to_string()))?;
341
342 Ok(ConversationMeta {
343 id: meta.session_id,
344 started_at: meta.started_at,
345 last_activity: meta.last_activity,
346 message_count: meta.message_count,
347 file_path: Some(meta.file_path),
348 predecessor: None,
349 successor: None,
350 })
351 }
352
353 fn list_metadata(&self, project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
354 let metas = self
355 .list_conversation_metadata(project)
356 .map_err(|e| ConvoError::Provider(e.to_string()))?;
357
358 Ok(metas
359 .into_iter()
360 .map(|m| ConversationMeta {
361 id: m.session_id,
362 started_at: m.started_at,
363 last_activity: m.last_activity,
364 message_count: m.message_count,
365 file_path: Some(m.file_path),
366 predecessor: None,
367 successor: None,
368 })
369 .collect())
370 }
371}
372
373#[cfg(feature = "watcher")]
376impl toolpath_convo::ConversationWatcher for crate::watcher::ConversationWatcher {
377 fn poll(&mut self) -> toolpath_convo::Result<Vec<WatcherEvent>> {
378 let entries = crate::watcher::ConversationWatcher::poll(self)
379 .map_err(|e| ConvoError::Provider(e.to_string()))?;
380
381 let mut events: Vec<WatcherEvent> = Vec::new();
382
383 for (from, to) in self.take_pending_rotations() {
385 events.push(WatcherEvent::Progress {
386 kind: "session_rotated".into(),
387 data: serde_json::json!({
388 "from": from,
389 "to": to,
390 }),
391 });
392 }
393
394 for entry in &entries {
395 let Some(msg) = &entry.message else {
396 events.push(entry_to_watcher_event(entry));
397 continue;
398 };
399
400 if is_tool_result_only(entry) {
401 let mut updated_turn: Option<Turn> = None;
405
406 for event in events.iter_mut().rev() {
408 if let WatcherEvent::Turn(turn) | WatcherEvent::TurnUpdated(turn) = event
409 && turn.tool_uses.iter().any(|tu| {
410 tu.result.is_none()
411 && msg.tool_results().iter().any(|tr| tr.tool_use_id == tu.id)
412 })
413 {
414 let mut updated = (**turn).clone();
416 merge_tool_results(std::slice::from_mut(&mut updated), msg);
417 updated_turn = Some(updated.clone());
418 **turn = updated;
421 break;
422 }
423 }
424
425 if let Some(turn) = updated_turn {
426 events.push(WatcherEvent::TurnUpdated(Box::new(turn)));
427 }
428 continue;
432 }
433
434 events.push(entry_to_watcher_event(entry));
435 }
436
437 Ok(events)
438 }
439
440 fn seen_count(&self) -> usize {
441 crate::watcher::ConversationWatcher::seen_count(self)
442 }
443}
444
445pub fn to_view(convo: &Conversation) -> ConversationView {
453 conversation_to_view(convo)
454}
455
456pub fn to_turn(entry: &ConversationEntry) -> Option<Turn> {
462 entry_to_turn(entry)
463}
464
465#[cfg(test)]
468mod tests {
469 use super::*;
470 use crate::PathResolver;
471 use std::fs;
472 use tempfile::TempDir;
473
474 fn setup_provider() -> (TempDir, ClaudeConvo) {
475 let temp = TempDir::new().unwrap();
476 let claude_dir = temp.path().join(".claude");
477 let project_dir = claude_dir.join("projects/-test-project");
478 fs::create_dir_all(&project_dir).unwrap();
479
480 let entries = [
481 r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Fix the bug"}}"#,
482 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}}}"#,
483 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}]}}"#,
484 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}}}"#,
485 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}]}}"#,
486 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"}}"#,
487 r#"{"uuid":"uuid-7","type":"user","parentUuid":"uuid-6","timestamp":"2024-01-01T00:00:06Z","message":{"role":"user","content":"Thanks!"}}"#,
488 ];
489 fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap();
490
491 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
492 (temp, ClaudeConvo::with_resolver(resolver))
493 }
494
495 #[test]
496 fn test_load_conversation_assembles_tool_results() {
497 let (_temp, provider) = setup_provider();
498 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
499 .unwrap();
500
501 assert_eq!(view.id, "session-1");
502 assert_eq!(view.turns.len(), 5);
504
505 assert_eq!(view.turns[0].role, Role::User);
507 assert_eq!(view.turns[0].text, "Fix the bug");
508 assert!(view.turns[0].parent_id.is_none());
509
510 assert_eq!(view.turns[1].role, Role::Assistant);
512 assert_eq!(view.turns[1].text, "I'll fix that.");
513 assert_eq!(
514 view.turns[1].thinking.as_deref(),
515 Some("The bug is in auth")
516 );
517 assert_eq!(view.turns[1].tool_uses.len(), 1);
518 assert_eq!(view.turns[1].tool_uses[0].name, "Read");
519 assert_eq!(view.turns[1].tool_uses[0].id, "t1");
520 let result = view.turns[1].tool_uses[0].result.as_ref().unwrap();
522 assert!(!result.is_error);
523 assert!(result.content.contains("fn main()"));
524 assert_eq!(view.turns[1].model.as_deref(), Some("claude-opus-4-6"));
525 assert_eq!(view.turns[1].stop_reason.as_deref(), Some("tool_use"));
526 assert_eq!(view.turns[1].parent_id.as_deref(), Some("uuid-1"));
527
528 let usage = view.turns[1].token_usage.as_ref().unwrap();
530 assert_eq!(usage.input_tokens, Some(100));
531 assert_eq!(usage.output_tokens, Some(50));
532
533 assert_eq!(view.turns[2].role, Role::Assistant);
535 assert_eq!(view.turns[2].text, "I see the issue. Let me fix it.");
536 assert_eq!(view.turns[2].tool_uses[0].name, "Edit");
537 let result2 = view.turns[2].tool_uses[0].result.as_ref().unwrap();
538 assert_eq!(result2.content, "File written successfully");
539
540 assert_eq!(view.turns[3].role, Role::Assistant);
542 assert_eq!(view.turns[3].text, "Done! The bug is fixed.");
543 assert!(view.turns[3].tool_uses.is_empty());
544
545 assert_eq!(view.turns[4].role, Role::User);
547 assert_eq!(view.turns[4].text, "Thanks!");
548 }
549
550 #[test]
551 fn test_no_phantom_empty_turns() {
552 let (_temp, provider) = setup_provider();
553 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
554 .unwrap();
555
556 for turn in &view.turns {
558 if turn.role == Role::User {
559 assert!(
560 !turn.text.is_empty(),
561 "Found phantom empty user turn: {:?}",
562 turn.id
563 );
564 }
565 }
566 }
567
568 #[test]
569 fn test_tool_result_error_flag() {
570 let temp = TempDir::new().unwrap();
571 let claude_dir = temp.path().join(".claude");
572 let project_dir = claude_dir.join("projects/-test-project");
573 fs::create_dir_all(&project_dir).unwrap();
574
575 let entries = [
576 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read a file"}}"#,
577 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"}}"#,
578 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}]}}"#,
579 ];
580 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
581
582 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
583 let provider = ClaudeConvo::with_resolver(resolver);
584 let view =
585 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
586
587 assert_eq!(view.turns.len(), 2); let result = view.turns[1].tool_uses[0].result.as_ref().unwrap();
589 assert!(result.is_error);
590 assert_eq!(result.content, "File not found");
591 }
592
593 #[test]
594 fn test_multiple_tool_uses_single_result_entry() {
595 let temp = TempDir::new().unwrap();
596 let claude_dir = temp.path().join(".claude");
597 let project_dir = claude_dir.join("projects/-test-project");
598 fs::create_dir_all(&project_dir).unwrap();
599
600 let entries = [
601 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Check two files"}}"#,
602 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"}}]}}"#,
603 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}]}}"#,
604 ];
605 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
606
607 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
608 let provider = ClaudeConvo::with_resolver(resolver);
609 let view =
610 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
611
612 assert_eq!(view.turns.len(), 2);
613 assert_eq!(view.turns[1].tool_uses.len(), 2);
614
615 let r1 = view.turns[1].tool_uses[0].result.as_ref().unwrap();
616 assert_eq!(r1.content, "file a contents");
617
618 let r2 = view.turns[1].tool_uses[1].result.as_ref().unwrap();
619 assert_eq!(r2.content, "file b contents");
620 }
621
622 #[test]
623 fn test_conversation_without_tool_use_unchanged() {
624 let temp = TempDir::new().unwrap();
625 let claude_dir = temp.path().join(".claude");
626 let project_dir = claude_dir.join("projects/-test-project");
627 fs::create_dir_all(&project_dir).unwrap();
628
629 let entries = [
630 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
631 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi there!"}}"#,
632 ];
633 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
634
635 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
636 let provider = ClaudeConvo::with_resolver(resolver);
637 let view =
638 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
639
640 assert_eq!(view.turns.len(), 2);
641 assert_eq!(view.turns[0].text, "Hello");
642 assert_eq!(view.turns[1].text, "Hi there!");
643 }
644
645 #[test]
646 fn test_assistant_turn_without_result_has_none() {
647 let temp = TempDir::new().unwrap();
649 let claude_dir = temp.path().join(".claude");
650 let project_dir = claude_dir.join("projects/-test-project");
651 fs::create_dir_all(&project_dir).unwrap();
652
653 let entries = [
654 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read a file"}}"#,
655 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"}}]}}"#,
656 ];
657 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
658
659 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
660 let provider = ClaudeConvo::with_resolver(resolver);
661 let view =
662 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
663
664 assert_eq!(view.turns.len(), 2);
665 assert!(view.turns[1].tool_uses[0].result.is_none());
666 }
667
668 #[test]
669 fn test_list_conversations() {
670 let (_temp, provider) = setup_provider();
671 let ids = ConversationProvider::list_conversations(&provider, "/test/project").unwrap();
672 assert_eq!(ids, vec!["session-1"]);
673 }
674
675 #[test]
676 fn test_load_metadata() {
677 let (_temp, provider) = setup_provider();
678 let meta =
679 ConversationProvider::load_metadata(&provider, "/test/project", "session-1").unwrap();
680 assert_eq!(meta.id, "session-1");
681 assert_eq!(meta.message_count, 7);
682 assert!(meta.file_path.is_some());
683 }
684
685 #[test]
686 fn test_list_metadata() {
687 let (_temp, provider) = setup_provider();
688 let metas = ConversationProvider::list_metadata(&provider, "/test/project").unwrap();
689 assert_eq!(metas.len(), 1);
690 assert_eq!(metas[0].id, "session-1");
691 }
692
693 #[test]
694 fn test_to_view() {
695 let (_temp, manager) = setup_provider();
696 let convo = manager
697 .read_conversation("/test/project", "session-1")
698 .unwrap();
699 let view = to_view(&convo);
700 assert_eq!(view.turns.len(), 5);
701 assert_eq!(view.title(20).unwrap(), "Fix the bug");
702 }
703
704 #[test]
705 fn test_to_turn_with_message() {
706 let entry: ConversationEntry = serde_json::from_str(
707 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
708 )
709 .unwrap();
710 let turn = to_turn(&entry).unwrap();
711 assert_eq!(turn.id, "u1");
712 assert_eq!(turn.text, "hello");
713 assert_eq!(turn.role, Role::User);
714 }
715
716 #[test]
717 fn test_to_turn_without_message() {
718 let entry: ConversationEntry = serde_json::from_str(
719 r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
720 )
721 .unwrap();
722 assert!(to_turn(&entry).is_none());
723 }
724
725 #[test]
726 fn test_entry_to_watcher_event_turn() {
727 let entry: ConversationEntry = serde_json::from_str(
728 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hi"}}"#,
729 )
730 .unwrap();
731 let event = entry_to_watcher_event(&entry);
732 assert!(matches!(event, WatcherEvent::Turn(_)));
733 }
734
735 #[test]
736 fn test_entry_to_watcher_event_progress() {
737 let entry: ConversationEntry = serde_json::from_str(
738 r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
739 )
740 .unwrap();
741 let event = entry_to_watcher_event(&entry);
742 assert!(matches!(event, WatcherEvent::Progress { .. }));
743 }
744
745 #[cfg(feature = "watcher")]
746 #[test]
747 fn test_watcher_trait_basic() {
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":"Hello"}}"#,
755 r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
756 ];
757 fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap();
758
759 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
760 let manager = ClaudeConvo::with_resolver(resolver);
761
762 let mut watcher = crate::watcher::ConversationWatcher::new(
763 manager,
764 "/test/project".to_string(),
765 "session-1".to_string(),
766 );
767
768 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
770 assert_eq!(events.len(), 2);
771 assert!(matches!(&events[0], WatcherEvent::Turn(t) if t.role == Role::User));
772 assert!(matches!(&events[1], WatcherEvent::Turn(t) if t.role == Role::Assistant));
773 assert_eq!(toolpath_convo::ConversationWatcher::seen_count(&watcher), 2);
774
775 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
777 assert!(events.is_empty());
778 }
779
780 #[cfg(feature = "watcher")]
781 #[test]
782 fn test_watcher_trait_assembles_tool_results() {
783 let temp = TempDir::new().unwrap();
784 let claude_dir = temp.path().join(".claude");
785 let project_dir = claude_dir.join("projects/-test-project");
786 fs::create_dir_all(&project_dir).unwrap();
787
788 let entries = [
789 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read the file"}}"#,
790 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"}}]}}"#,
791 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}]}}"#,
792 r#"{"uuid":"u4","type":"assistant","timestamp":"2024-01-01T00:00:03Z","message":{"role":"assistant","content":"Done!"}}"#,
793 ];
794 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
795
796 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
797 let manager = ClaudeConvo::with_resolver(resolver);
798
799 let mut watcher = crate::watcher::ConversationWatcher::new(
800 manager,
801 "/test/project".to_string(),
802 "s1".to_string(),
803 );
804
805 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
806
807 assert_eq!(events.len(), 4);
809
810 assert!(matches!(&events[0], WatcherEvent::Turn(t) if t.role == Role::User));
812
813 assert!(matches!(&events[1], WatcherEvent::Turn(t) if t.role == Role::Assistant));
815
816 match &events[2] {
818 WatcherEvent::TurnUpdated(turn) => {
819 assert_eq!(turn.id, "u2");
820 assert_eq!(turn.tool_uses.len(), 1);
821 let result = turn.tool_uses[0].result.as_ref().unwrap();
822 assert_eq!(result.content, "fn main() {}");
823 assert!(!result.is_error);
824 }
825 other => panic!("Expected TurnUpdated, got {:?}", other),
826 }
827
828 assert!(matches!(&events[3], WatcherEvent::Turn(t) if t.text == "Done!"));
830 }
831
832 #[cfg(feature = "watcher")]
833 #[test]
834 fn test_watcher_trait_incremental_tool_results() {
835 let temp = TempDir::new().unwrap();
837 let claude_dir = temp.path().join(".claude");
838 let project_dir = claude_dir.join("projects/-test-project");
839 fs::create_dir_all(&project_dir).unwrap();
840
841 let entries_phase1 = [
843 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read file"}}"#,
844 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"}}]}}"#,
845 ];
846 fs::write(
847 project_dir.join("s1.jsonl"),
848 entries_phase1.join("\n") + "\n",
849 )
850 .unwrap();
851
852 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
853 let manager = ClaudeConvo::with_resolver(resolver);
854
855 let mut watcher = crate::watcher::ConversationWatcher::new(
856 manager,
857 "/test/project".to_string(),
858 "s1".to_string(),
859 );
860
861 let events1 = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
863 assert_eq!(events1.len(), 2);
864 if let WatcherEvent::Turn(t) = &events1[1] {
866 assert!(t.tool_uses[0].result.is_none());
867 } else {
868 panic!("Expected Turn");
869 }
870
871 use std::io::Write;
873 let mut file = fs::OpenOptions::new()
874 .append(true)
875 .open(project_dir.join("s1.jsonl"))
876 .unwrap();
877 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();
878
879 let events2 = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
881 assert!(events2.is_empty() || events2.iter().all(|e| !matches!(e, WatcherEvent::Turn(_))));
888 }
889
890 #[test]
891 fn test_merge_tool_results_by_id() {
892 let mut turns = vec![Turn {
894 id: "t1".into(),
895 parent_id: None,
896 role: Role::Assistant,
897 timestamp: "2024-01-01T00:00:00Z".into(),
898 text: "test".into(),
899 thinking: None,
900 tool_uses: vec![
901 ToolInvocation {
902 id: "tool-a".into(),
903 name: "Read".into(),
904 input: serde_json::json!({}),
905 result: None,
906 category: Some(ToolCategory::FileRead),
907 },
908 ToolInvocation {
909 id: "tool-b".into(),
910 name: "Write".into(),
911 input: serde_json::json!({}),
912 result: None,
913 category: Some(ToolCategory::FileWrite),
914 },
915 ],
916 model: None,
917 stop_reason: None,
918 token_usage: None,
919 environment: None,
920 delegations: vec![],
921 extra: Default::default(),
922 }];
923
924 let msg: Message = serde_json::from_str(
926 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}]}"#,
927 )
928 .unwrap();
929
930 let merged = merge_tool_results(&mut turns, &msg);
931 assert!(merged);
932
933 assert_eq!(
935 turns[0].tool_uses[0].result.as_ref().unwrap().content,
936 "read result"
937 );
938 assert!(turns[0].tool_uses[0].result.as_ref().unwrap().is_error);
939
940 assert_eq!(
941 turns[0].tool_uses[1].result.as_ref().unwrap().content,
942 "write result"
943 );
944 assert!(!turns[0].tool_uses[1].result.as_ref().unwrap().is_error);
945 }
946
947 #[test]
948 fn test_is_tool_result_only() {
949 let entry: ConversationEntry = serde_json::from_str(
951 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}]}}"#,
952 )
953 .unwrap();
954 assert!(is_tool_result_only(&entry));
955
956 let entry: ConversationEntry = serde_json::from_str(
958 r#"{"uuid":"u2","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
959 )
960 .unwrap();
961 assert!(!is_tool_result_only(&entry));
962
963 let entry: ConversationEntry = serde_json::from_str(
965 r#"{"uuid":"u3","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
966 )
967 .unwrap();
968 assert!(!is_tool_result_only(&entry));
969
970 let entry: ConversationEntry = serde_json::from_str(
972 r#"{"uuid":"u4","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"hi"}}"#,
973 )
974 .unwrap();
975 assert!(!is_tool_result_only(&entry));
976 }
977
978 #[test]
981 fn test_tool_category_mapping() {
982 assert_eq!(tool_category("Read"), Some(ToolCategory::FileRead));
983 assert_eq!(tool_category("Glob"), Some(ToolCategory::FileSearch));
984 assert_eq!(tool_category("Grep"), Some(ToolCategory::FileSearch));
985 assert_eq!(tool_category("Write"), Some(ToolCategory::FileWrite));
986 assert_eq!(tool_category("Edit"), Some(ToolCategory::FileWrite));
987 assert_eq!(tool_category("NotebookEdit"), Some(ToolCategory::FileWrite));
988 assert_eq!(tool_category("Bash"), Some(ToolCategory::Shell));
989 assert_eq!(tool_category("WebFetch"), Some(ToolCategory::Network));
990 assert_eq!(tool_category("WebSearch"), Some(ToolCategory::Network));
991 assert_eq!(tool_category("Task"), Some(ToolCategory::Delegation));
992 assert_eq!(tool_category("UnknownTool"), None);
993 }
994
995 #[test]
996 fn test_turn_has_tool_category() {
997 let (_temp, provider) = setup_provider();
998 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
999 .unwrap();
1000
1001 assert_eq!(
1003 view.turns[1].tool_uses[0].category,
1004 Some(ToolCategory::FileRead)
1005 );
1006 assert_eq!(
1008 view.turns[2].tool_uses[0].category,
1009 Some(ToolCategory::FileWrite)
1010 );
1011 }
1012
1013 #[test]
1014 fn test_environment_populated_from_entry() {
1015 let temp = TempDir::new().unwrap();
1016 let claude_dir = temp.path().join(".claude");
1017 let project_dir = claude_dir.join("projects/-test-project");
1018 fs::create_dir_all(&project_dir).unwrap();
1019
1020 let entries = [
1021 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","cwd":"/project/path","gitBranch":"feat/auth","message":{"role":"user","content":"Hello"}}"#,
1022 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
1023 ];
1024 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1025
1026 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1027 let provider = ClaudeConvo::with_resolver(resolver);
1028 let view =
1029 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1030
1031 let env = view.turns[0].environment.as_ref().unwrap();
1033 assert_eq!(env.working_dir.as_deref(), Some("/project/path"));
1034 assert_eq!(env.vcs_branch.as_deref(), Some("feat/auth"));
1035 assert!(env.vcs_revision.is_none());
1036
1037 assert!(view.turns[1].environment.is_none());
1039 }
1040
1041 #[test]
1042 fn test_cache_tokens_populated() {
1043 let temp = TempDir::new().unwrap();
1044 let claude_dir = temp.path().join(".claude");
1045 let project_dir = claude_dir.join("projects/-test-project");
1046 fs::create_dir_all(&project_dir).unwrap();
1047
1048 let entries = [
1049 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
1050 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}}}"#,
1051 ];
1052 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1053
1054 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1055 let provider = ClaudeConvo::with_resolver(resolver);
1056 let view =
1057 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1058
1059 let usage = view.turns[1].token_usage.as_ref().unwrap();
1060 assert_eq!(usage.cache_read_tokens, Some(500));
1061 assert_eq!(usage.cache_write_tokens, Some(200));
1062 }
1063
1064 #[test]
1065 fn test_total_usage_aggregated() {
1066 let (_temp, provider) = setup_provider();
1067 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1068 .unwrap();
1069
1070 let total = view.total_usage.as_ref().unwrap();
1071 assert_eq!(total.input_tokens, Some(300));
1073 assert_eq!(total.output_tokens, Some(150));
1074 }
1075
1076 #[test]
1077 fn test_provider_id_set() {
1078 let (_temp, provider) = setup_provider();
1079 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1080 .unwrap();
1081
1082 assert_eq!(view.provider_id.as_deref(), Some("claude-code"));
1083 }
1084
1085 #[test]
1086 fn test_files_changed_populated() {
1087 let temp = TempDir::new().unwrap();
1088 let claude_dir = temp.path().join(".claude");
1089 let project_dir = claude_dir.join("projects/-test-project");
1090 fs::create_dir_all(&project_dir).unwrap();
1091
1092 let entries = [
1093 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Edit files"}}"#,
1094 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"}}]}}"#,
1095 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}]}}"#,
1096 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"}}]}}"#,
1097 ];
1098 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1099
1100 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1101 let provider = ClaudeConvo::with_resolver(resolver);
1102 let view =
1103 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1104
1105 assert_eq!(view.files_changed, vec!["src/main.rs", "src/lib.rs"]);
1107 }
1108
1109 #[test]
1110 fn test_delegations_extracted() {
1111 let temp = TempDir::new().unwrap();
1112 let claude_dir = temp.path().join(".claude");
1113 let project_dir = claude_dir.join("projects/-test-project");
1114 fs::create_dir_all(&project_dir).unwrap();
1115
1116 let entries = [
1117 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Search for bugs"}}"#,
1118 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"}}]}}"#,
1119 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}]}}"#,
1120 ];
1121 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1122
1123 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1124 let provider = ClaudeConvo::with_resolver(resolver);
1125 let view =
1126 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1127
1128 assert_eq!(view.turns[1].delegations.len(), 1);
1130 let d = &view.turns[1].delegations[0];
1131 assert_eq!(d.agent_id, "task-1");
1132 assert_eq!(d.prompt, "Find the authentication bug");
1133 assert!(d.turns.is_empty()); assert_eq!(
1136 d.result.as_deref(),
1137 Some("Found the bug in auth.rs line 42")
1138 );
1139 }
1140
1141 #[test]
1144 fn test_turn_extra_populated_from_entry() {
1145 let entry: ConversationEntry = serde_json::from_str(
1146 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","subtype":"init","message":{"role":"user","content":"hello"}}"#,
1147 )
1148 .unwrap();
1149 let turn = to_turn(&entry).unwrap();
1150 let claude = turn.extra.get("claude").expect("extra[\"claude\"] missing");
1151 assert_eq!(claude["subtype"], "init");
1152 }
1153
1154 #[test]
1155 fn test_turn_extra_empty_when_no_extras() {
1156 let entry: ConversationEntry = serde_json::from_str(
1157 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
1158 )
1159 .unwrap();
1160 let turn = to_turn(&entry).unwrap();
1161 assert!(turn.extra.is_empty());
1162 }
1163
1164 #[test]
1165 fn test_progress_data_enriched_with_extras() {
1166 let entry: ConversationEntry = serde_json::from_str(
1167 r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z","data":{"type":"hook_progress","hookName":"pre-commit"}}"#,
1168 )
1169 .unwrap();
1170 let event = entry_to_watcher_event(&entry);
1171 match event {
1172 WatcherEvent::Progress { kind, data } => {
1173 assert_eq!(kind, "progress");
1174 assert_eq!(data["uuid"], "u1");
1175 assert_eq!(data["timestamp"], "2024-01-01T00:00:00Z");
1176 let claude = &data["claude"];
1177 assert_eq!(claude["data"]["type"], "hook_progress");
1178 assert_eq!(claude["data"]["hookName"], "pre-commit");
1179 }
1180 other => panic!(
1181 "Expected Progress, got {:?}",
1182 std::mem::discriminant(&other)
1183 ),
1184 }
1185 }
1186
1187 #[test]
1188 fn test_progress_data_no_claude_key_when_no_extras() {
1189 let entry: ConversationEntry = serde_json::from_str(
1190 r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
1191 )
1192 .unwrap();
1193 let event = entry_to_watcher_event(&entry);
1194 match event {
1195 WatcherEvent::Progress { data, .. } => {
1196 assert!(data.get("claude").is_none());
1197 }
1198 other => panic!(
1199 "Expected Progress, got {:?}",
1200 std::mem::discriminant(&other)
1201 ),
1202 }
1203 }
1204
1205 #[test]
1206 fn test_no_delegations_for_non_task_tools() {
1207 let (_temp, provider) = setup_provider();
1208 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1209 .unwrap();
1210
1211 for turn in &view.turns {
1213 assert!(turn.delegations.is_empty());
1214 }
1215 }
1216
1217 fn setup_chained_provider() -> (TempDir, ClaudeConvo) {
1220 let temp = TempDir::new().unwrap();
1221 let claude_dir = temp.path().join(".claude");
1222 let project_dir = claude_dir.join("projects/-test-project");
1223 fs::create_dir_all(&project_dir).unwrap();
1224
1225 let entries_a = [
1227 r#"{"uuid":"a1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Fix the bug"}}"#,
1228 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}}}"#,
1229 ];
1230 fs::write(project_dir.join("session-a.jsonl"), entries_a.join("\n")).unwrap();
1231
1232 let entries_b = [
1234 r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Continue the fix"}}"#,
1236 r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"What about the tests?"}}"#,
1238 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}}}"#,
1239 ];
1240 fs::write(project_dir.join("session-b.jsonl"), entries_b.join("\n")).unwrap();
1241
1242 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1243 (temp, ClaudeConvo::with_resolver(resolver))
1244 }
1245
1246 #[test]
1247 fn test_load_conversation_merges_chain() {
1248 let (_temp, provider) = setup_chained_provider();
1249
1250 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-a")
1252 .unwrap();
1253
1254 assert_eq!(view.turns.len(), 4);
1258 assert_eq!(view.turns[0].text, "Fix the bug");
1259 assert_eq!(view.turns[1].text, "I'll fix that.");
1260 assert_eq!(view.turns[2].text, "What about the tests?");
1261 assert_eq!(view.turns[3].text, "Tests pass now.");
1262
1263 assert_eq!(view.session_ids, vec!["session-a", "session-b"]);
1265 }
1266
1267 #[test]
1268 fn test_load_conversation_skips_bridge_entries() {
1269 let (_temp, provider) = setup_chained_provider();
1270
1271 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-a")
1272 .unwrap();
1273
1274 for turn in &view.turns {
1276 assert_ne!(turn.text, "Continue the fix");
1277 }
1278 }
1279
1280 #[test]
1281 fn test_load_conversation_single_segment_unchanged() {
1282 let temp = TempDir::new().unwrap();
1283 let claude_dir = temp.path().join(".claude");
1284 let project_dir = claude_dir.join("projects/-test-project");
1285 fs::create_dir_all(&project_dir).unwrap();
1286
1287 let entries = [
1288 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"solo","message":{"role":"user","content":"Hello"}}"#,
1289 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","sessionId":"solo","message":{"role":"assistant","content":"Hi there!"}}"#,
1290 ];
1291 fs::write(project_dir.join("solo.jsonl"), entries.join("\n")).unwrap();
1292
1293 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1294 let provider = ClaudeConvo::with_resolver(resolver);
1295 let view =
1296 ConversationProvider::load_conversation(&provider, "/test/project", "solo").unwrap();
1297
1298 assert_eq!(view.turns.len(), 2);
1299 assert_eq!(view.turns[0].text, "Hello");
1300 assert_eq!(view.turns[1].text, "Hi there!");
1301 assert!(view.session_ids.is_empty());
1303 }
1304
1305 #[test]
1306 fn test_list_metadata_chain_transparent() {
1307 let (_temp, provider) = setup_chained_provider();
1308
1309 let metas = ConversationProvider::list_metadata(&provider, "/test/project").unwrap();
1310
1311 assert_eq!(metas.len(), 1);
1313 assert_eq!(metas[0].id, "session-a");
1314
1315 assert!(metas[0].predecessor.is_none());
1317 assert!(metas[0].successor.is_none());
1318 }
1319
1320 #[cfg(feature = "watcher")]
1321 #[test]
1322 fn test_watcher_emits_rotation_progress() {
1323 let temp = TempDir::new().unwrap();
1324 let claude_dir = temp.path().join(".claude");
1325 let project_dir = claude_dir.join("projects/-test-project");
1326 fs::create_dir_all(&project_dir).unwrap();
1327
1328 let entry_a = r#"{"uuid":"a1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Hello"}}"#;
1330 fs::write(
1331 project_dir.join("session-a.jsonl"),
1332 format!("{}\n", entry_a),
1333 )
1334 .unwrap();
1335
1336 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1337 let manager = ClaudeConvo::with_resolver(resolver);
1338
1339 let mut watcher = crate::watcher::ConversationWatcher::new(
1340 manager,
1341 "/test/project".to_string(),
1342 "session-a".to_string(),
1343 );
1344
1345 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1347 assert_eq!(events.len(), 1);
1348 assert!(matches!(&events[0], WatcherEvent::Turn(_)));
1349
1350 let entries_b = [
1352 r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Bridge"}}"#,
1353 r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"New"}}"#,
1354 ];
1355 fs::write(project_dir.join("session-b.jsonl"), entries_b.join("\n")).unwrap();
1356
1357 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1359
1360 assert!(
1362 events.len() >= 2,
1363 "Expected Progress + Turn, got {} events",
1364 events.len()
1365 );
1366 match &events[0] {
1367 WatcherEvent::Progress { kind, data } => {
1368 assert_eq!(kind, "session_rotated");
1369 assert_eq!(data["from"], "session-a");
1370 assert_eq!(data["to"], "session-b");
1371 }
1372 other => panic!("Expected Progress, got {:?}", std::mem::discriminant(other)),
1373 }
1374
1375 match &events[1] {
1377 WatcherEvent::Turn(turn) => {
1378 assert_eq!(turn.id, "b1");
1379 assert_eq!(turn.text, "New");
1380 }
1381 other => panic!("Expected Turn(b1), got {:?}", std::mem::discriminant(other)),
1382 }
1383
1384 for event in &events {
1386 if let WatcherEvent::Turn(t) = event {
1387 assert_ne!(t.id, "b0", "Bridge entry should not appear as a Turn");
1388 }
1389 }
1390 }
1391
1392 #[test]
1393 fn test_load_metadata_chain_transparent() {
1394 let (_temp, provider) = setup_chained_provider();
1395
1396 let meta_a =
1398 ConversationProvider::load_metadata(&provider, "/test/project", "session-a").unwrap();
1399 assert_eq!(meta_a.id, "session-a");
1400 assert_eq!(meta_a.message_count, 5);
1402 assert!(meta_a.predecessor.is_none());
1404 assert!(meta_a.successor.is_none());
1405
1406 let meta_b =
1408 ConversationProvider::load_metadata(&provider, "/test/project", "session-b").unwrap();
1409 assert_eq!(meta_b.id, "session-a"); assert_eq!(meta_b.message_count, 5);
1411 assert!(meta_b.predecessor.is_none());
1412 assert!(meta_b.successor.is_none());
1413 }
1414}