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