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