Skip to main content

toolpath_claude/
provider.rs

1//! Implementation of `toolpath-convo` traits for Claude conversations.
2//!
3//! Handles cross-entry tool result assembly: Claude's JSONL format writes
4//! tool invocations and their results as separate entries. This module
5//! pairs them by `tool_use_id` so consumers get complete `Turn` values
6//! with `ToolInvocation.result` populated.
7
8use 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
19// ── Conversion helpers ───────────────────────────────────────────────
20
21fn 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
29/// Classify a Claude Code tool into toolpath's category ontology.
30///
31/// Returns `None` for unrecognized tools. When Claude Code adds or
32/// renames tools, update this map.
33fn tool_category(name: &str) -> Option<ToolCategory> {
34    match name {
35        "Read" => Some(ToolCategory::FileRead),
36        "Glob" | "Grep" => Some(ToolCategory::FileSearch),
37        "Write" | "Edit" | "NotebookEdit" => Some(ToolCategory::FileWrite),
38        "Bash" => Some(ToolCategory::Shell),
39        "WebFetch" | "WebSearch" => Some(ToolCategory::Network),
40        "Task" => Some(ToolCategory::Delegation),
41        _ => None,
42    }
43}
44
45/// Convert a single entry to a Turn without cross-entry assembly.
46/// Tool results within the same message are still matched.
47fn 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
115/// Extract delegation info from Task tool invocations.
116fn 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
152/// Returns true if this entry is a tool-result-only user message
153/// (no human-authored text, only tool_result parts).
154fn 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
161/// Merge tool results from a tool-result-only message into existing turns.
162///
163/// Matches by `tool_use_id` — scans backwards through turns to find the
164/// `ToolInvocation` with a matching `id` for each result. This handles
165/// cases where a single result entry carries results for tool uses from
166/// different assistant turns.
167///
168/// Returns true if any results were merged.
169fn 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
197/// Convert a full conversation to a view with cross-entry tool result assembly.
198///
199/// Tool-result-only user entries are absorbed into the preceding assistant
200/// turn's `ToolInvocation.result` fields rather than emitted as separate turns.
201fn 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        // Tool-result-only user entries get merged into existing turns
210        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    // Re-derive delegation results now that tool results are merged
219    for turn in &mut turns {
220        for delegation in &mut turn.delegations {
221            if delegation.result.is_none()
222                && let Some(tu) = turn
223                    .tool_uses
224                    .iter()
225                    .find(|tu| tu.id == delegation.agent_id)
226            {
227                delegation.result = tu.result.as_ref().map(|r| r.content.clone());
228            }
229        }
230    }
231
232    let total_usage = sum_usage(&turns);
233    let files_changed = extract_files_changed(&turns);
234
235    ConversationView {
236        id: convo.session_id.clone(),
237        started_at: convo.started_at,
238        last_activity: convo.last_activity,
239        turns,
240        total_usage,
241        provider_id: Some("claude-code".into()),
242        files_changed,
243        session_ids: vec![],
244    }
245}
246
247/// Sum token usage across all turns.
248fn sum_usage(turns: &[Turn]) -> Option<TokenUsage> {
249    let mut total = TokenUsage::default();
250    let mut any = false;
251    for turn in turns {
252        if let Some(u) = &turn.token_usage {
253            any = true;
254            total.input_tokens =
255                Some(total.input_tokens.unwrap_or(0) + u.input_tokens.unwrap_or(0));
256            total.output_tokens =
257                Some(total.output_tokens.unwrap_or(0) + u.output_tokens.unwrap_or(0));
258            total.cache_read_tokens = match (total.cache_read_tokens, u.cache_read_tokens) {
259                (Some(a), Some(b)) => Some(a + b),
260                (Some(a), None) => Some(a),
261                (None, Some(b)) => Some(b),
262                (None, None) => None,
263            };
264            total.cache_write_tokens = match (total.cache_write_tokens, u.cache_write_tokens) {
265                (Some(a), Some(b)) => Some(a + b),
266                (Some(a), None) => Some(a),
267                (None, Some(b)) => Some(b),
268                (None, None) => None,
269            };
270        }
271    }
272    if any { Some(total) } else { None }
273}
274
275/// Extract deduplicated file paths from file-write tool invocations.
276fn extract_files_changed(turns: &[Turn]) -> Vec<String> {
277    let mut seen = std::collections::HashSet::new();
278    let mut files = Vec::new();
279    for turn in turns {
280        for tool_use in &turn.tool_uses {
281            if tool_use.category == Some(ToolCategory::FileWrite)
282                && let Some(path) = tool_use.input.get("file_path").and_then(|v| v.as_str())
283                && seen.insert(path.to_string())
284            {
285                files.push(path.to_string());
286            }
287        }
288    }
289    files
290}
291
292#[cfg(any(feature = "watcher", test))]
293fn entry_to_watcher_event(entry: &ConversationEntry) -> WatcherEvent {
294    match entry_to_turn(entry) {
295        Some(turn) => WatcherEvent::Turn(Box::new(turn)),
296        None => {
297            let mut data = serde_json::json!({
298                "uuid": entry.uuid,
299                "timestamp": entry.timestamp,
300            });
301            if !entry.extra.is_empty() {
302                data["claude"] = serde_json::to_value(&entry.extra).unwrap_or_default();
303            }
304            WatcherEvent::Progress {
305                kind: entry.entry_type.clone(),
306                data,
307            }
308        }
309    }
310}
311
312// ── ConversationProvider for ClaudeConvo ──────────────────────────────
313
314impl ConversationProvider for ClaudeConvo {
315    fn list_conversations(&self, project: &str) -> toolpath_convo::Result<Vec<String>> {
316        crate::ClaudeConvo::list_conversations(self, project)
317            .map_err(|e| ConvoError::Provider(e.to_string()))
318    }
319
320    fn load_conversation(
321        &self,
322        project: &str,
323        conversation_id: &str,
324    ) -> toolpath_convo::Result<ConversationView> {
325        let convo = self
326            .read_conversation(project, conversation_id)
327            .map_err(|e| ConvoError::Provider(e.to_string()))?;
328        let mut view = conversation_to_view(&convo);
329        view.session_ids = convo.session_ids.clone();
330        Ok(view)
331    }
332
333    fn load_metadata(
334        &self,
335        project: &str,
336        conversation_id: &str,
337    ) -> toolpath_convo::Result<ConversationMeta> {
338        let meta = self
339            .read_conversation_metadata(project, conversation_id)
340            .map_err(|e| ConvoError::Provider(e.to_string()))?;
341
342        Ok(ConversationMeta {
343            id: meta.session_id,
344            started_at: meta.started_at,
345            last_activity: meta.last_activity,
346            message_count: meta.message_count,
347            file_path: Some(meta.file_path),
348            predecessor: None,
349            successor: None,
350        })
351    }
352
353    fn list_metadata(&self, project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
354        let metas = self
355            .list_conversation_metadata(project)
356            .map_err(|e| ConvoError::Provider(e.to_string()))?;
357
358        Ok(metas
359            .into_iter()
360            .map(|m| ConversationMeta {
361                id: m.session_id,
362                started_at: m.started_at,
363                last_activity: m.last_activity,
364                message_count: m.message_count,
365                file_path: Some(m.file_path),
366                predecessor: None,
367                successor: None,
368            })
369            .collect())
370    }
371}
372
373// ── ConversationWatcher with eager emit + TurnUpdated ────────────────
374
375#[cfg(feature = "watcher")]
376impl toolpath_convo::ConversationWatcher for crate::watcher::ConversationWatcher {
377    fn poll(&mut self) -> toolpath_convo::Result<Vec<WatcherEvent>> {
378        let entries = crate::watcher::ConversationWatcher::poll(self)
379            .map_err(|e| ConvoError::Provider(e.to_string()))?;
380
381        let mut events: Vec<WatcherEvent> = Vec::new();
382
383        // Check for session rotations and prepend Progress events
384        for (from, to) in self.take_pending_rotations() {
385            events.push(WatcherEvent::Progress {
386                kind: "session_rotated".into(),
387                data: serde_json::json!({
388                    "from": from,
389                    "to": to,
390                }),
391            });
392        }
393
394        for entry in &entries {
395            let Some(msg) = &entry.message else {
396                events.push(entry_to_watcher_event(entry));
397                continue;
398            };
399
400            if is_tool_result_only(entry) {
401                // Find matching turns in previously emitted events and in
402                // our assembled state, merge results, emit TurnUpdated.
403                // Walk events in reverse to find the turn to update.
404                let mut updated_turn: Option<Turn> = None;
405
406                // Search backwards through events emitted this poll cycle
407                for event in events.iter_mut().rev() {
408                    if let WatcherEvent::Turn(turn) | WatcherEvent::TurnUpdated(turn) = event
409                        && turn.tool_uses.iter().any(|tu| {
410                            tu.result.is_none()
411                                && msg.tool_results().iter().any(|tr| tr.tool_use_id == tu.id)
412                        })
413                    {
414                        // Merge results into this turn
415                        let mut updated = (**turn).clone();
416                        merge_tool_results(std::slice::from_mut(&mut updated), msg);
417                        updated_turn = Some(updated.clone());
418                        // Also update the existing event in-place so later
419                        // result entries can find the right state
420                        **turn = updated;
421                        break;
422                    }
423                }
424
425                if let Some(turn) = updated_turn {
426                    events.push(WatcherEvent::TurnUpdated(Box::new(turn)));
427                }
428                // If no matching turn found, the tool-result-only entry
429                // is silently dropped (the matching turn was emitted in a
430                // prior poll cycle and can't be updated from here).
431                continue;
432            }
433
434            events.push(entry_to_watcher_event(entry));
435        }
436
437        Ok(events)
438    }
439
440    fn seen_count(&self) -> usize {
441        crate::watcher::ConversationWatcher::seen_count(self)
442    }
443}
444
445// ── Public re-exports for convenience ────────────────────────────────
446
447/// Convert a Claude [`Conversation`] directly into a [`ConversationView`].
448///
449/// This performs cross-entry tool result assembly: tool-result-only user
450/// entries are merged into the preceding assistant turn rather than emitted
451/// as separate turns.
452pub fn to_view(convo: &Conversation) -> ConversationView {
453    conversation_to_view(convo)
454}
455
456/// Convert a single Claude [`ConversationEntry`] into a [`Turn`], if it
457/// contains a message.
458///
459/// Note: this does *not* perform cross-entry assembly. For assembled
460/// results, use [`to_view`] instead.
461pub fn to_turn(entry: &ConversationEntry) -> Option<Turn> {
462    entry_to_turn(entry)
463}
464
465// ── Tests ────────────────────────────────────────────────────────────
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use crate::PathResolver;
471    use std::fs;
472    use tempfile::TempDir;
473
474    fn setup_provider() -> (TempDir, ClaudeConvo) {
475        let temp = TempDir::new().unwrap();
476        let claude_dir = temp.path().join(".claude");
477        let project_dir = claude_dir.join("projects/-test-project");
478        fs::create_dir_all(&project_dir).unwrap();
479
480        let entries = [
481            r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Fix the bug"}}"#,
482            r#"{"uuid":"uuid-2","type":"assistant","parentUuid":"uuid-1","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"I'll fix that."},{"type":"thinking","thinking":"The bug is in auth"},{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"src/main.rs"}}],"model":"claude-opus-4-6","stop_reason":"tool_use","usage":{"input_tokens":100,"output_tokens":50}}}"#,
483            r#"{"uuid":"uuid-3","type":"user","parentUuid":"uuid-2","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"fn main() { println!(\"hello\"); }","is_error":false}]}}"#,
484            r#"{"uuid":"uuid-4","type":"assistant","parentUuid":"uuid-3","timestamp":"2024-01-01T00:00:03Z","message":{"role":"assistant","content":[{"type":"text","text":"I see the issue. Let me fix it."},{"type":"tool_use","id":"t2","name":"Edit","input":{"file_path":"src/main.rs","old_string":"hello","new_string":"fixed"}}],"model":"claude-opus-4-6","stop_reason":"tool_use","usage":{"input_tokens":200,"output_tokens":100}}}"#,
485            r#"{"uuid":"uuid-5","type":"user","parentUuid":"uuid-4","timestamp":"2024-01-01T00:00:04Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t2","content":"File written successfully","is_error":false}]}}"#,
486            r#"{"uuid":"uuid-6","type":"assistant","parentUuid":"uuid-5","timestamp":"2024-01-01T00:00:05Z","message":{"role":"assistant","content":"Done! The bug is fixed.","model":"claude-opus-4-6","stop_reason":"end_turn"}}"#,
487            r#"{"uuid":"uuid-7","type":"user","parentUuid":"uuid-6","timestamp":"2024-01-01T00:00:06Z","message":{"role":"user","content":"Thanks!"}}"#,
488        ];
489        fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap();
490
491        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
492        (temp, ClaudeConvo::with_resolver(resolver))
493    }
494
495    #[test]
496    fn test_load_conversation_assembles_tool_results() {
497        let (_temp, provider) = setup_provider();
498        let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
499            .unwrap();
500
501        assert_eq!(view.id, "session-1");
502        // 7 entries collapse to 5 turns (2 tool-result-only entries absorbed)
503        assert_eq!(view.turns.len(), 5);
504
505        // Turn 0: user "Fix the bug"
506        assert_eq!(view.turns[0].role, Role::User);
507        assert_eq!(view.turns[0].text, "Fix the bug");
508        assert!(view.turns[0].parent_id.is_none());
509
510        // Turn 1: assistant with tool use + assembled result
511        assert_eq!(view.turns[1].role, Role::Assistant);
512        assert_eq!(view.turns[1].text, "I'll fix that.");
513        assert_eq!(
514            view.turns[1].thinking.as_deref(),
515            Some("The bug is in auth")
516        );
517        assert_eq!(view.turns[1].tool_uses.len(), 1);
518        assert_eq!(view.turns[1].tool_uses[0].name, "Read");
519        assert_eq!(view.turns[1].tool_uses[0].id, "t1");
520        // Key assertion: result is populated from the next entry
521        let result = view.turns[1].tool_uses[0].result.as_ref().unwrap();
522        assert!(!result.is_error);
523        assert!(result.content.contains("fn main()"));
524        assert_eq!(view.turns[1].model.as_deref(), Some("claude-opus-4-6"));
525        assert_eq!(view.turns[1].stop_reason.as_deref(), Some("tool_use"));
526        assert_eq!(view.turns[1].parent_id.as_deref(), Some("uuid-1"));
527
528        // Token usage
529        let usage = view.turns[1].token_usage.as_ref().unwrap();
530        assert_eq!(usage.input_tokens, Some(100));
531        assert_eq!(usage.output_tokens, Some(50));
532
533        // Turn 2: second assistant with tool use + assembled result
534        assert_eq!(view.turns[2].role, Role::Assistant);
535        assert_eq!(view.turns[2].text, "I see the issue. Let me fix it.");
536        assert_eq!(view.turns[2].tool_uses[0].name, "Edit");
537        let result2 = view.turns[2].tool_uses[0].result.as_ref().unwrap();
538        assert_eq!(result2.content, "File written successfully");
539
540        // Turn 3: final assistant (no tools)
541        assert_eq!(view.turns[3].role, Role::Assistant);
542        assert_eq!(view.turns[3].text, "Done! The bug is fixed.");
543        assert!(view.turns[3].tool_uses.is_empty());
544
545        // Turn 4: user "Thanks!"
546        assert_eq!(view.turns[4].role, Role::User);
547        assert_eq!(view.turns[4].text, "Thanks!");
548    }
549
550    #[test]
551    fn test_no_phantom_empty_turns() {
552        let (_temp, provider) = setup_provider();
553        let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
554            .unwrap();
555
556        // No turns should have empty text with User role (phantom turns)
557        for turn in &view.turns {
558            if turn.role == Role::User {
559                assert!(
560                    !turn.text.is_empty(),
561                    "Found phantom empty user turn: {:?}",
562                    turn.id
563                );
564            }
565        }
566    }
567
568    #[test]
569    fn test_tool_result_error_flag() {
570        let temp = TempDir::new().unwrap();
571        let claude_dir = temp.path().join(".claude");
572        let project_dir = claude_dir.join("projects/-test-project");
573        fs::create_dir_all(&project_dir).unwrap();
574
575        let entries = [
576            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read a file"}}"#,
577            r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"Reading..."},{"type":"tool_use","id":"t1","name":"Read","input":{"path":"/nonexistent"}}],"stop_reason":"tool_use"}}"#,
578            r#"{"uuid":"u3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"File not found","is_error":true}]}}"#,
579        ];
580        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
581
582        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
583        let provider = ClaudeConvo::with_resolver(resolver);
584        let view =
585            ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
586
587        assert_eq!(view.turns.len(), 2); // user + assistant (tool-result absorbed)
588        let result = view.turns[1].tool_uses[0].result.as_ref().unwrap();
589        assert!(result.is_error);
590        assert_eq!(result.content, "File not found");
591    }
592
593    #[test]
594    fn test_multiple_tool_uses_single_result_entry() {
595        let temp = TempDir::new().unwrap();
596        let claude_dir = temp.path().join(".claude");
597        let project_dir = claude_dir.join("projects/-test-project");
598        fs::create_dir_all(&project_dir).unwrap();
599
600        let entries = [
601            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Check two files"}}"#,
602            r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"Reading both..."},{"type":"tool_use","id":"t1","name":"Read","input":{"path":"a.rs"}},{"type":"tool_use","id":"t2","name":"Read","input":{"path":"b.rs"}}]}}"#,
603            r#"{"uuid":"u3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"file a contents","is_error":false},{"type":"tool_result","tool_use_id":"t2","content":"file b contents","is_error":false}]}}"#,
604        ];
605        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
606
607        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
608        let provider = ClaudeConvo::with_resolver(resolver);
609        let view =
610            ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
611
612        assert_eq!(view.turns.len(), 2);
613        assert_eq!(view.turns[1].tool_uses.len(), 2);
614
615        let r1 = view.turns[1].tool_uses[0].result.as_ref().unwrap();
616        assert_eq!(r1.content, "file a contents");
617
618        let r2 = view.turns[1].tool_uses[1].result.as_ref().unwrap();
619        assert_eq!(r2.content, "file b contents");
620    }
621
622    #[test]
623    fn test_conversation_without_tool_use_unchanged() {
624        let temp = TempDir::new().unwrap();
625        let claude_dir = temp.path().join(".claude");
626        let project_dir = claude_dir.join("projects/-test-project");
627        fs::create_dir_all(&project_dir).unwrap();
628
629        let entries = [
630            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
631            r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi there!"}}"#,
632        ];
633        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
634
635        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
636        let provider = ClaudeConvo::with_resolver(resolver);
637        let view =
638            ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
639
640        assert_eq!(view.turns.len(), 2);
641        assert_eq!(view.turns[0].text, "Hello");
642        assert_eq!(view.turns[1].text, "Hi there!");
643    }
644
645    #[test]
646    fn test_assistant_turn_without_result_has_none() {
647        // Tool use at end of conversation with no result entry
648        let temp = TempDir::new().unwrap();
649        let claude_dir = temp.path().join(".claude");
650        let project_dir = claude_dir.join("projects/-test-project");
651        fs::create_dir_all(&project_dir).unwrap();
652
653        let entries = [
654            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read a file"}}"#,
655            r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"Reading..."},{"type":"tool_use","id":"t1","name":"Read","input":{"path":"test.rs"}}]}}"#,
656        ];
657        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
658
659        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
660        let provider = ClaudeConvo::with_resolver(resolver);
661        let view =
662            ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
663
664        assert_eq!(view.turns.len(), 2);
665        assert!(view.turns[1].tool_uses[0].result.is_none());
666    }
667
668    #[test]
669    fn test_list_conversations() {
670        let (_temp, provider) = setup_provider();
671        let ids = ConversationProvider::list_conversations(&provider, "/test/project").unwrap();
672        assert_eq!(ids, vec!["session-1"]);
673    }
674
675    #[test]
676    fn test_load_metadata() {
677        let (_temp, provider) = setup_provider();
678        let meta =
679            ConversationProvider::load_metadata(&provider, "/test/project", "session-1").unwrap();
680        assert_eq!(meta.id, "session-1");
681        assert_eq!(meta.message_count, 7);
682        assert!(meta.file_path.is_some());
683    }
684
685    #[test]
686    fn test_list_metadata() {
687        let (_temp, provider) = setup_provider();
688        let metas = ConversationProvider::list_metadata(&provider, "/test/project").unwrap();
689        assert_eq!(metas.len(), 1);
690        assert_eq!(metas[0].id, "session-1");
691    }
692
693    #[test]
694    fn test_to_view() {
695        let (_temp, manager) = setup_provider();
696        let convo = manager
697            .read_conversation("/test/project", "session-1")
698            .unwrap();
699        let view = to_view(&convo);
700        assert_eq!(view.turns.len(), 5);
701        assert_eq!(view.title(20).unwrap(), "Fix the bug");
702    }
703
704    #[test]
705    fn test_to_turn_with_message() {
706        let entry: ConversationEntry = serde_json::from_str(
707            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
708        )
709        .unwrap();
710        let turn = to_turn(&entry).unwrap();
711        assert_eq!(turn.id, "u1");
712        assert_eq!(turn.text, "hello");
713        assert_eq!(turn.role, Role::User);
714    }
715
716    #[test]
717    fn test_to_turn_without_message() {
718        let entry: ConversationEntry = serde_json::from_str(
719            r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
720        )
721        .unwrap();
722        assert!(to_turn(&entry).is_none());
723    }
724
725    #[test]
726    fn test_entry_to_watcher_event_turn() {
727        let entry: ConversationEntry = serde_json::from_str(
728            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hi"}}"#,
729        )
730        .unwrap();
731        let event = entry_to_watcher_event(&entry);
732        assert!(matches!(event, WatcherEvent::Turn(_)));
733    }
734
735    #[test]
736    fn test_entry_to_watcher_event_progress() {
737        let entry: ConversationEntry = serde_json::from_str(
738            r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
739        )
740        .unwrap();
741        let event = entry_to_watcher_event(&entry);
742        assert!(matches!(event, WatcherEvent::Progress { .. }));
743    }
744
745    #[cfg(feature = "watcher")]
746    #[test]
747    fn test_watcher_trait_basic() {
748        let temp = TempDir::new().unwrap();
749        let claude_dir = temp.path().join(".claude");
750        let project_dir = claude_dir.join("projects/-test-project");
751        fs::create_dir_all(&project_dir).unwrap();
752
753        let entries = [
754            r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
755            r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
756        ];
757        fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap();
758
759        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
760        let manager = ClaudeConvo::with_resolver(resolver);
761
762        let mut watcher = crate::watcher::ConversationWatcher::new(
763            manager,
764            "/test/project".to_string(),
765            "session-1".to_string(),
766        );
767
768        // Use the trait explicitly (inherent poll returns ConversationEntry)
769        let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
770        assert_eq!(events.len(), 2);
771        assert!(matches!(&events[0], WatcherEvent::Turn(t) if t.role == Role::User));
772        assert!(matches!(&events[1], WatcherEvent::Turn(t) if t.role == Role::Assistant));
773        assert_eq!(toolpath_convo::ConversationWatcher::seen_count(&watcher), 2);
774
775        // Second poll returns nothing
776        let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
777        assert!(events.is_empty());
778    }
779
780    #[cfg(feature = "watcher")]
781    #[test]
782    fn test_watcher_trait_assembles_tool_results() {
783        let temp = TempDir::new().unwrap();
784        let claude_dir = temp.path().join(".claude");
785        let project_dir = claude_dir.join("projects/-test-project");
786        fs::create_dir_all(&project_dir).unwrap();
787
788        let entries = [
789            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read the file"}}"#,
790            r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"Reading..."},{"type":"tool_use","id":"t1","name":"Read","input":{"path":"test.rs"}}]}}"#,
791            r#"{"uuid":"u3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"fn main() {}","is_error":false}]}}"#,
792            r#"{"uuid":"u4","type":"assistant","timestamp":"2024-01-01T00:00:03Z","message":{"role":"assistant","content":"Done!"}}"#,
793        ];
794        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
795
796        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
797        let manager = ClaudeConvo::with_resolver(resolver);
798
799        let mut watcher = crate::watcher::ConversationWatcher::new(
800            manager,
801            "/test/project".to_string(),
802            "s1".to_string(),
803        );
804
805        let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
806
807        // Should get: Turn(user), Turn(assistant), TurnUpdated(assistant), Turn(assistant)
808        assert_eq!(events.len(), 4);
809
810        // First: user turn
811        assert!(matches!(&events[0], WatcherEvent::Turn(t) if t.role == Role::User));
812
813        // Second: assistant turn emitted eagerly (result may not be populated yet in the event)
814        assert!(matches!(&events[1], WatcherEvent::Turn(t) if t.role == Role::Assistant));
815
816        // Third: TurnUpdated with results merged
817        match &events[2] {
818            WatcherEvent::TurnUpdated(turn) => {
819                assert_eq!(turn.id, "u2");
820                assert_eq!(turn.tool_uses.len(), 1);
821                let result = turn.tool_uses[0].result.as_ref().unwrap();
822                assert_eq!(result.content, "fn main() {}");
823                assert!(!result.is_error);
824            }
825            other => panic!("Expected TurnUpdated, got {:?}", other),
826        }
827
828        // Fourth: final assistant turn
829        assert!(matches!(&events[3], WatcherEvent::Turn(t) if t.text == "Done!"));
830    }
831
832    #[cfg(feature = "watcher")]
833    #[test]
834    fn test_watcher_trait_incremental_tool_results() {
835        // Simulate tool results arriving in a different poll cycle than the tool use
836        let temp = TempDir::new().unwrap();
837        let claude_dir = temp.path().join(".claude");
838        let project_dir = claude_dir.join("projects/-test-project");
839        fs::create_dir_all(&project_dir).unwrap();
840
841        // Start with just the user message and assistant tool use
842        let entries_phase1 = [
843            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read file"}}"#,
844            r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"Reading..."},{"type":"tool_use","id":"t1","name":"Read","input":{"path":"test.rs"}}]}}"#,
845        ];
846        fs::write(
847            project_dir.join("s1.jsonl"),
848            entries_phase1.join("\n") + "\n",
849        )
850        .unwrap();
851
852        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
853        let manager = ClaudeConvo::with_resolver(resolver);
854
855        let mut watcher = crate::watcher::ConversationWatcher::new(
856            manager,
857            "/test/project".to_string(),
858            "s1".to_string(),
859        );
860
861        // First poll: get user + assistant turns
862        let events1 = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
863        assert_eq!(events1.len(), 2);
864        // Assistant turn emitted eagerly with result: None
865        if let WatcherEvent::Turn(t) = &events1[1] {
866            assert!(t.tool_uses[0].result.is_none());
867        } else {
868            panic!("Expected Turn");
869        }
870
871        // Now append the tool result entry
872        use std::io::Write;
873        let mut file = fs::OpenOptions::new()
874            .append(true)
875            .open(project_dir.join("s1.jsonl"))
876            .unwrap();
877        writeln!(file, r#"{{"uuid":"u3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t1","content":"fn main() {{}}","is_error":false}}]}}}}"#).unwrap();
878
879        // Second poll: tool-result-only entry arrives
880        let events2 = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
881        // The tool-result-only entry can't find its matching turn in this poll
882        // cycle (it was emitted in the previous one), so it's silently absorbed.
883        // This is a known limitation of the eager-emit approach for cross-poll
884        // boundaries — the batch path (to_view) handles this correctly.
885        // Consumers needing full fidelity across poll boundaries should
886        // periodically do a full load_conversation.
887        assert!(events2.is_empty() || events2.iter().all(|e| !matches!(e, WatcherEvent::Turn(_))));
888    }
889
890    #[test]
891    fn test_merge_tool_results_by_id() {
892        // Verify that merge matches by tool_use_id, not position
893        let mut turns = vec![Turn {
894            id: "t1".into(),
895            parent_id: None,
896            role: Role::Assistant,
897            timestamp: "2024-01-01T00:00:00Z".into(),
898            text: "test".into(),
899            thinking: None,
900            tool_uses: vec![
901                ToolInvocation {
902                    id: "tool-a".into(),
903                    name: "Read".into(),
904                    input: serde_json::json!({}),
905                    result: None,
906                    category: Some(ToolCategory::FileRead),
907                },
908                ToolInvocation {
909                    id: "tool-b".into(),
910                    name: "Write".into(),
911                    input: serde_json::json!({}),
912                    result: None,
913                    category: Some(ToolCategory::FileWrite),
914                },
915            ],
916            model: None,
917            stop_reason: None,
918            token_usage: None,
919            environment: None,
920            delegations: vec![],
921            extra: Default::default(),
922        }];
923
924        // Create a message with results in reversed order
925        let msg: Message = serde_json::from_str(
926            r#"{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-b","content":"write result","is_error":false},{"type":"tool_result","tool_use_id":"tool-a","content":"read result","is_error":true}]}"#,
927        )
928        .unwrap();
929
930        let merged = merge_tool_results(&mut turns, &msg);
931        assert!(merged);
932
933        // Results should match by ID regardless of order
934        assert_eq!(
935            turns[0].tool_uses[0].result.as_ref().unwrap().content,
936            "read result"
937        );
938        assert!(turns[0].tool_uses[0].result.as_ref().unwrap().is_error);
939
940        assert_eq!(
941            turns[0].tool_uses[1].result.as_ref().unwrap().content,
942            "write result"
943        );
944        assert!(!turns[0].tool_uses[1].result.as_ref().unwrap().is_error);
945    }
946
947    #[test]
948    fn test_is_tool_result_only() {
949        // Tool-result-only entry
950        let entry: ConversationEntry = serde_json::from_str(
951            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"ok","is_error":false}]}}"#,
952        )
953        .unwrap();
954        assert!(is_tool_result_only(&entry));
955
956        // Regular user entry with text
957        let entry: ConversationEntry = serde_json::from_str(
958            r#"{"uuid":"u2","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
959        )
960        .unwrap();
961        assert!(!is_tool_result_only(&entry));
962
963        // Entry without message
964        let entry: ConversationEntry = serde_json::from_str(
965            r#"{"uuid":"u3","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
966        )
967        .unwrap();
968        assert!(!is_tool_result_only(&entry));
969
970        // Assistant entry (never tool-result-only)
971        let entry: ConversationEntry = serde_json::from_str(
972            r#"{"uuid":"u4","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"hi"}}"#,
973        )
974        .unwrap();
975        assert!(!is_tool_result_only(&entry));
976    }
977
978    // ── New enrichment tests ─────────────────────────────────────────
979
980    #[test]
981    fn test_tool_category_mapping() {
982        assert_eq!(tool_category("Read"), Some(ToolCategory::FileRead));
983        assert_eq!(tool_category("Glob"), Some(ToolCategory::FileSearch));
984        assert_eq!(tool_category("Grep"), Some(ToolCategory::FileSearch));
985        assert_eq!(tool_category("Write"), Some(ToolCategory::FileWrite));
986        assert_eq!(tool_category("Edit"), Some(ToolCategory::FileWrite));
987        assert_eq!(tool_category("NotebookEdit"), Some(ToolCategory::FileWrite));
988        assert_eq!(tool_category("Bash"), Some(ToolCategory::Shell));
989        assert_eq!(tool_category("WebFetch"), Some(ToolCategory::Network));
990        assert_eq!(tool_category("WebSearch"), Some(ToolCategory::Network));
991        assert_eq!(tool_category("Task"), Some(ToolCategory::Delegation));
992        assert_eq!(tool_category("UnknownTool"), None);
993    }
994
995    #[test]
996    fn test_turn_has_tool_category() {
997        let (_temp, provider) = setup_provider();
998        let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
999            .unwrap();
1000
1001        // Turn 1 (assistant) has a Read tool
1002        assert_eq!(
1003            view.turns[1].tool_uses[0].category,
1004            Some(ToolCategory::FileRead)
1005        );
1006        // Turn 2 (assistant) has an Edit tool
1007        assert_eq!(
1008            view.turns[2].tool_uses[0].category,
1009            Some(ToolCategory::FileWrite)
1010        );
1011    }
1012
1013    #[test]
1014    fn test_environment_populated_from_entry() {
1015        let temp = TempDir::new().unwrap();
1016        let claude_dir = temp.path().join(".claude");
1017        let project_dir = claude_dir.join("projects/-test-project");
1018        fs::create_dir_all(&project_dir).unwrap();
1019
1020        let entries = [
1021            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","cwd":"/project/path","gitBranch":"feat/auth","message":{"role":"user","content":"Hello"}}"#,
1022            r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
1023        ];
1024        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1025
1026        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1027        let provider = ClaudeConvo::with_resolver(resolver);
1028        let view =
1029            ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1030
1031        // User turn has environment (entry has cwd and gitBranch)
1032        let env = view.turns[0].environment.as_ref().unwrap();
1033        assert_eq!(env.working_dir.as_deref(), Some("/project/path"));
1034        assert_eq!(env.vcs_branch.as_deref(), Some("feat/auth"));
1035        assert!(env.vcs_revision.is_none());
1036
1037        // Assistant turn has no environment (entry has no cwd/gitBranch)
1038        assert!(view.turns[1].environment.is_none());
1039    }
1040
1041    #[test]
1042    fn test_cache_tokens_populated() {
1043        let temp = TempDir::new().unwrap();
1044        let claude_dir = temp.path().join(".claude");
1045        let project_dir = claude_dir.join("projects/-test-project");
1046        fs::create_dir_all(&project_dir).unwrap();
1047
1048        let entries = [
1049            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
1050            r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":200,"cache_read_input_tokens":500}}}"#,
1051        ];
1052        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1053
1054        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1055        let provider = ClaudeConvo::with_resolver(resolver);
1056        let view =
1057            ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1058
1059        let usage = view.turns[1].token_usage.as_ref().unwrap();
1060        assert_eq!(usage.cache_read_tokens, Some(500));
1061        assert_eq!(usage.cache_write_tokens, Some(200));
1062    }
1063
1064    #[test]
1065    fn test_total_usage_aggregated() {
1066        let (_temp, provider) = setup_provider();
1067        let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1068            .unwrap();
1069
1070        let total = view.total_usage.as_ref().unwrap();
1071        // Two assistant turns with usage: (100, 50) and (200, 100)
1072        assert_eq!(total.input_tokens, Some(300));
1073        assert_eq!(total.output_tokens, Some(150));
1074    }
1075
1076    #[test]
1077    fn test_provider_id_set() {
1078        let (_temp, provider) = setup_provider();
1079        let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1080            .unwrap();
1081
1082        assert_eq!(view.provider_id.as_deref(), Some("claude-code"));
1083    }
1084
1085    #[test]
1086    fn test_files_changed_populated() {
1087        let temp = TempDir::new().unwrap();
1088        let claude_dir = temp.path().join(".claude");
1089        let project_dir = claude_dir.join("projects/-test-project");
1090        fs::create_dir_all(&project_dir).unwrap();
1091
1092        let entries = [
1093            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Edit files"}}"#,
1094            r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"Editing..."},{"type":"tool_use","id":"t1","name":"Write","input":{"file_path":"src/main.rs","content":"fn main() {}"}},{"type":"tool_use","id":"t2","name":"Edit","input":{"file_path":"src/lib.rs","old_string":"a","new_string":"b"}}]}}"#,
1095            r#"{"uuid":"u3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"ok","is_error":false},{"type":"tool_result","tool_use_id":"t2","content":"ok","is_error":false}]}}"#,
1096            r#"{"uuid":"u4","type":"assistant","timestamp":"2024-01-01T00:00:03Z","message":{"role":"assistant","content":[{"type":"text","text":"More edits..."},{"type":"tool_use","id":"t3","name":"Write","input":{"file_path":"src/main.rs","content":"updated"}}]}}"#,
1097        ];
1098        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1099
1100        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1101        let provider = ClaudeConvo::with_resolver(resolver);
1102        let view =
1103            ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1104
1105        // Deduplicated, first-touch order: src/main.rs first, then src/lib.rs
1106        assert_eq!(view.files_changed, vec!["src/main.rs", "src/lib.rs"]);
1107    }
1108
1109    #[test]
1110    fn test_delegations_extracted() {
1111        let temp = TempDir::new().unwrap();
1112        let claude_dir = temp.path().join(".claude");
1113        let project_dir = claude_dir.join("projects/-test-project");
1114        fs::create_dir_all(&project_dir).unwrap();
1115
1116        let entries = [
1117            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Search for bugs"}}"#,
1118            r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"Delegating..."},{"type":"tool_use","id":"task-1","name":"Task","input":{"prompt":"Find the authentication bug","subagent_type":"Explore"}}]}}"#,
1119            r#"{"uuid":"u3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"task-1","content":"Found the bug in auth.rs line 42","is_error":false}]}}"#,
1120        ];
1121        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1122
1123        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1124        let provider = ClaudeConvo::with_resolver(resolver);
1125        let view =
1126            ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1127
1128        // Assistant turn should have one delegation
1129        assert_eq!(view.turns[1].delegations.len(), 1);
1130        let d = &view.turns[1].delegations[0];
1131        assert_eq!(d.agent_id, "task-1");
1132        assert_eq!(d.prompt, "Find the authentication bug");
1133        assert!(d.turns.is_empty()); // Sub-agent turns are in separate files
1134        // Result gets populated from tool result assembly
1135        assert_eq!(
1136            d.result.as_deref(),
1137            Some("Found the bug in auth.rs line 42")
1138        );
1139    }
1140
1141    // ── Provider-specific extras (Turn.extra["claude"]) ─────────────
1142
1143    #[test]
1144    fn test_turn_extra_populated_from_entry() {
1145        let entry: ConversationEntry = serde_json::from_str(
1146            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","subtype":"init","message":{"role":"user","content":"hello"}}"#,
1147        )
1148        .unwrap();
1149        let turn = to_turn(&entry).unwrap();
1150        let claude = turn.extra.get("claude").expect("extra[\"claude\"] missing");
1151        assert_eq!(claude["subtype"], "init");
1152    }
1153
1154    #[test]
1155    fn test_turn_extra_empty_when_no_extras() {
1156        let entry: ConversationEntry = serde_json::from_str(
1157            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
1158        )
1159        .unwrap();
1160        let turn = to_turn(&entry).unwrap();
1161        assert!(turn.extra.is_empty());
1162    }
1163
1164    #[test]
1165    fn test_progress_data_enriched_with_extras() {
1166        let entry: ConversationEntry = serde_json::from_str(
1167            r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z","data":{"type":"hook_progress","hookName":"pre-commit"}}"#,
1168        )
1169        .unwrap();
1170        let event = entry_to_watcher_event(&entry);
1171        match event {
1172            WatcherEvent::Progress { kind, data } => {
1173                assert_eq!(kind, "progress");
1174                assert_eq!(data["uuid"], "u1");
1175                assert_eq!(data["timestamp"], "2024-01-01T00:00:00Z");
1176                let claude = &data["claude"];
1177                assert_eq!(claude["data"]["type"], "hook_progress");
1178                assert_eq!(claude["data"]["hookName"], "pre-commit");
1179            }
1180            other => panic!(
1181                "Expected Progress, got {:?}",
1182                std::mem::discriminant(&other)
1183            ),
1184        }
1185    }
1186
1187    #[test]
1188    fn test_progress_data_no_claude_key_when_no_extras() {
1189        let entry: ConversationEntry = serde_json::from_str(
1190            r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
1191        )
1192        .unwrap();
1193        let event = entry_to_watcher_event(&entry);
1194        match event {
1195            WatcherEvent::Progress { data, .. } => {
1196                assert!(data.get("claude").is_none());
1197            }
1198            other => panic!(
1199                "Expected Progress, got {:?}",
1200                std::mem::discriminant(&other)
1201            ),
1202        }
1203    }
1204
1205    #[test]
1206    fn test_no_delegations_for_non_task_tools() {
1207        let (_temp, provider) = setup_provider();
1208        let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1209            .unwrap();
1210
1211        // No turns should have delegations (none use Task tool)
1212        for turn in &view.turns {
1213            assert!(turn.delegations.is_empty());
1214        }
1215    }
1216
1217    // ── Session chain tests ─────────────────────────────────────────
1218
1219    fn setup_chained_provider() -> (TempDir, ClaudeConvo) {
1220        let temp = TempDir::new().unwrap();
1221        let claude_dir = temp.path().join(".claude");
1222        let project_dir = claude_dir.join("projects/-test-project");
1223        fs::create_dir_all(&project_dir).unwrap();
1224
1225        // Session A: original conversation
1226        let entries_a = [
1227            r#"{"uuid":"a1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Fix the bug"}}"#,
1228            r#"{"uuid":"a2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","sessionId":"session-a","message":{"role":"assistant","content":"I'll fix that.","model":"claude-opus-4-6","usage":{"input_tokens":100,"output_tokens":50}}}"#,
1229        ];
1230        fs::write(project_dir.join("session-a.jsonl"), entries_a.join("\n")).unwrap();
1231
1232        // Session B: continuation with bridge entry
1233        let entries_b = [
1234            // Bridge entry: session_id points back to session-a
1235            r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Continue the fix"}}"#,
1236            // Real entries in session-b
1237            r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"What about the tests?"}}"#,
1238            r#"{"uuid":"b2","type":"assistant","timestamp":"2024-01-01T01:00:02Z","sessionId":"session-b","message":{"role":"assistant","content":"Tests pass now.","model":"claude-opus-4-6","usage":{"input_tokens":200,"output_tokens":100}}}"#,
1239        ];
1240        fs::write(project_dir.join("session-b.jsonl"), entries_b.join("\n")).unwrap();
1241
1242        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1243        (temp, ClaudeConvo::with_resolver(resolver))
1244    }
1245
1246    #[test]
1247    fn test_load_conversation_merges_chain() {
1248        let (_temp, provider) = setup_chained_provider();
1249
1250        // Load from session-a — should merge with session-b
1251        let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-a")
1252            .unwrap();
1253
1254        // Should have turns from both segments (minus the bridge entry)
1255        // session-a: a1 (user), a2 (assistant)
1256        // session-b: b1 (user), b2 (assistant) — b0 is bridge, filtered
1257        assert_eq!(view.turns.len(), 4);
1258        assert_eq!(view.turns[0].text, "Fix the bug");
1259        assert_eq!(view.turns[1].text, "I'll fix that.");
1260        assert_eq!(view.turns[2].text, "What about the tests?");
1261        assert_eq!(view.turns[3].text, "Tests pass now.");
1262
1263        // Session IDs should be set
1264        assert_eq!(view.session_ids, vec!["session-a", "session-b"]);
1265    }
1266
1267    #[test]
1268    fn test_load_conversation_skips_bridge_entries() {
1269        let (_temp, provider) = setup_chained_provider();
1270
1271        let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-a")
1272            .unwrap();
1273
1274        // Bridge entry text "Continue the fix" should NOT appear
1275        for turn in &view.turns {
1276            assert_ne!(turn.text, "Continue the fix");
1277        }
1278    }
1279
1280    #[test]
1281    fn test_load_conversation_single_segment_unchanged() {
1282        let temp = TempDir::new().unwrap();
1283        let claude_dir = temp.path().join(".claude");
1284        let project_dir = claude_dir.join("projects/-test-project");
1285        fs::create_dir_all(&project_dir).unwrap();
1286
1287        let entries = [
1288            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"solo","message":{"role":"user","content":"Hello"}}"#,
1289            r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","sessionId":"solo","message":{"role":"assistant","content":"Hi there!"}}"#,
1290        ];
1291        fs::write(project_dir.join("solo.jsonl"), entries.join("\n")).unwrap();
1292
1293        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1294        let provider = ClaudeConvo::with_resolver(resolver);
1295        let view =
1296            ConversationProvider::load_conversation(&provider, "/test/project", "solo").unwrap();
1297
1298        assert_eq!(view.turns.len(), 2);
1299        assert_eq!(view.turns[0].text, "Hello");
1300        assert_eq!(view.turns[1].text, "Hi there!");
1301        // Single segment — session_ids should be empty
1302        assert!(view.session_ids.is_empty());
1303    }
1304
1305    #[test]
1306    fn test_list_metadata_chain_transparent() {
1307        let (_temp, provider) = setup_chained_provider();
1308
1309        let metas = ConversationProvider::list_metadata(&provider, "/test/project").unwrap();
1310
1311        // Chain-default: only the chain head is returned
1312        assert_eq!(metas.len(), 1);
1313        assert_eq!(metas[0].id, "session-a");
1314
1315        // Chains are transparent — no predecessor/successor links
1316        assert!(metas[0].predecessor.is_none());
1317        assert!(metas[0].successor.is_none());
1318    }
1319
1320    #[cfg(feature = "watcher")]
1321    #[test]
1322    fn test_watcher_emits_rotation_progress() {
1323        let temp = TempDir::new().unwrap();
1324        let claude_dir = temp.path().join(".claude");
1325        let project_dir = claude_dir.join("projects/-test-project");
1326        fs::create_dir_all(&project_dir).unwrap();
1327
1328        // Session A
1329        let entry_a = r#"{"uuid":"a1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Hello"}}"#;
1330        fs::write(
1331            project_dir.join("session-a.jsonl"),
1332            format!("{}\n", entry_a),
1333        )
1334        .unwrap();
1335
1336        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1337        let manager = ClaudeConvo::with_resolver(resolver);
1338
1339        let mut watcher = crate::watcher::ConversationWatcher::new(
1340            manager,
1341            "/test/project".to_string(),
1342            "session-a".to_string(),
1343        );
1344
1345        // First poll via trait: consume session-a entries
1346        let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1347        assert_eq!(events.len(), 1);
1348        assert!(matches!(&events[0], WatcherEvent::Turn(_)));
1349
1350        // Create successor session-b
1351        let entries_b = [
1352            r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Bridge"}}"#,
1353            r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"New"}}"#,
1354        ];
1355        fs::write(project_dir.join("session-b.jsonl"), entries_b.join("\n")).unwrap();
1356
1357        // Second poll via trait: should include rotation Progress event
1358        let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1359
1360        // First event: Progress(session_rotated) with from/to
1361        assert!(
1362            events.len() >= 2,
1363            "Expected Progress + Turn, got {} events",
1364            events.len()
1365        );
1366        match &events[0] {
1367            WatcherEvent::Progress { kind, data } => {
1368                assert_eq!(kind, "session_rotated");
1369                assert_eq!(data["from"], "session-a");
1370                assert_eq!(data["to"], "session-b");
1371            }
1372            other => panic!("Expected Progress, got {:?}", std::mem::discriminant(other)),
1373        }
1374
1375        // Second event: Turn for b1 (bridge entry b0 filtered out)
1376        match &events[1] {
1377            WatcherEvent::Turn(turn) => {
1378                assert_eq!(turn.id, "b1");
1379                assert_eq!(turn.text, "New");
1380            }
1381            other => panic!("Expected Turn(b1), got {:?}", std::mem::discriminant(other)),
1382        }
1383
1384        // No bridge entry should appear as a Turn
1385        for event in &events {
1386            if let WatcherEvent::Turn(t) = event {
1387                assert_ne!(t.id, "b0", "Bridge entry should not appear as a Turn");
1388            }
1389        }
1390    }
1391
1392    #[test]
1393    fn test_load_metadata_chain_transparent() {
1394        let (_temp, provider) = setup_chained_provider();
1395
1396        // Load from chain head — aggregated metadata
1397        let meta_a =
1398            ConversationProvider::load_metadata(&provider, "/test/project", "session-a").unwrap();
1399        assert_eq!(meta_a.id, "session-a");
1400        // Aggregated message count across both segments (2 + 3 = 5)
1401        assert_eq!(meta_a.message_count, 5);
1402        // Chains are transparent — no predecessor/successor links
1403        assert!(meta_a.predecessor.is_none());
1404        assert!(meta_a.successor.is_none());
1405
1406        // Load from a successor — still resolves the full chain
1407        let meta_b =
1408            ConversationProvider::load_metadata(&provider, "/test/project", "session-b").unwrap();
1409        assert_eq!(meta_b.id, "session-a"); // head of chain
1410        assert_eq!(meta_b.message_count, 5);
1411        assert!(meta_b.predecessor.is_none());
1412        assert!(meta_b.successor.is_none());
1413    }
1414}