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