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