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" | "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
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 events: vec![],
245 }
246}
247
248fn sum_usage(turns: &[Turn]) -> Option<TokenUsage> {
250 let mut total = TokenUsage::default();
251 let mut any = false;
252 for turn in turns {
253 if let Some(u) = &turn.token_usage {
254 any = true;
255 total.input_tokens =
256 Some(total.input_tokens.unwrap_or(0) + u.input_tokens.unwrap_or(0));
257 total.output_tokens =
258 Some(total.output_tokens.unwrap_or(0) + u.output_tokens.unwrap_or(0));
259 total.cache_read_tokens = match (total.cache_read_tokens, u.cache_read_tokens) {
260 (Some(a), Some(b)) => Some(a + b),
261 (Some(a), None) => Some(a),
262 (None, Some(b)) => Some(b),
263 (None, None) => None,
264 };
265 total.cache_write_tokens = match (total.cache_write_tokens, u.cache_write_tokens) {
266 (Some(a), Some(b)) => Some(a + b),
267 (Some(a), None) => Some(a),
268 (None, Some(b)) => Some(b),
269 (None, None) => None,
270 };
271 }
272 }
273 if any { Some(total) } else { None }
274}
275
276fn extract_files_changed(turns: &[Turn]) -> Vec<String> {
278 let mut seen = std::collections::HashSet::new();
279 let mut files = Vec::new();
280 for turn in turns {
281 for tool_use in &turn.tool_uses {
282 if tool_use.category == Some(ToolCategory::FileWrite)
283 && let Some(path) = tool_use.input.get("file_path").and_then(|v| v.as_str())
284 && seen.insert(path.to_string())
285 {
286 files.push(path.to_string());
287 }
288 }
289 }
290 files
291}
292
293#[cfg(any(feature = "watcher", test))]
294fn entry_to_watcher_event(entry: &ConversationEntry) -> WatcherEvent {
295 match entry_to_turn(entry) {
296 Some(turn) => WatcherEvent::Turn(Box::new(turn)),
297 None => {
298 let mut data = serde_json::json!({
299 "uuid": entry.uuid,
300 "timestamp": entry.timestamp,
301 });
302 if !entry.extra.is_empty() {
303 data["claude"] = serde_json::to_value(&entry.extra).unwrap_or_default();
304 }
305 WatcherEvent::Progress {
306 kind: entry.entry_type.clone(),
307 data,
308 }
309 }
310 }
311}
312
313impl ConversationProvider for ClaudeConvo {
316 fn list_conversations(&self, project: &str) -> toolpath_convo::Result<Vec<String>> {
317 crate::ClaudeConvo::list_conversations(self, project)
318 .map_err(|e| ConvoError::Provider(e.to_string()))
319 }
320
321 fn load_conversation(
322 &self,
323 project: &str,
324 conversation_id: &str,
325 ) -> toolpath_convo::Result<ConversationView> {
326 let convo = self
327 .read_conversation(project, conversation_id)
328 .map_err(|e| ConvoError::Provider(e.to_string()))?;
329 let mut view = conversation_to_view(&convo);
330 view.session_ids = convo.session_ids.clone();
331 Ok(view)
332 }
333
334 fn load_metadata(
335 &self,
336 project: &str,
337 conversation_id: &str,
338 ) -> toolpath_convo::Result<ConversationMeta> {
339 let meta = self
340 .read_conversation_metadata(project, conversation_id)
341 .map_err(|e| ConvoError::Provider(e.to_string()))?;
342
343 Ok(ConversationMeta {
344 id: meta.session_id,
345 started_at: meta.started_at,
346 last_activity: meta.last_activity,
347 message_count: meta.message_count,
348 file_path: Some(meta.file_path),
349 predecessor: None,
350 successor: None,
351 })
352 }
353
354 fn list_metadata(&self, project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
355 let metas = self
356 .list_conversation_metadata(project)
357 .map_err(|e| ConvoError::Provider(e.to_string()))?;
358
359 Ok(metas
360 .into_iter()
361 .map(|m| ConversationMeta {
362 id: m.session_id,
363 started_at: m.started_at,
364 last_activity: m.last_activity,
365 message_count: m.message_count,
366 file_path: Some(m.file_path),
367 predecessor: None,
368 successor: None,
369 })
370 .collect())
371 }
372}
373
374#[cfg(feature = "watcher")]
377impl toolpath_convo::ConversationWatcher for crate::watcher::ConversationWatcher {
378 fn poll(&mut self) -> toolpath_convo::Result<Vec<WatcherEvent>> {
379 let entries = crate::watcher::ConversationWatcher::poll(self)
380 .map_err(|e| ConvoError::Provider(e.to_string()))?;
381
382 let mut events: Vec<WatcherEvent> = Vec::new();
383
384 for (from, to) in self.take_pending_rotations() {
386 events.push(WatcherEvent::Progress {
387 kind: "session_rotated".into(),
388 data: serde_json::json!({
389 "from": from,
390 "to": to,
391 }),
392 });
393 }
394
395 for entry in &entries {
396 let Some(msg) = &entry.message else {
397 events.push(entry_to_watcher_event(entry));
398 continue;
399 };
400
401 if is_tool_result_only(entry) {
402 let mut updated_turn: Option<Turn> = None;
406
407 for event in events.iter_mut().rev() {
409 if let WatcherEvent::Turn(turn) | WatcherEvent::TurnUpdated(turn) = event
410 && turn.tool_uses.iter().any(|tu| {
411 tu.result.is_none()
412 && msg.tool_results().iter().any(|tr| tr.tool_use_id == tu.id)
413 })
414 {
415 let mut updated = (**turn).clone();
417 merge_tool_results(std::slice::from_mut(&mut updated), msg);
418 updated_turn = Some(updated.clone());
419 **turn = updated;
422 break;
423 }
424 }
425
426 if let Some(turn) = updated_turn {
427 events.push(WatcherEvent::TurnUpdated(Box::new(turn)));
428 }
429 continue;
433 }
434
435 events.push(entry_to_watcher_event(entry));
436 }
437
438 Ok(events)
439 }
440
441 fn seen_count(&self) -> usize {
442 crate::watcher::ConversationWatcher::seen_count(self)
443 }
444}
445
446pub fn to_view(convo: &Conversation) -> ConversationView {
454 conversation_to_view(convo)
455}
456
457pub fn to_turn(entry: &ConversationEntry) -> Option<Turn> {
463 entry_to_turn(entry)
464}
465
466#[cfg(test)]
469mod tests {
470 use super::*;
471 use crate::PathResolver;
472 use std::fs;
473 use tempfile::TempDir;
474
475 fn setup_provider() -> (TempDir, ClaudeConvo) {
476 let temp = TempDir::new().unwrap();
477 let claude_dir = temp.path().join(".claude");
478 let project_dir = claude_dir.join("projects/-test-project");
479 fs::create_dir_all(&project_dir).unwrap();
480
481 let entries = [
482 r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Fix the bug"}}"#,
483 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}}}"#,
484 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}]}}"#,
485 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}}}"#,
486 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}]}}"#,
487 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"}}"#,
488 r#"{"uuid":"uuid-7","type":"user","parentUuid":"uuid-6","timestamp":"2024-01-01T00:00:06Z","message":{"role":"user","content":"Thanks!"}}"#,
489 ];
490 fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap();
491
492 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
493 (temp, ClaudeConvo::with_resolver(resolver))
494 }
495
496 #[test]
497 fn test_load_conversation_assembles_tool_results() {
498 let (_temp, provider) = setup_provider();
499 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
500 .unwrap();
501
502 assert_eq!(view.id, "session-1");
503 assert_eq!(view.turns.len(), 5);
505
506 assert_eq!(view.turns[0].role, Role::User);
508 assert_eq!(view.turns[0].text, "Fix the bug");
509 assert!(view.turns[0].parent_id.is_none());
510
511 assert_eq!(view.turns[1].role, Role::Assistant);
513 assert_eq!(view.turns[1].text, "I'll fix that.");
514 assert_eq!(
515 view.turns[1].thinking.as_deref(),
516 Some("The bug is in auth")
517 );
518 assert_eq!(view.turns[1].tool_uses.len(), 1);
519 assert_eq!(view.turns[1].tool_uses[0].name, "Read");
520 assert_eq!(view.turns[1].tool_uses[0].id, "t1");
521 let result = view.turns[1].tool_uses[0].result.as_ref().unwrap();
523 assert!(!result.is_error);
524 assert!(result.content.contains("fn main()"));
525 assert_eq!(view.turns[1].model.as_deref(), Some("claude-opus-4-6"));
526 assert_eq!(view.turns[1].stop_reason.as_deref(), Some("tool_use"));
527 assert_eq!(view.turns[1].parent_id.as_deref(), Some("uuid-1"));
528
529 let usage = view.turns[1].token_usage.as_ref().unwrap();
531 assert_eq!(usage.input_tokens, Some(100));
532 assert_eq!(usage.output_tokens, Some(50));
533
534 assert_eq!(view.turns[2].role, Role::Assistant);
536 assert_eq!(view.turns[2].text, "I see the issue. Let me fix it.");
537 assert_eq!(view.turns[2].tool_uses[0].name, "Edit");
538 let result2 = view.turns[2].tool_uses[0].result.as_ref().unwrap();
539 assert_eq!(result2.content, "File written successfully");
540
541 assert_eq!(view.turns[3].role, Role::Assistant);
543 assert_eq!(view.turns[3].text, "Done! The bug is fixed.");
544 assert!(view.turns[3].tool_uses.is_empty());
545
546 assert_eq!(view.turns[4].role, Role::User);
548 assert_eq!(view.turns[4].text, "Thanks!");
549 }
550
551 #[test]
552 fn test_no_phantom_empty_turns() {
553 let (_temp, provider) = setup_provider();
554 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
555 .unwrap();
556
557 for turn in &view.turns {
559 if turn.role == Role::User {
560 assert!(
561 !turn.text.is_empty(),
562 "Found phantom empty user turn: {:?}",
563 turn.id
564 );
565 }
566 }
567 }
568
569 #[test]
570 fn test_tool_result_error_flag() {
571 let temp = TempDir::new().unwrap();
572 let claude_dir = temp.path().join(".claude");
573 let project_dir = claude_dir.join("projects/-test-project");
574 fs::create_dir_all(&project_dir).unwrap();
575
576 let entries = [
577 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read a file"}}"#,
578 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"}}"#,
579 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}]}}"#,
580 ];
581 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
582
583 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
584 let provider = ClaudeConvo::with_resolver(resolver);
585 let view =
586 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
587
588 assert_eq!(view.turns.len(), 2); let result = view.turns[1].tool_uses[0].result.as_ref().unwrap();
590 assert!(result.is_error);
591 assert_eq!(result.content, "File not found");
592 }
593
594 #[test]
595 fn test_multiple_tool_uses_single_result_entry() {
596 let temp = TempDir::new().unwrap();
597 let claude_dir = temp.path().join(".claude");
598 let project_dir = claude_dir.join("projects/-test-project");
599 fs::create_dir_all(&project_dir).unwrap();
600
601 let entries = [
602 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Check two files"}}"#,
603 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"}}]}}"#,
604 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}]}}"#,
605 ];
606 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
607
608 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
609 let provider = ClaudeConvo::with_resolver(resolver);
610 let view =
611 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
612
613 assert_eq!(view.turns.len(), 2);
614 assert_eq!(view.turns[1].tool_uses.len(), 2);
615
616 let r1 = view.turns[1].tool_uses[0].result.as_ref().unwrap();
617 assert_eq!(r1.content, "file a contents");
618
619 let r2 = view.turns[1].tool_uses[1].result.as_ref().unwrap();
620 assert_eq!(r2.content, "file b contents");
621 }
622
623 #[test]
624 fn test_conversation_without_tool_use_unchanged() {
625 let temp = TempDir::new().unwrap();
626 let claude_dir = temp.path().join(".claude");
627 let project_dir = claude_dir.join("projects/-test-project");
628 fs::create_dir_all(&project_dir).unwrap();
629
630 let entries = [
631 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
632 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi there!"}}"#,
633 ];
634 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
635
636 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
637 let provider = ClaudeConvo::with_resolver(resolver);
638 let view =
639 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
640
641 assert_eq!(view.turns.len(), 2);
642 assert_eq!(view.turns[0].text, "Hello");
643 assert_eq!(view.turns[1].text, "Hi there!");
644 }
645
646 #[test]
647 fn test_assistant_turn_without_result_has_none() {
648 let temp = TempDir::new().unwrap();
650 let claude_dir = temp.path().join(".claude");
651 let project_dir = claude_dir.join("projects/-test-project");
652 fs::create_dir_all(&project_dir).unwrap();
653
654 let entries = [
655 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read a file"}}"#,
656 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"}}]}}"#,
657 ];
658 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
659
660 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
661 let provider = ClaudeConvo::with_resolver(resolver);
662 let view =
663 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
664
665 assert_eq!(view.turns.len(), 2);
666 assert!(view.turns[1].tool_uses[0].result.is_none());
667 }
668
669 #[test]
670 fn test_list_conversations() {
671 let (_temp, provider) = setup_provider();
672 let ids = ConversationProvider::list_conversations(&provider, "/test/project").unwrap();
673 assert_eq!(ids, vec!["session-1"]);
674 }
675
676 #[test]
677 fn test_load_metadata() {
678 let (_temp, provider) = setup_provider();
679 let meta =
680 ConversationProvider::load_metadata(&provider, "/test/project", "session-1").unwrap();
681 assert_eq!(meta.id, "session-1");
682 assert_eq!(meta.message_count, 7);
683 assert!(meta.file_path.is_some());
684 }
685
686 #[test]
687 fn test_list_metadata() {
688 let (_temp, provider) = setup_provider();
689 let metas = ConversationProvider::list_metadata(&provider, "/test/project").unwrap();
690 assert_eq!(metas.len(), 1);
691 assert_eq!(metas[0].id, "session-1");
692 }
693
694 #[test]
695 fn test_to_view() {
696 let (_temp, manager) = setup_provider();
697 let convo = manager
698 .read_conversation("/test/project", "session-1")
699 .unwrap();
700 let view = to_view(&convo);
701 assert_eq!(view.turns.len(), 5);
702 assert_eq!(view.title(20).unwrap(), "Fix the bug");
703 }
704
705 #[test]
706 fn test_to_turn_with_message() {
707 let entry: ConversationEntry = serde_json::from_str(
708 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
709 )
710 .unwrap();
711 let turn = to_turn(&entry).unwrap();
712 assert_eq!(turn.id, "u1");
713 assert_eq!(turn.text, "hello");
714 assert_eq!(turn.role, Role::User);
715 }
716
717 #[test]
718 fn test_to_turn_without_message() {
719 let entry: ConversationEntry = serde_json::from_str(
720 r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
721 )
722 .unwrap();
723 assert!(to_turn(&entry).is_none());
724 }
725
726 #[test]
727 fn test_entry_to_watcher_event_turn() {
728 let entry: ConversationEntry = serde_json::from_str(
729 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hi"}}"#,
730 )
731 .unwrap();
732 let event = entry_to_watcher_event(&entry);
733 assert!(matches!(event, WatcherEvent::Turn(_)));
734 }
735
736 #[test]
737 fn test_entry_to_watcher_event_progress() {
738 let entry: ConversationEntry = serde_json::from_str(
739 r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
740 )
741 .unwrap();
742 let event = entry_to_watcher_event(&entry);
743 assert!(matches!(event, WatcherEvent::Progress { .. }));
744 }
745
746 #[cfg(feature = "watcher")]
747 #[test]
748 fn test_watcher_trait_basic() {
749 let temp = TempDir::new().unwrap();
750 let claude_dir = temp.path().join(".claude");
751 let project_dir = claude_dir.join("projects/-test-project");
752 fs::create_dir_all(&project_dir).unwrap();
753
754 let entries = [
755 r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
756 r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
757 ];
758 fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap();
759
760 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
761 let manager = ClaudeConvo::with_resolver(resolver);
762
763 let mut watcher = crate::watcher::ConversationWatcher::new(
764 manager,
765 "/test/project".to_string(),
766 "session-1".to_string(),
767 );
768
769 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
771 assert_eq!(events.len(), 2);
772 assert!(matches!(&events[0], WatcherEvent::Turn(t) if t.role == Role::User));
773 assert!(matches!(&events[1], WatcherEvent::Turn(t) if t.role == Role::Assistant));
774 assert_eq!(toolpath_convo::ConversationWatcher::seen_count(&watcher), 2);
775
776 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
778 assert!(events.is_empty());
779 }
780
781 #[cfg(feature = "watcher")]
782 #[test]
783 fn test_watcher_trait_assembles_tool_results() {
784 let temp = TempDir::new().unwrap();
785 let claude_dir = temp.path().join(".claude");
786 let project_dir = claude_dir.join("projects/-test-project");
787 fs::create_dir_all(&project_dir).unwrap();
788
789 let entries = [
790 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read the file"}}"#,
791 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"}}]}}"#,
792 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}]}}"#,
793 r#"{"uuid":"u4","type":"assistant","timestamp":"2024-01-01T00:00:03Z","message":{"role":"assistant","content":"Done!"}}"#,
794 ];
795 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
796
797 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
798 let manager = ClaudeConvo::with_resolver(resolver);
799
800 let mut watcher = crate::watcher::ConversationWatcher::new(
801 manager,
802 "/test/project".to_string(),
803 "s1".to_string(),
804 );
805
806 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
807
808 assert_eq!(events.len(), 4);
810
811 assert!(matches!(&events[0], WatcherEvent::Turn(t) if t.role == Role::User));
813
814 assert!(matches!(&events[1], WatcherEvent::Turn(t) if t.role == Role::Assistant));
816
817 match &events[2] {
819 WatcherEvent::TurnUpdated(turn) => {
820 assert_eq!(turn.id, "u2");
821 assert_eq!(turn.tool_uses.len(), 1);
822 let result = turn.tool_uses[0].result.as_ref().unwrap();
823 assert_eq!(result.content, "fn main() {}");
824 assert!(!result.is_error);
825 }
826 other => panic!("Expected TurnUpdated, got {:?}", other),
827 }
828
829 assert!(matches!(&events[3], WatcherEvent::Turn(t) if t.text == "Done!"));
831 }
832
833 #[cfg(feature = "watcher")]
834 #[test]
835 fn test_watcher_trait_incremental_tool_results() {
836 let temp = TempDir::new().unwrap();
838 let claude_dir = temp.path().join(".claude");
839 let project_dir = claude_dir.join("projects/-test-project");
840 fs::create_dir_all(&project_dir).unwrap();
841
842 let entries_phase1 = [
844 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read file"}}"#,
845 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"}}]}}"#,
846 ];
847 fs::write(
848 project_dir.join("s1.jsonl"),
849 entries_phase1.join("\n") + "\n",
850 )
851 .unwrap();
852
853 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
854 let manager = ClaudeConvo::with_resolver(resolver);
855
856 let mut watcher = crate::watcher::ConversationWatcher::new(
857 manager,
858 "/test/project".to_string(),
859 "s1".to_string(),
860 );
861
862 let events1 = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
864 assert_eq!(events1.len(), 2);
865 if let WatcherEvent::Turn(t) = &events1[1] {
867 assert!(t.tool_uses[0].result.is_none());
868 } else {
869 panic!("Expected Turn");
870 }
871
872 use std::io::Write;
874 let mut file = fs::OpenOptions::new()
875 .append(true)
876 .open(project_dir.join("s1.jsonl"))
877 .unwrap();
878 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();
879
880 let events2 = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
882 assert!(events2.is_empty() || events2.iter().all(|e| !matches!(e, WatcherEvent::Turn(_))));
889 }
890
891 #[test]
892 fn test_merge_tool_results_by_id() {
893 let mut turns = vec![Turn {
895 id: "t1".into(),
896 parent_id: None,
897 role: Role::Assistant,
898 timestamp: "2024-01-01T00:00:00Z".into(),
899 text: "test".into(),
900 thinking: None,
901 tool_uses: vec![
902 ToolInvocation {
903 id: "tool-a".into(),
904 name: "Read".into(),
905 input: serde_json::json!({}),
906 result: None,
907 category: Some(ToolCategory::FileRead),
908 },
909 ToolInvocation {
910 id: "tool-b".into(),
911 name: "Write".into(),
912 input: serde_json::json!({}),
913 result: None,
914 category: Some(ToolCategory::FileWrite),
915 },
916 ],
917 model: None,
918 stop_reason: None,
919 token_usage: None,
920 environment: None,
921 delegations: vec![],
922 extra: Default::default(),
923 }];
924
925 let msg: Message = serde_json::from_str(
927 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}]}"#,
928 )
929 .unwrap();
930
931 let merged = merge_tool_results(&mut turns, &msg);
932 assert!(merged);
933
934 assert_eq!(
936 turns[0].tool_uses[0].result.as_ref().unwrap().content,
937 "read result"
938 );
939 assert!(turns[0].tool_uses[0].result.as_ref().unwrap().is_error);
940
941 assert_eq!(
942 turns[0].tool_uses[1].result.as_ref().unwrap().content,
943 "write result"
944 );
945 assert!(!turns[0].tool_uses[1].result.as_ref().unwrap().is_error);
946 }
947
948 #[test]
949 fn test_is_tool_result_only() {
950 let entry: ConversationEntry = serde_json::from_str(
952 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}]}}"#,
953 )
954 .unwrap();
955 assert!(is_tool_result_only(&entry));
956
957 let entry: ConversationEntry = serde_json::from_str(
959 r#"{"uuid":"u2","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
960 )
961 .unwrap();
962 assert!(!is_tool_result_only(&entry));
963
964 let entry: ConversationEntry = serde_json::from_str(
966 r#"{"uuid":"u3","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
967 )
968 .unwrap();
969 assert!(!is_tool_result_only(&entry));
970
971 let entry: ConversationEntry = serde_json::from_str(
973 r#"{"uuid":"u4","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"hi"}}"#,
974 )
975 .unwrap();
976 assert!(!is_tool_result_only(&entry));
977 }
978
979 #[test]
982 fn test_tool_category_mapping() {
983 assert_eq!(tool_category("Read"), Some(ToolCategory::FileRead));
984 assert_eq!(tool_category("Glob"), Some(ToolCategory::FileSearch));
985 assert_eq!(tool_category("Grep"), Some(ToolCategory::FileSearch));
986 assert_eq!(tool_category("Write"), Some(ToolCategory::FileWrite));
987 assert_eq!(tool_category("Edit"), Some(ToolCategory::FileWrite));
988 assert_eq!(tool_category("NotebookEdit"), Some(ToolCategory::FileWrite));
989 assert_eq!(tool_category("Bash"), Some(ToolCategory::Shell));
990 assert_eq!(tool_category("WebFetch"), Some(ToolCategory::Network));
991 assert_eq!(tool_category("WebSearch"), Some(ToolCategory::Network));
992 assert_eq!(tool_category("Task"), Some(ToolCategory::Delegation));
993 assert_eq!(tool_category("UnknownTool"), None);
994 }
995
996 #[test]
997 fn test_turn_has_tool_category() {
998 let (_temp, provider) = setup_provider();
999 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1000 .unwrap();
1001
1002 assert_eq!(
1004 view.turns[1].tool_uses[0].category,
1005 Some(ToolCategory::FileRead)
1006 );
1007 assert_eq!(
1009 view.turns[2].tool_uses[0].category,
1010 Some(ToolCategory::FileWrite)
1011 );
1012 }
1013
1014 #[test]
1015 fn test_environment_populated_from_entry() {
1016 let temp = TempDir::new().unwrap();
1017 let claude_dir = temp.path().join(".claude");
1018 let project_dir = claude_dir.join("projects/-test-project");
1019 fs::create_dir_all(&project_dir).unwrap();
1020
1021 let entries = [
1022 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","cwd":"/project/path","gitBranch":"feat/auth","message":{"role":"user","content":"Hello"}}"#,
1023 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
1024 ];
1025 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1026
1027 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1028 let provider = ClaudeConvo::with_resolver(resolver);
1029 let view =
1030 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1031
1032 let env = view.turns[0].environment.as_ref().unwrap();
1034 assert_eq!(env.working_dir.as_deref(), Some("/project/path"));
1035 assert_eq!(env.vcs_branch.as_deref(), Some("feat/auth"));
1036 assert!(env.vcs_revision.is_none());
1037
1038 assert!(view.turns[1].environment.is_none());
1040 }
1041
1042 #[test]
1043 fn test_cache_tokens_populated() {
1044 let temp = TempDir::new().unwrap();
1045 let claude_dir = temp.path().join(".claude");
1046 let project_dir = claude_dir.join("projects/-test-project");
1047 fs::create_dir_all(&project_dir).unwrap();
1048
1049 let entries = [
1050 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
1051 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}}}"#,
1052 ];
1053 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1054
1055 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1056 let provider = ClaudeConvo::with_resolver(resolver);
1057 let view =
1058 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1059
1060 let usage = view.turns[1].token_usage.as_ref().unwrap();
1061 assert_eq!(usage.cache_read_tokens, Some(500));
1062 assert_eq!(usage.cache_write_tokens, Some(200));
1063 }
1064
1065 #[test]
1066 fn test_total_usage_aggregated() {
1067 let (_temp, provider) = setup_provider();
1068 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1069 .unwrap();
1070
1071 let total = view.total_usage.as_ref().unwrap();
1072 assert_eq!(total.input_tokens, Some(300));
1074 assert_eq!(total.output_tokens, Some(150));
1075 }
1076
1077 #[test]
1078 fn test_provider_id_set() {
1079 let (_temp, provider) = setup_provider();
1080 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1081 .unwrap();
1082
1083 assert_eq!(view.provider_id.as_deref(), Some("claude-code"));
1084 }
1085
1086 #[test]
1087 fn test_files_changed_populated() {
1088 let temp = TempDir::new().unwrap();
1089 let claude_dir = temp.path().join(".claude");
1090 let project_dir = claude_dir.join("projects/-test-project");
1091 fs::create_dir_all(&project_dir).unwrap();
1092
1093 let entries = [
1094 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Edit files"}}"#,
1095 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"}}]}}"#,
1096 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}]}}"#,
1097 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"}}]}}"#,
1098 ];
1099 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1100
1101 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1102 let provider = ClaudeConvo::with_resolver(resolver);
1103 let view =
1104 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1105
1106 assert_eq!(view.files_changed, vec!["src/main.rs", "src/lib.rs"]);
1108 }
1109
1110 #[test]
1111 fn test_delegations_extracted() {
1112 let temp = TempDir::new().unwrap();
1113 let claude_dir = temp.path().join(".claude");
1114 let project_dir = claude_dir.join("projects/-test-project");
1115 fs::create_dir_all(&project_dir).unwrap();
1116
1117 let entries = [
1118 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Search for bugs"}}"#,
1119 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"}}]}}"#,
1120 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}]}}"#,
1121 ];
1122 fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1123
1124 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1125 let provider = ClaudeConvo::with_resolver(resolver);
1126 let view =
1127 ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1128
1129 assert_eq!(view.turns[1].delegations.len(), 1);
1131 let d = &view.turns[1].delegations[0];
1132 assert_eq!(d.agent_id, "task-1");
1133 assert_eq!(d.prompt, "Find the authentication bug");
1134 assert!(d.turns.is_empty()); assert_eq!(
1137 d.result.as_deref(),
1138 Some("Found the bug in auth.rs line 42")
1139 );
1140 }
1141
1142 #[test]
1145 fn test_turn_extra_populated_from_entry() {
1146 let entry: ConversationEntry = serde_json::from_str(
1147 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","subtype":"init","message":{"role":"user","content":"hello"}}"#,
1148 )
1149 .unwrap();
1150 let turn = to_turn(&entry).unwrap();
1151 let claude = turn.extra.get("claude").expect("extra[\"claude\"] missing");
1152 assert_eq!(claude["subtype"], "init");
1153 }
1154
1155 #[test]
1156 fn test_turn_extra_empty_when_no_extras() {
1157 let entry: ConversationEntry = serde_json::from_str(
1158 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
1159 )
1160 .unwrap();
1161 let turn = to_turn(&entry).unwrap();
1162 assert!(turn.extra.is_empty());
1163 }
1164
1165 #[test]
1166 fn test_progress_data_enriched_with_extras() {
1167 let entry: ConversationEntry = serde_json::from_str(
1168 r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z","data":{"type":"hook_progress","hookName":"pre-commit"}}"#,
1169 )
1170 .unwrap();
1171 let event = entry_to_watcher_event(&entry);
1172 match event {
1173 WatcherEvent::Progress { kind, data } => {
1174 assert_eq!(kind, "progress");
1175 assert_eq!(data["uuid"], "u1");
1176 assert_eq!(data["timestamp"], "2024-01-01T00:00:00Z");
1177 let claude = &data["claude"];
1178 assert_eq!(claude["data"]["type"], "hook_progress");
1179 assert_eq!(claude["data"]["hookName"], "pre-commit");
1180 }
1181 other => panic!(
1182 "Expected Progress, got {:?}",
1183 std::mem::discriminant(&other)
1184 ),
1185 }
1186 }
1187
1188 #[test]
1189 fn test_progress_data_no_claude_key_when_no_extras() {
1190 let entry: ConversationEntry = serde_json::from_str(
1191 r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
1192 )
1193 .unwrap();
1194 let event = entry_to_watcher_event(&entry);
1195 match event {
1196 WatcherEvent::Progress { data, .. } => {
1197 assert!(data.get("claude").is_none());
1198 }
1199 other => panic!(
1200 "Expected Progress, got {:?}",
1201 std::mem::discriminant(&other)
1202 ),
1203 }
1204 }
1205
1206 #[test]
1207 fn test_no_delegations_for_non_task_tools() {
1208 let (_temp, provider) = setup_provider();
1209 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1210 .unwrap();
1211
1212 for turn in &view.turns {
1214 assert!(turn.delegations.is_empty());
1215 }
1216 }
1217
1218 fn setup_chained_provider() -> (TempDir, ClaudeConvo) {
1221 let temp = TempDir::new().unwrap();
1222 let claude_dir = temp.path().join(".claude");
1223 let project_dir = claude_dir.join("projects/-test-project");
1224 fs::create_dir_all(&project_dir).unwrap();
1225
1226 let entries_a = [
1228 r#"{"uuid":"a1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Fix the bug"}}"#,
1229 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}}}"#,
1230 ];
1231 fs::write(project_dir.join("session-a.jsonl"), entries_a.join("\n")).unwrap();
1232
1233 let entries_b = [
1235 r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Continue the fix"}}"#,
1237 r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"What about the tests?"}}"#,
1239 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}}}"#,
1240 ];
1241 fs::write(project_dir.join("session-b.jsonl"), entries_b.join("\n")).unwrap();
1242
1243 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1244 (temp, ClaudeConvo::with_resolver(resolver))
1245 }
1246
1247 #[test]
1248 fn test_load_conversation_merges_chain() {
1249 let (_temp, provider) = setup_chained_provider();
1250
1251 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-a")
1253 .unwrap();
1254
1255 assert_eq!(view.turns.len(), 4);
1259 assert_eq!(view.turns[0].text, "Fix the bug");
1260 assert_eq!(view.turns[1].text, "I'll fix that.");
1261 assert_eq!(view.turns[2].text, "What about the tests?");
1262 assert_eq!(view.turns[3].text, "Tests pass now.");
1263
1264 assert_eq!(view.session_ids, vec!["session-a", "session-b"]);
1266 }
1267
1268 #[test]
1269 fn test_load_conversation_skips_bridge_entries() {
1270 let (_temp, provider) = setup_chained_provider();
1271
1272 let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-a")
1273 .unwrap();
1274
1275 for turn in &view.turns {
1277 assert_ne!(turn.text, "Continue the fix");
1278 }
1279 }
1280
1281 #[test]
1282 fn test_load_conversation_single_segment_unchanged() {
1283 let temp = TempDir::new().unwrap();
1284 let claude_dir = temp.path().join(".claude");
1285 let project_dir = claude_dir.join("projects/-test-project");
1286 fs::create_dir_all(&project_dir).unwrap();
1287
1288 let entries = [
1289 r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"solo","message":{"role":"user","content":"Hello"}}"#,
1290 r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","sessionId":"solo","message":{"role":"assistant","content":"Hi there!"}}"#,
1291 ];
1292 fs::write(project_dir.join("solo.jsonl"), entries.join("\n")).unwrap();
1293
1294 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1295 let provider = ClaudeConvo::with_resolver(resolver);
1296 let view =
1297 ConversationProvider::load_conversation(&provider, "/test/project", "solo").unwrap();
1298
1299 assert_eq!(view.turns.len(), 2);
1300 assert_eq!(view.turns[0].text, "Hello");
1301 assert_eq!(view.turns[1].text, "Hi there!");
1302 assert!(view.session_ids.is_empty());
1304 }
1305
1306 #[test]
1307 fn test_list_metadata_chain_transparent() {
1308 let (_temp, provider) = setup_chained_provider();
1309
1310 let metas = ConversationProvider::list_metadata(&provider, "/test/project").unwrap();
1311
1312 assert_eq!(metas.len(), 1);
1314 assert_eq!(metas[0].id, "session-a");
1315
1316 assert!(metas[0].predecessor.is_none());
1318 assert!(metas[0].successor.is_none());
1319 }
1320
1321 #[cfg(feature = "watcher")]
1322 #[test]
1323 fn test_watcher_emits_rotation_progress() {
1324 let temp = TempDir::new().unwrap();
1325 let claude_dir = temp.path().join(".claude");
1326 let project_dir = claude_dir.join("projects/-test-project");
1327 fs::create_dir_all(&project_dir).unwrap();
1328
1329 let entry_a = r#"{"uuid":"a1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Hello"}}"#;
1331 fs::write(
1332 project_dir.join("session-a.jsonl"),
1333 format!("{}\n", entry_a),
1334 )
1335 .unwrap();
1336
1337 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1338 let manager = ClaudeConvo::with_resolver(resolver);
1339
1340 let mut watcher = crate::watcher::ConversationWatcher::new(
1341 manager,
1342 "/test/project".to_string(),
1343 "session-a".to_string(),
1344 );
1345
1346 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1348 assert_eq!(events.len(), 1);
1349 assert!(matches!(&events[0], WatcherEvent::Turn(_)));
1350
1351 let entries_b = [
1353 r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Bridge"}}"#,
1354 r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"New"}}"#,
1355 ];
1356 fs::write(project_dir.join("session-b.jsonl"), entries_b.join("\n")).unwrap();
1357
1358 let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1360
1361 assert!(
1363 events.len() >= 2,
1364 "Expected Progress + Turn, got {} events",
1365 events.len()
1366 );
1367 match &events[0] {
1368 WatcherEvent::Progress { kind, data } => {
1369 assert_eq!(kind, "session_rotated");
1370 assert_eq!(data["from"], "session-a");
1371 assert_eq!(data["to"], "session-b");
1372 }
1373 other => panic!("Expected Progress, got {:?}", std::mem::discriminant(other)),
1374 }
1375
1376 match &events[1] {
1378 WatcherEvent::Turn(turn) => {
1379 assert_eq!(turn.id, "b1");
1380 assert_eq!(turn.text, "New");
1381 }
1382 other => panic!("Expected Turn(b1), got {:?}", std::mem::discriminant(other)),
1383 }
1384
1385 for event in &events {
1387 if let WatcherEvent::Turn(t) = event {
1388 assert_ne!(t.id, "b0", "Bridge entry should not appear as a Turn");
1389 }
1390 }
1391 }
1392
1393 #[test]
1394 fn test_load_metadata_chain_transparent() {
1395 let (_temp, provider) = setup_chained_provider();
1396
1397 let meta_a =
1399 ConversationProvider::load_metadata(&provider, "/test/project", "session-a").unwrap();
1400 assert_eq!(meta_a.id, "session-a");
1401 assert_eq!(meta_a.message_count, 5);
1403 assert!(meta_a.predecessor.is_none());
1405 assert!(meta_a.successor.is_none());
1406
1407 let meta_b =
1409 ConversationProvider::load_metadata(&provider, "/test/project", "session-b").unwrap();
1410 assert_eq!(meta_b.id, "session-a"); assert_eq!(meta_b.message_count, 5);
1412 assert!(meta_b.predecessor.is_none());
1413 assert!(meta_b.successor.is_none());
1414 }
1415}