Skip to main content

toolpath_claude/
provider.rs

1//! Implementation of `toolpath-convo` traits for Claude conversations.
2//!
3//! Handles cross-entry tool result assembly: Claude's JSONL format writes
4//! tool invocations and their results as separate entries. This module
5//! pairs them by `tool_use_id` so consumers get complete `Turn` values
6//! with `ToolInvocation.result` populated.
7
8use std::collections::HashMap;
9
10use crate::ClaudeConvo;
11use crate::types::{Conversation, ConversationEntry, Message, MessageContent, MessageRole};
12#[cfg(any(feature = "watcher", test))]
13use toolpath_convo::WatcherEvent;
14use toolpath_convo::{
15    ConversationMeta, ConversationProvider, ConversationView, ConvoError, DelegatedWork,
16    EnvironmentSnapshot, Role, TokenUsage, ToolCategory, ToolInvocation, ToolResult, Turn,
17};
18
19// ── Conversion helpers ───────────────────────────────────────────────
20
21fn claude_role_to_role(role: &MessageRole) -> Role {
22    match role {
23        MessageRole::User => Role::User,
24        MessageRole::Assistant => Role::Assistant,
25        MessageRole::System => Role::System,
26    }
27}
28
29/// Classify a Claude Code tool into toolpath's category ontology.
30///
31/// Returns `None` for unrecognized tools. When Claude Code adds or
32/// renames tools, update this map.
33pub fn tool_category(name: &str) -> Option<ToolCategory> {
34    match name {
35        "Read" => Some(ToolCategory::FileRead),
36        "Glob" | "Grep" => Some(ToolCategory::FileSearch),
37        "Write" | "Edit" | "MultiEdit" | "NotebookEdit" => Some(ToolCategory::FileWrite),
38        "Bash" => Some(ToolCategory::Shell),
39        "WebFetch" | "WebSearch" => Some(ToolCategory::Network),
40        "Task" | "Agent" => Some(ToolCategory::Delegation),
41        _ => None,
42    }
43}
44
45/// Reverse of [`tool_category`]: pick Claude's native tool name for a
46/// given [`ToolCategory`], disambiguating by `args` shape where needed
47/// (e.g. `Edit` vs `Write`, `Glob` vs `Grep`).
48///
49/// Returns `None` when no Claude-canonical equivalent exists. Mirrors
50/// the `provider::native_name` helpers on opencode / codex / gemini /
51/// pi — projectors call it to surface cross-harness tool calls under
52/// the names Claude Code's UI knows how to render.
53pub fn native_name(category: ToolCategory, args: &serde_json::Value) -> Option<&'static str> {
54    let has = |k: &str| args.get(k).is_some();
55    match category {
56        ToolCategory::Shell => Some("Bash"),
57        ToolCategory::FileRead => Some("Read"),
58        ToolCategory::FileWrite => Some(if has("old_string") || has("oldString") {
59            "Edit"
60        } else {
61            "Write"
62        }),
63        ToolCategory::FileSearch => Some(
64            // Grep takes a regex `pattern` and often has output_mode/type
65            // hints; Glob takes a glob pattern. When ambiguous, default to
66            // Glob — its file-list rendering at least shows results.
67            if has("output_mode") || has("path_pattern") || has("type") {
68                "Grep"
69            } else {
70                "Glob"
71            },
72        ),
73        ToolCategory::Network => Some(if has("url") { "WebFetch" } else { "WebSearch" }),
74        ToolCategory::Delegation => Some("Task"),
75    }
76}
77
78/// Convert a single entry to a Turn without cross-entry assembly.
79/// Tool results within the same message are still matched.
80fn message_to_turn(entry: &ConversationEntry, msg: &Message) -> Turn {
81    let text = msg.text();
82
83    let thinking = msg.thinking().map(|parts| parts.join("\n"));
84
85    let tool_uses: Vec<ToolInvocation> = msg
86        .tool_uses()
87        .into_iter()
88        .map(|tu| {
89            let result = find_tool_result_in_parts(msg, tu.id);
90            let category = tool_category(tu.name);
91            ToolInvocation {
92                id: tu.id.to_string(),
93                name: tu.name.to_string(),
94                input: tu.input.clone(),
95                result,
96                category,
97            }
98        })
99        .collect();
100
101    let file_mutations = compute_file_mutations(&tool_uses, entry.cwd.as_deref());
102
103    let token_usage = msg.usage.as_ref().map(|u| TokenUsage {
104        input_tokens: u.input_tokens,
105        output_tokens: u.output_tokens,
106        cache_read_tokens: u.cache_read_input_tokens,
107        cache_write_tokens: u.cache_creation_input_tokens,
108        ..Default::default()
109    });
110
111    let environment = if entry.cwd.is_some() || entry.git_branch.is_some() {
112        Some(EnvironmentSnapshot {
113            working_dir: entry.cwd.clone(),
114            vcs_branch: entry.git_branch.clone(),
115            vcs_revision: None,
116        })
117    } else {
118        None
119    };
120
121    let delegations = extract_delegations(&tool_uses);
122
123    Turn {
124        id: entry.uuid.clone(),
125        parent_id: entry.parent_uuid.clone(),
126        // The API message ID (`msg_…`). Claude Code writes one JSONL line
127        // per content block, so several turns can share one group_id —
128        // and each repeats the message-level `usage`. Downstream accounting
129        // (sum_usage, derive_path) counts a message group once.
130        group_id: msg.id.clone(),
131        role: claude_role_to_role(&msg.role),
132        timestamp: entry.timestamp.clone(),
133        text,
134        thinking,
135        tool_uses,
136        model: msg.model.clone(),
137        stop_reason: msg.stop_reason.clone(),
138        token_usage,
139        attributed_token_usage: None,
140        environment,
141        delegations,
142        file_mutations,
143    }
144}
145
146/// For each file-write tool invocation in the turn, synthesize a unified
147/// diff via [`toolpath_convo::file_write_diff`] and pre-resolve the
148/// before-state for `Write` via `git show HEAD:<path>` (best-effort).
149/// Each mutation links back to its tool via `tool_id`.
150fn compute_file_mutations(
151    tool_uses: &[ToolInvocation],
152    cwd: Option<&str>,
153) -> Vec<toolpath_convo::FileMutation> {
154    let mut out = Vec::new();
155    for tu in tool_uses {
156        if tu.category != Some(ToolCategory::FileWrite) {
157            continue;
158        }
159        let Some(path) = extract_file_path_for_tool(&tu.input) else {
160            continue;
161        };
162        // Only `Write` carries whole-file content; consult git HEAD for
163        // its pre-image so the diff isn't addition-only. Other tools
164        // (Edit / MultiEdit / NotebookEdit) carry old_string/new_string
165        // pairs and don't need a before-state lookup.
166        let before_state = if tu.name == "Write" {
167            cwd.and_then(|c| git_head_content(c, &path))
168        } else {
169            None
170        };
171        let raw_diff =
172            toolpath_convo::file_write_diff(&tu.name, &tu.input, &path, before_state.as_deref());
173        let operation = match tu.name.as_str() {
174            "Write" => Some("add".to_string()),
175            "Edit" | "MultiEdit" | "NotebookEdit" => Some("update".to_string()),
176            _ => None,
177        };
178        let after = match tu.name.as_str() {
179            "Write" => tu
180                .input
181                .get("content")
182                .and_then(|v| v.as_str())
183                .map(|s| s.to_string()),
184            _ => None,
185        };
186        out.push(toolpath_convo::FileMutation {
187            path,
188            tool_id: Some(tu.id.clone()),
189            operation,
190            raw_diff,
191            before: before_state,
192            after,
193            rename_to: None,
194        });
195    }
196    out
197}
198
199/// Best-effort lookup of a file's contents at `HEAD` in the git repo
200/// rooted at `repo_dir` (or one of its ancestors). Shells out to `git
201/// show HEAD:<relative-path>`. Returns `None` when any of these hold:
202/// `repo_dir` isn't inside a git repo, `path` isn't tracked at `HEAD`,
203/// `git` isn't on `PATH`, or the command otherwise fails.
204fn git_head_content(repo_dir: &str, path: &str) -> Option<String> {
205    use std::path::Path as FsPath;
206    use std::process::Command;
207    let repo = FsPath::new(repo_dir);
208    let file = FsPath::new(path);
209    let rel = if file.is_absolute() {
210        file.strip_prefix(repo).ok()?.to_path_buf()
211    } else {
212        file.to_path_buf()
213    };
214    let rel_str = rel.to_string_lossy().replace('\\', "/");
215    let output = Command::new("git")
216        .arg("-C")
217        .arg(repo)
218        .arg("show")
219        .arg(format!("HEAD:{rel_str}"))
220        .output()
221        .ok()?;
222    if !output.status.success() {
223        return None;
224    }
225    String::from_utf8(output.stdout).ok()
226}
227
228fn extract_file_path_for_tool(input: &serde_json::Value) -> Option<String> {
229    for k in ["file_path", "path", "filename", "file"] {
230        if let Some(s) = input.get(k).and_then(|v| v.as_str()) {
231            return Some(s.to_string());
232        }
233    }
234    None
235}
236
237/// Extract delegation info from Task tool invocations.
238fn extract_delegations(tool_uses: &[ToolInvocation]) -> Vec<DelegatedWork> {
239    tool_uses
240        .iter()
241        .filter(|tu| tu.category == Some(ToolCategory::Delegation))
242        .map(|tu| DelegatedWork {
243            agent_id: tu.id.clone(),
244            prompt: tu
245                .input
246                .get("prompt")
247                .and_then(|v| v.as_str())
248                .unwrap_or("")
249                .to_string(),
250            turns: vec![],
251            result: tu.result.as_ref().map(|r| r.content.clone()),
252        })
253        .collect()
254}
255
256fn find_tool_result_in_parts(msg: &Message, tool_use_id: &str) -> Option<ToolResult> {
257    let parts = match &msg.content {
258        Some(MessageContent::Parts(parts)) => parts,
259        _ => return None,
260    };
261    parts.iter().find_map(|p| match p {
262        crate::types::ContentPart::ToolResult {
263            tool_use_id: id,
264            content,
265            is_error,
266        } if id == tool_use_id => Some(ToolResult {
267            content: content.text(),
268            is_error: *is_error,
269        }),
270        _ => None,
271    })
272}
273
274/// Returns true if this entry is a tool-result-only user message
275/// (no human-authored text, only tool_result parts).
276fn is_tool_result_only(entry: &ConversationEntry) -> bool {
277    let Some(msg) = &entry.message else {
278        return false;
279    };
280    msg.role == MessageRole::User && msg.text().is_empty() && !msg.tool_results().is_empty()
281}
282
283/// Merge tool results from a tool-result-only message into existing turns.
284///
285/// Matches by `tool_use_id` — scans backwards through turns to find the
286/// `ToolInvocation` with a matching `id` for each result. This handles
287/// cases where a single result entry carries results for tool uses from
288/// different assistant turns.
289///
290/// Returns true if any results were merged.
291fn merge_tool_results(turns: &mut [Turn], msg: &Message) -> bool {
292    let mut merged = false;
293    for tr in msg.tool_results() {
294        for turn in turns.iter_mut().rev() {
295            if let Some(invocation) = turn
296                .tool_uses
297                .iter_mut()
298                .find(|tu| tu.id == tr.tool_use_id && tu.result.is_none())
299            {
300                invocation.result = Some(ToolResult {
301                    content: tr.content.text(),
302                    is_error: tr.is_error,
303                });
304                merged = true;
305                break;
306            }
307        }
308    }
309    merged
310}
311
312fn entry_to_turn(entry: &ConversationEntry) -> Option<Turn> {
313    entry
314        .message
315        .as_ref()
316        .map(|msg| message_to_turn(entry, msg))
317}
318
319/// Convert a full conversation to a view with cross-entry tool result assembly.
320///
321/// Tool-result-only user entries are absorbed into the preceding assistant
322/// turn's `ToolInvocation.result` fields rather than emitted as separate turns.
323fn conversation_to_view(convo: &Conversation) -> ConversationView {
324    let mut turns: Vec<Turn> = Vec::new();
325    let mut events: Vec<toolpath_convo::ConversationEvent> = Vec::new();
326
327    // Headerless preamble lines (ai-title, last-prompt, queue-operation,
328    // permission-mode, file-history-snapshot, etc.) become events so they
329    // round-trip back to JSONL.
330    for (idx, raw) in convo.preamble.iter().enumerate() {
331        events.push(preamble_to_event(idx, raw));
332    }
333
334    // Map from "absorbed-or-skipped entry UUID" → "the previous
335    // turn-bearing entry's UUID". Used so that an assistant turn whose
336    // wire parentUuid points at a tool-result-only entry (or any other
337    // absorbed entry that didn't become a Turn) gets a Turn.parent_id
338    // that still maps onto a real Turn — keeping the IR's turn-to-turn
339    // chain intact for `derive_path`. The original UUID is preserved
340    // via the `tool_result_user` event.
341    let mut parent_rewrites: HashMap<String, String> = HashMap::new();
342    let mut last_turn_uuid: Option<String> = None;
343
344    for entry in &convo.entries {
345        let Some(msg) = &entry.message else {
346            // Message-less entries (attachments, snapshots) survive as
347            // events so the projector can re-emit them.
348            events.push(entry_to_event(entry));
349            if let Some(prev) = &last_turn_uuid {
350                parent_rewrites.insert(entry.uuid.clone(), prev.clone());
351            }
352            continue;
353        };
354
355        // Tool-result-only user entries get merged into the preceding
356        // assistant's tool_uses[i].result and dropped from the turn
357        // stream. The next assistant entry's wire parentUuid points at
358        // this entry; we record a rewrite so the IR's turn-to-turn chain
359        // stays connected. (The projector re-synthesizes the wire-level
360        // tool-result entries on the way out from tool_uses[i].result —
361        // their original UUIDs aren't preserved across the roundtrip,
362        // but the Claude UI walks the chain by parentUuid, not by
363        // specific UUIDs, so that's fine.)
364        if is_tool_result_only(entry) {
365            merge_tool_results(&mut turns, msg);
366            if let Some(prev) = &last_turn_uuid {
367                parent_rewrites.insert(entry.uuid.clone(), prev.clone());
368            }
369            continue;
370        }
371
372        let mut turn = message_to_turn(entry, msg);
373        if let Some(pid) = turn.parent_id.as_ref()
374            && let Some(real) = parent_rewrites.get(pid)
375        {
376            turn.parent_id = Some(real.clone());
377        }
378        last_turn_uuid = Some(turn.id.clone());
379        turns.push(turn);
380    }
381
382    canonicalize_message_usage(&mut turns);
383
384    // Re-derive delegation results now that tool results are merged
385    for turn in &mut turns {
386        for delegation in &mut turn.delegations {
387            if delegation.result.is_none()
388                && let Some(tu) = turn
389                    .tool_uses
390                    .iter()
391                    .find(|tu| tu.id == delegation.agent_id)
392            {
393                delegation.result = tu.result.as_ref().map(|r| r.content.clone());
394            }
395        }
396    }
397
398    let total_usage = sum_usage(&turns);
399    let files_changed = extract_files_changed(&turns);
400
401    // Pull path-level base/producer from the first entry that carries the
402    // metadata (Claude records cwd / git_branch / version on every
403    // conversational entry; the first one is the canonical "this is where
404    // we started").
405    let mut base = toolpath_convo::SessionBase::default();
406    let mut producer_version: Option<String> = None;
407    for entry in &convo.entries {
408        if base.working_dir.is_none()
409            && let Some(cwd) = &entry.cwd
410        {
411            base.working_dir = Some(cwd.clone());
412        }
413        if base.vcs_branch.is_none()
414            && let Some(b) = &entry.git_branch
415        {
416            base.vcs_branch = Some(b.clone());
417        }
418        if producer_version.is_none()
419            && let Some(v) = &entry.version
420        {
421            producer_version = Some(v.clone());
422        }
423        if base.working_dir.is_some() && base.vcs_branch.is_some() && producer_version.is_some() {
424            break;
425        }
426    }
427    let view_base = if base.working_dir.is_some()
428        || base.vcs_branch.is_some()
429        || base.vcs_revision.is_some()
430        || base.vcs_remote.is_some()
431    {
432        Some(base)
433    } else {
434        None
435    };
436    let producer = producer_version.map(|v| toolpath_convo::ProducerInfo {
437        name: "claude-code".into(),
438        version: Some(v),
439    });
440
441    ConversationView {
442        id: convo.session_id.clone(),
443        started_at: convo.started_at,
444        last_activity: convo.last_activity,
445        turns,
446        total_usage,
447        provider_id: Some("claude-code".into()),
448        files_changed,
449        session_ids: vec![],
450        events,
451        base: view_base,
452        producer,
453    }
454}
455
456/// Build an event from a headerless preamble JSON line (`ai-title`,
457/// `last-prompt`, `queue-operation`, `permission-mode`, `file-history-snapshot`,
458/// or anything else above `entries` in Claude's JSONL).
459///
460/// The whole line is preserved verbatim under `data["raw"]`; the projector
461/// dumps it straight back onto `convo.preamble`. We don't model the shape —
462/// a headerless line is identified by the presence of `data["raw"]`, not by
463/// an enumerated `type` list. `event_type` carries the line's `type`, purely
464/// informational.
465fn preamble_to_event(idx: usize, raw: &serde_json::Value) -> toolpath_convo::ConversationEvent {
466    let event_type = raw
467        .get("type")
468        .and_then(|v| v.as_str())
469        .unwrap_or("preamble")
470        .to_string();
471    let timestamp = raw
472        .get("timestamp")
473        .and_then(|v| v.as_str())
474        .unwrap_or("")
475        .to_string();
476    let mut data: HashMap<String, serde_json::Value> = HashMap::new();
477    data.insert("raw".to_string(), raw.clone());
478    toolpath_convo::ConversationEvent {
479        id: format!("claude-preamble-{idx}"),
480        timestamp,
481        parent_id: None,
482        event_type,
483        data,
484    }
485}
486
487/// Build an event from a message-less ConversationEntry (attachment, snapshot).
488///
489/// Captures the entry's typed fields in `event.data` so the projector can
490/// reconstruct an equivalent entry. The flatten extras (e.g. an attachment's
491/// `attachment` payload) come along for the ride under `entry_extra`.
492fn entry_to_event(entry: &ConversationEntry) -> toolpath_convo::ConversationEvent {
493    let mut data = HashMap::new();
494    if let Some(v) = &entry.cwd {
495        data.insert(
496            crate::constants::CWD.into(),
497            serde_json::Value::String(v.clone()),
498        );
499    }
500    if let Some(v) = &entry.git_branch {
501        data.insert("git_branch".into(), serde_json::Value::String(v.clone()));
502    }
503    if let Some(v) = &entry.version {
504        data.insert("version".into(), serde_json::Value::String(v.clone()));
505    }
506    if let Some(v) = &entry.user_type {
507        data.insert("user_type".into(), serde_json::Value::String(v.clone()));
508    }
509    if let Some(v) = &entry.message_id {
510        data.insert("message_id".into(), serde_json::Value::String(v.clone()));
511    }
512    if let Some(v) = &entry.tool_use_result {
513        data.insert("tool_use_result".into(), v.clone());
514    }
515    if let Some(v) = &entry.snapshot {
516        data.insert("snapshot".into(), v.clone());
517    }
518    if !entry.extra.is_empty()
519        && let Ok(value) = serde_json::to_value(&entry.extra)
520    {
521        data.insert("entry_extra".into(), value);
522    }
523    toolpath_convo::ConversationEvent {
524        id: entry.uuid.clone(),
525        timestamp: entry.timestamp.clone(),
526        parent_id: entry.parent_uuid.clone(),
527        event_type: entry.entry_type.clone(),
528        data,
529    }
530}
531
532/// Field-wise maximum of two usage tuples. `None` is "absent", not 0, so a
533/// field present in only one operand survives.
534pub(crate) fn max_usage(a: &TokenUsage, b: &TokenUsage) -> TokenUsage {
535    fn m(x: Option<u32>, y: Option<u32>) -> Option<u32> {
536        match (x, y) {
537            (Some(a), Some(b)) => Some(a.max(b)),
538            (Some(v), None) | (None, Some(v)) => Some(v),
539            (None, None) => None,
540        }
541    }
542    TokenUsage {
543        input_tokens: m(a.input_tokens, b.input_tokens),
544        output_tokens: m(a.output_tokens, b.output_tokens),
545        cache_read_tokens: m(a.cache_read_tokens, b.cache_read_tokens),
546        cache_write_tokens: m(a.cache_write_tokens, b.cache_write_tokens),
547        ..Default::default()
548    }
549}
550
551/// Canonicalize message-level accounting for split messages.
552///
553/// Claude Code writes one JSONL line per content block of an assistant API
554/// message, each stamped with `message.usage`. That `usage` is a **streaming
555/// snapshot**, not a per-line bill: per the Anthropic streaming API,
556/// `message_start` seeds `output_tokens` near zero and each `message_delta`
557/// reports the running **cumulative** total, with the final value being the
558/// message total. So across a split message's lines, `input`/`cache` are
559/// constant and `output_tokens` climbs to the total on the final line —
560/// confirmed across every session sampled (~27% of multi-line messages vary;
561/// the rest repeat one value stamped after generation). The intermediate
562/// values are flush-time snapshots, **not** per-content-block costs (a real
563/// prose block routinely shows `output_tokens: 1`), so we do not derive
564/// per-step attribution from them, and — the format being undocumented — we
565/// do not trust line order.
566///
567/// For each consecutive `group_id` run this sets `token_usage` on the run's
568/// **final** turn to the field-wise **maximum** across the run (the message
569/// total — never under-counts whatever the stream order) and clears it from
570/// the others, so summing `token_usage` over turns yields session totals.
571fn canonicalize_message_usage(turns: &mut [Turn]) {
572    let mut i = 0;
573    while i < turns.len() {
574        let Some(mid) = turns[i].group_id.clone() else {
575            i += 1;
576            continue;
577        };
578        let mut j = i;
579        while j < turns.len() && turns[j].group_id.as_deref() == Some(mid.as_str()) {
580            j += 1;
581        }
582
583        // Message total = field-wise max across the run (the final streaming
584        // snapshot, found without trusting line order).
585        let mut total: Option<TokenUsage> = None;
586        for t in &turns[i..j] {
587            if let Some(u) = &t.token_usage {
588                total = Some(match total {
589                    Some(acc) => max_usage(&acc, u),
590                    None => u.clone(),
591                });
592            }
593        }
594
595        for t in &mut turns[i..j] {
596            t.token_usage = None;
597        }
598        if let Some(total) = total {
599            turns[j - 1].token_usage = Some(total);
600        }
601        i = j;
602    }
603}
604
605/// Sum token usage across all turns.
606fn sum_usage(turns: &[Turn]) -> Option<TokenUsage> {
607    let mut total = TokenUsage::default();
608    let mut any = false;
609    for (idx, turn) in turns.iter().enumerate() {
610        // Turns split from one provider message all repeat that message's
611        // usage; count it once, on the run's last turn.
612        if let Some(mid) = &turn.group_id
613            && turns
614                .get(idx + 1)
615                .is_some_and(|next| next.group_id.as_ref() == Some(mid))
616        {
617            continue;
618        }
619        if let Some(u) = &turn.token_usage {
620            any = true;
621            total.input_tokens =
622                Some(total.input_tokens.unwrap_or(0) + u.input_tokens.unwrap_or(0));
623            total.output_tokens =
624                Some(total.output_tokens.unwrap_or(0) + u.output_tokens.unwrap_or(0));
625            total.cache_read_tokens = match (total.cache_read_tokens, u.cache_read_tokens) {
626                (Some(a), Some(b)) => Some(a + b),
627                (Some(a), None) => Some(a),
628                (None, Some(b)) => Some(b),
629                (None, None) => None,
630            };
631            total.cache_write_tokens = match (total.cache_write_tokens, u.cache_write_tokens) {
632                (Some(a), Some(b)) => Some(a + b),
633                (Some(a), None) => Some(a),
634                (None, Some(b)) => Some(b),
635                (None, None) => None,
636            };
637        }
638    }
639    if any { Some(total) } else { None }
640}
641
642/// Extract deduplicated file paths from file-write tool invocations.
643fn extract_files_changed(turns: &[Turn]) -> Vec<String> {
644    let mut seen = std::collections::HashSet::new();
645    let mut files = Vec::new();
646    for turn in turns {
647        for tool_use in &turn.tool_uses {
648            if tool_use.category == Some(ToolCategory::FileWrite)
649                && let Some(path) = tool_use.input.get("file_path").and_then(|v| v.as_str())
650                && seen.insert(path.to_string())
651            {
652                files.push(path.to_string());
653            }
654        }
655    }
656    files
657}
658
659#[cfg(any(feature = "watcher", test))]
660fn entry_to_watcher_event(entry: &ConversationEntry) -> WatcherEvent {
661    match entry_to_turn(entry) {
662        Some(turn) => WatcherEvent::Turn(Box::new(turn)),
663        None => {
664            let mut data = serde_json::json!({
665                "uuid": entry.uuid,
666                "timestamp": entry.timestamp,
667            });
668            if !entry.extra.is_empty() {
669                data["claude"] = serde_json::to_value(&entry.extra).unwrap_or_default();
670            }
671            WatcherEvent::Progress {
672                kind: entry.entry_type.clone(),
673                data,
674            }
675        }
676    }
677}
678
679// ── ConversationProvider for ClaudeConvo ──────────────────────────────
680
681impl ConversationProvider for ClaudeConvo {
682    fn list_conversations(&self, project: &str) -> toolpath_convo::Result<Vec<String>> {
683        crate::ClaudeConvo::list_conversations(self, project)
684            .map_err(|e| ConvoError::Provider(e.to_string()))
685    }
686
687    fn load_conversation(
688        &self,
689        project: &str,
690        conversation_id: &str,
691    ) -> toolpath_convo::Result<ConversationView> {
692        let convo = self
693            .read_conversation(project, conversation_id)
694            .map_err(|e| ConvoError::Provider(e.to_string()))?;
695        let mut view = conversation_to_view(&convo);
696        view.session_ids = convo.segment_ids.clone();
697        Ok(view)
698    }
699
700    fn load_metadata(
701        &self,
702        project: &str,
703        conversation_id: &str,
704    ) -> toolpath_convo::Result<ConversationMeta> {
705        let meta = self
706            .read_conversation_metadata(project, conversation_id)
707            .map_err(|e| ConvoError::Provider(e.to_string()))?;
708
709        Ok(ConversationMeta {
710            id: meta.session_id,
711            started_at: meta.started_at,
712            last_activity: meta.last_activity,
713            message_count: meta.message_count,
714            file_path: Some(meta.file_path),
715            predecessor: None,
716            successor: None,
717        })
718    }
719
720    fn list_metadata(&self, project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
721        let metas = self
722            .list_conversation_metadata(project)
723            .map_err(|e| ConvoError::Provider(e.to_string()))?;
724
725        Ok(metas
726            .into_iter()
727            .map(|m| ConversationMeta {
728                id: m.session_id,
729                started_at: m.started_at,
730                last_activity: m.last_activity,
731                message_count: m.message_count,
732                file_path: Some(m.file_path),
733                predecessor: None,
734                successor: None,
735            })
736            .collect())
737    }
738}
739
740// ── ConversationWatcher with eager emit + TurnUpdated ────────────────
741
742#[cfg(feature = "watcher")]
743impl toolpath_convo::ConversationWatcher for crate::watcher::ConversationWatcher {
744    fn poll(&mut self) -> toolpath_convo::Result<Vec<WatcherEvent>> {
745        let entries = crate::watcher::ConversationWatcher::poll(self)
746            .map_err(|e| ConvoError::Provider(e.to_string()))?;
747
748        let mut events: Vec<WatcherEvent> = Vec::new();
749
750        // Check for session rotations and prepend Progress events
751        for (from, to) in self.take_pending_rotations() {
752            events.push(WatcherEvent::Progress {
753                kind: "session_rotated".into(),
754                data: serde_json::json!({
755                    "from": from,
756                    "to": to,
757                }),
758            });
759        }
760
761        for entry in &entries {
762            let Some(msg) = &entry.message else {
763                events.push(entry_to_watcher_event(entry));
764                continue;
765            };
766
767            if is_tool_result_only(entry) {
768                // Find matching turns in previously emitted events and in
769                // our assembled state, merge results, emit TurnUpdated.
770                // Walk events in reverse to find the turn to update.
771                let mut updated_turn: Option<Turn> = None;
772
773                // Search backwards through events emitted this poll cycle
774                for event in events.iter_mut().rev() {
775                    if let WatcherEvent::Turn(turn) | WatcherEvent::TurnUpdated(turn) = event
776                        && turn.tool_uses.iter().any(|tu| {
777                            tu.result.is_none()
778                                && msg.tool_results().iter().any(|tr| tr.tool_use_id == tu.id)
779                        })
780                    {
781                        // Merge results into this turn
782                        let mut updated = (**turn).clone();
783                        merge_tool_results(std::slice::from_mut(&mut updated), msg);
784                        updated_turn = Some(updated.clone());
785                        // Also update the existing event in-place so later
786                        // result entries can find the right state
787                        **turn = updated;
788                        break;
789                    }
790                }
791
792                if let Some(turn) = updated_turn {
793                    events.push(WatcherEvent::TurnUpdated(Box::new(turn)));
794                }
795                // If no matching turn found, the tool-result-only entry
796                // is silently dropped (the matching turn was emitted in a
797                // prior poll cycle and can't be updated from here).
798                continue;
799            }
800
801            events.push(entry_to_watcher_event(entry));
802        }
803
804        Ok(events)
805    }
806
807    fn seen_count(&self) -> usize {
808        crate::watcher::ConversationWatcher::seen_count(self)
809    }
810}
811
812// ── Public re-exports for convenience ────────────────────────────────
813
814/// Convert a Claude [`Conversation`] directly into a [`ConversationView`].
815///
816/// This performs cross-entry tool result assembly: tool-result-only user
817/// entries are merged into the preceding assistant turn rather than emitted
818/// as separate turns.
819pub fn to_view(convo: &Conversation) -> ConversationView {
820    conversation_to_view(convo)
821}
822
823/// Convert a single Claude [`ConversationEntry`] into a [`Turn`], if it
824/// contains a message.
825///
826/// Note: this does *not* perform cross-entry assembly. For assembled
827/// results, use [`to_view`] instead.
828pub fn to_turn(entry: &ConversationEntry) -> Option<Turn> {
829    entry_to_turn(entry)
830}
831
832// ── Tests ────────────────────────────────────────────────────────────
833
834#[cfg(test)]
835mod tests {
836    use super::*;
837    use crate::PathResolver;
838    use std::fs;
839    use tempfile::TempDir;
840
841    /// One assistant turn carrying a cumulative usage snapshot (only
842    /// output varies across a split, so input/cache are fixed here).
843    fn grp_turn(id: &str, mid: &str, output: u32) -> Turn {
844        let mut t = message_turn_stub(id);
845        t.group_id = Some(mid.into());
846        t.token_usage = Some(TokenUsage {
847            input_tokens: Some(6),
848            output_tokens: Some(output),
849            cache_read_tokens: Some(14_842),
850            cache_write_tokens: Some(429_831),
851            ..Default::default()
852        });
853        t
854    }
855
856    fn message_turn_stub(id: &str) -> Turn {
857        Turn {
858            id: id.into(),
859            parent_id: None,
860            group_id: None,
861            role: Role::Assistant,
862            timestamp: "2024-01-01T00:00:00Z".into(),
863            text: String::new(),
864            thinking: None,
865            tool_uses: vec![],
866            model: None,
867            stop_reason: None,
868            token_usage: None,
869            attributed_token_usage: None,
870            environment: None,
871            delegations: vec![],
872            file_mutations: vec![],
873        }
874    }
875
876    #[test]
877    fn canonicalize_streamed_group_keeps_total_only_on_final_turn() {
878        // Streaming snapshots climb 55 -> 164 across two lines of one
879        // message. The final turn carries the message total (the final
880        // snapshot); earlier turns carry nothing. The intermediate snapshot
881        // (55) is NOT per-block attribution — it's where generation happened
882        // to be when the line was flushed — so we never record it.
883        let mut turns = vec![grp_turn("t1", "msg_A", 55), grp_turn("t2", "msg_A", 164)];
884        canonicalize_message_usage(&mut turns);
885
886        assert!(turns[0].token_usage.is_none(), "total only on final turn");
887        assert_eq!(
888            turns[1].token_usage.as_ref().unwrap().output_tokens,
889            Some(164)
890        );
891        assert_eq!(turns[1].token_usage.as_ref().unwrap().input_tokens, Some(6));
892        for t in &turns {
893            assert!(
894                t.attributed_token_usage.is_none(),
895                "Claude per-line snapshots are not per-step attribution"
896            );
897        }
898    }
899
900    #[test]
901    fn canonicalize_does_not_trust_line_order() {
902        // Defensive: the complete total arrives FIRST (out of order). We
903        // must still report 164 as the message total — the field-wise max,
904        // not the last line's snapshot.
905        let mut turns = vec![grp_turn("t1", "msg_A", 164), grp_turn("t2", "msg_A", 55)];
906        canonicalize_message_usage(&mut turns);
907
908        assert_eq!(
909            turns[1].token_usage.as_ref().unwrap().output_tokens,
910            Some(164),
911            "field-wise max, not the last line"
912        );
913    }
914
915    #[test]
916    fn canonicalize_collapses_repeated_total_to_one_turn() {
917        // Byte-identical lines (the ~73% case): the total lands once, on the
918        // final turn; no attribution either way.
919        let mut turns = vec![
920            grp_turn("t1", "msg_A", 997),
921            grp_turn("t2", "msg_A", 997),
922            grp_turn("t3", "msg_A", 997),
923        ];
924        canonicalize_message_usage(&mut turns);
925
926        assert!(turns[0].token_usage.is_none());
927        assert!(turns[1].token_usage.is_none());
928        assert_eq!(
929            turns[2].token_usage.as_ref().unwrap().output_tokens,
930            Some(997)
931        );
932        for t in &turns {
933            assert!(t.attributed_token_usage.is_none());
934        }
935    }
936
937    fn setup_provider() -> (TempDir, ClaudeConvo) {
938        let temp = TempDir::new().unwrap();
939        let claude_dir = temp.path().join(".claude");
940        let project_dir = claude_dir.join("projects/-test-project");
941        fs::create_dir_all(&project_dir).unwrap();
942
943        let entries = [
944            r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Fix the bug"}}"#,
945            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}}}"#,
946            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}]}}"#,
947            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}}}"#,
948            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}]}}"#,
949            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"}}"#,
950            r#"{"uuid":"uuid-7","type":"user","parentUuid":"uuid-6","timestamp":"2024-01-01T00:00:06Z","message":{"role":"user","content":"Thanks!"}}"#,
951        ];
952        fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap();
953
954        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
955        (temp, ClaudeConvo::with_resolver(resolver))
956    }
957
958    /// A session whose first assistant API message is split across three
959    /// JSONL lines (text, then one per tool_use) — the on-disk shape Claude
960    /// Code writes. Each line repeats the same `message.id` and the full
961    /// message-level `usage`, followed by a singleton assistant message.
962    fn setup_split_message_provider() -> (TempDir, ClaudeConvo) {
963        let temp = TempDir::new().unwrap();
964        let claude_dir = temp.path().join(".claude");
965        let project_dir = claude_dir.join("projects/-test-project");
966        fs::create_dir_all(&project_dir).unwrap();
967
968        let usage_a = r#"{"input_tokens":6,"output_tokens":997,"cache_read_input_tokens":14842,"cache_creation_input_tokens":429831}"#;
969        let entries = [
970            r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Fix the bug"}}"#.to_string(),
971            format!(
972                r#"{{"uuid":"uuid-2","type":"assistant","parentUuid":"uuid-1","timestamp":"2024-01-01T00:00:01Z","message":{{"id":"msg_A","role":"assistant","content":[{{"type":"text","text":"Working on it."}}],"model":"claude-opus-4-7","stop_reason":null,"usage":{usage_a}}}}}"#
973            ),
974            format!(
975                r#"{{"uuid":"uuid-3","type":"assistant","parentUuid":"uuid-2","timestamp":"2024-01-01T00:00:02Z","message":{{"id":"msg_A","role":"assistant","content":[{{"type":"tool_use","id":"t1","name":"Read","input":{{"file_path":"a.rs"}}}}],"model":"claude-opus-4-7","stop_reason":null,"usage":{usage_a}}}}}"#
976            ),
977            format!(
978                r#"{{"uuid":"uuid-4","type":"assistant","parentUuid":"uuid-3","timestamp":"2024-01-01T00:00:03Z","message":{{"id":"msg_A","role":"assistant","content":[{{"type":"tool_use","id":"t2","name":"Read","input":{{"file_path":"b.rs"}}}}],"model":"claude-opus-4-7","stop_reason":"tool_use","usage":{usage_a}}}}}"#
979            ),
980            r#"{"uuid":"uuid-5","type":"assistant","parentUuid":"uuid-4","timestamp":"2024-01-01T00:00:04Z","message":{"id":"msg_B","role":"assistant","content":[{"type":"text","text":"Done."}],"model":"claude-opus-4-7","stop_reason":"end_turn","usage":{"input_tokens":5,"output_tokens":11}}}"#.to_string(),
981        ];
982        fs::write(project_dir.join("session-2.jsonl"), entries.join("\n")).unwrap();
983
984        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
985        (temp, ClaudeConvo::with_resolver(resolver))
986    }
987
988    #[test]
989    fn test_split_message_turns_share_group_id() {
990        let (_temp, provider) = setup_split_message_provider();
991        let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-2")
992            .unwrap();
993
994        assert_eq!(view.turns.len(), 5);
995        assert!(view.turns[0].group_id.is_none(), "user lines carry no ID");
996        for turn in &view.turns[1..=3] {
997            assert_eq!(turn.group_id.as_deref(), Some("msg_A"));
998        }
999        assert_eq!(view.turns[4].group_id.as_deref(), Some("msg_B"));
1000    }
1001
1002    #[test]
1003    fn test_view_usage_is_canonical_total_on_group_final_turn() {
1004        // IR contract: `Turn.token_usage` always means "the message's
1005        // total" and appears only on the message's final turn. The wire
1006        // repeats the total on every line of a split; the view must not.
1007        let (_temp, provider) = setup_split_message_provider();
1008        let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-2")
1009            .unwrap();
1010
1011        assert!(view.turns[1].token_usage.is_none());
1012        assert!(view.turns[2].token_usage.is_none());
1013        assert_eq!(
1014            view.turns[3].token_usage.as_ref().unwrap().output_tokens,
1015            Some(997)
1016        );
1017        assert_eq!(
1018            view.turns[4].token_usage.as_ref().unwrap().output_tokens,
1019            Some(11)
1020        );
1021    }
1022
1023    #[test]
1024    fn test_total_usage_counts_each_message_once() {
1025        let (_temp, provider) = setup_split_message_provider();
1026        let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-2")
1027            .unwrap();
1028
1029        // msg_A's usage appears on three lines but is one API message;
1030        // totals must be msg_A + msg_B, not 3×msg_A + msg_B.
1031        let total = view.total_usage.as_ref().unwrap();
1032        assert_eq!(total.output_tokens, Some(997 + 11));
1033        assert_eq!(total.input_tokens, Some(6 + 5));
1034        assert_eq!(total.cache_read_tokens, Some(14_842));
1035        assert_eq!(total.cache_write_tokens, Some(429_831));
1036    }
1037
1038    #[test]
1039    fn test_load_conversation_assembles_tool_results() {
1040        let (_temp, provider) = setup_provider();
1041        let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1042            .unwrap();
1043
1044        assert_eq!(view.id, "session-1");
1045        // 7 entries collapse to 5 turns (2 tool-result-only entries absorbed)
1046        assert_eq!(view.turns.len(), 5);
1047
1048        // Turn 0: user "Fix the bug"
1049        assert_eq!(view.turns[0].role, Role::User);
1050        assert_eq!(view.turns[0].text, "Fix the bug");
1051        assert!(view.turns[0].parent_id.is_none());
1052
1053        // Turn 1: assistant with tool use + assembled result
1054        assert_eq!(view.turns[1].role, Role::Assistant);
1055        assert_eq!(view.turns[1].text, "I'll fix that.");
1056        assert_eq!(
1057            view.turns[1].thinking.as_deref(),
1058            Some("The bug is in auth")
1059        );
1060        assert_eq!(view.turns[1].tool_uses.len(), 1);
1061        assert_eq!(view.turns[1].tool_uses[0].name, "Read");
1062        assert_eq!(view.turns[1].tool_uses[0].id, "t1");
1063        // Key assertion: result is populated from the next entry
1064        let result = view.turns[1].tool_uses[0].result.as_ref().unwrap();
1065        assert!(!result.is_error);
1066        assert!(result.content.contains("fn main()"));
1067        assert_eq!(view.turns[1].model.as_deref(), Some("claude-opus-4-6"));
1068        assert_eq!(view.turns[1].stop_reason.as_deref(), Some("tool_use"));
1069        assert_eq!(view.turns[1].parent_id.as_deref(), Some("uuid-1"));
1070
1071        // Token usage
1072        let usage = view.turns[1].token_usage.as_ref().unwrap();
1073        assert_eq!(usage.input_tokens, Some(100));
1074        assert_eq!(usage.output_tokens, Some(50));
1075
1076        // Turn 2: second assistant with tool use + assembled result
1077        assert_eq!(view.turns[2].role, Role::Assistant);
1078        assert_eq!(view.turns[2].text, "I see the issue. Let me fix it.");
1079        assert_eq!(view.turns[2].tool_uses[0].name, "Edit");
1080        let result2 = view.turns[2].tool_uses[0].result.as_ref().unwrap();
1081        assert_eq!(result2.content, "File written successfully");
1082
1083        // Turn 3: final assistant (no tools)
1084        assert_eq!(view.turns[3].role, Role::Assistant);
1085        assert_eq!(view.turns[3].text, "Done! The bug is fixed.");
1086        assert!(view.turns[3].tool_uses.is_empty());
1087
1088        // Turn 4: user "Thanks!"
1089        assert_eq!(view.turns[4].role, Role::User);
1090        assert_eq!(view.turns[4].text, "Thanks!");
1091    }
1092
1093    #[test]
1094    fn test_no_phantom_empty_turns() {
1095        let (_temp, provider) = setup_provider();
1096        let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1097            .unwrap();
1098
1099        // No turns should have empty text with User role (phantom turns)
1100        for turn in &view.turns {
1101            if turn.role == Role::User {
1102                assert!(
1103                    !turn.text.is_empty(),
1104                    "Found phantom empty user turn: {:?}",
1105                    turn.id
1106                );
1107            }
1108        }
1109    }
1110
1111    #[test]
1112    fn test_tool_result_error_flag() {
1113        let temp = TempDir::new().unwrap();
1114        let claude_dir = temp.path().join(".claude");
1115        let project_dir = claude_dir.join("projects/-test-project");
1116        fs::create_dir_all(&project_dir).unwrap();
1117
1118        let entries = [
1119            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read a file"}}"#,
1120            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"}}"#,
1121            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}]}}"#,
1122        ];
1123        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1124
1125        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1126        let provider = ClaudeConvo::with_resolver(resolver);
1127        let view =
1128            ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1129
1130        assert_eq!(view.turns.len(), 2); // user + assistant (tool-result absorbed)
1131        let result = view.turns[1].tool_uses[0].result.as_ref().unwrap();
1132        assert!(result.is_error);
1133        assert_eq!(result.content, "File not found");
1134    }
1135
1136    #[test]
1137    fn test_multiple_tool_uses_single_result_entry() {
1138        let temp = TempDir::new().unwrap();
1139        let claude_dir = temp.path().join(".claude");
1140        let project_dir = claude_dir.join("projects/-test-project");
1141        fs::create_dir_all(&project_dir).unwrap();
1142
1143        let entries = [
1144            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Check two files"}}"#,
1145            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"}}]}}"#,
1146            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}]}}"#,
1147        ];
1148        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1149
1150        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1151        let provider = ClaudeConvo::with_resolver(resolver);
1152        let view =
1153            ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1154
1155        assert_eq!(view.turns.len(), 2);
1156        assert_eq!(view.turns[1].tool_uses.len(), 2);
1157
1158        let r1 = view.turns[1].tool_uses[0].result.as_ref().unwrap();
1159        assert_eq!(r1.content, "file a contents");
1160
1161        let r2 = view.turns[1].tool_uses[1].result.as_ref().unwrap();
1162        assert_eq!(r2.content, "file b contents");
1163    }
1164
1165    #[test]
1166    fn test_conversation_without_tool_use_unchanged() {
1167        let temp = TempDir::new().unwrap();
1168        let claude_dir = temp.path().join(".claude");
1169        let project_dir = claude_dir.join("projects/-test-project");
1170        fs::create_dir_all(&project_dir).unwrap();
1171
1172        let entries = [
1173            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
1174            r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi there!"}}"#,
1175        ];
1176        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1177
1178        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1179        let provider = ClaudeConvo::with_resolver(resolver);
1180        let view =
1181            ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1182
1183        assert_eq!(view.turns.len(), 2);
1184        assert_eq!(view.turns[0].text, "Hello");
1185        assert_eq!(view.turns[1].text, "Hi there!");
1186    }
1187
1188    #[test]
1189    fn test_assistant_turn_without_result_has_none() {
1190        // Tool use at end of conversation with no result entry
1191        let temp = TempDir::new().unwrap();
1192        let claude_dir = temp.path().join(".claude");
1193        let project_dir = claude_dir.join("projects/-test-project");
1194        fs::create_dir_all(&project_dir).unwrap();
1195
1196        let entries = [
1197            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read a file"}}"#,
1198            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"}}]}}"#,
1199        ];
1200        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1201
1202        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1203        let provider = ClaudeConvo::with_resolver(resolver);
1204        let view =
1205            ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1206
1207        assert_eq!(view.turns.len(), 2);
1208        assert!(view.turns[1].tool_uses[0].result.is_none());
1209    }
1210
1211    #[test]
1212    fn test_list_conversations() {
1213        let (_temp, provider) = setup_provider();
1214        let ids = ConversationProvider::list_conversations(&provider, "/test/project").unwrap();
1215        assert_eq!(ids, vec!["session-1"]);
1216    }
1217
1218    #[test]
1219    fn test_load_metadata() {
1220        let (_temp, provider) = setup_provider();
1221        let meta =
1222            ConversationProvider::load_metadata(&provider, "/test/project", "session-1").unwrap();
1223        assert_eq!(meta.id, "session-1");
1224        assert_eq!(meta.message_count, 7);
1225        assert!(meta.file_path.is_some());
1226    }
1227
1228    #[test]
1229    fn test_list_metadata() {
1230        let (_temp, provider) = setup_provider();
1231        let metas = ConversationProvider::list_metadata(&provider, "/test/project").unwrap();
1232        assert_eq!(metas.len(), 1);
1233        assert_eq!(metas[0].id, "session-1");
1234    }
1235
1236    #[test]
1237    fn test_to_view() {
1238        let (_temp, manager) = setup_provider();
1239        let convo = manager
1240            .read_conversation("/test/project", "session-1")
1241            .unwrap();
1242        let view = to_view(&convo);
1243        assert_eq!(view.turns.len(), 5);
1244        assert_eq!(view.title(20).unwrap(), "Fix the bug");
1245    }
1246
1247    #[test]
1248    fn test_to_turn_with_message() {
1249        let entry: ConversationEntry = serde_json::from_str(
1250            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
1251        )
1252        .unwrap();
1253        let turn = to_turn(&entry).unwrap();
1254        assert_eq!(turn.id, "u1");
1255        assert_eq!(turn.text, "hello");
1256        assert_eq!(turn.role, Role::User);
1257    }
1258
1259    #[test]
1260    fn test_to_turn_without_message() {
1261        let entry: ConversationEntry = serde_json::from_str(
1262            r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
1263        )
1264        .unwrap();
1265        assert!(to_turn(&entry).is_none());
1266    }
1267
1268    #[test]
1269    fn test_entry_to_watcher_event_turn() {
1270        let entry: ConversationEntry = serde_json::from_str(
1271            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hi"}}"#,
1272        )
1273        .unwrap();
1274        let event = entry_to_watcher_event(&entry);
1275        assert!(matches!(event, WatcherEvent::Turn(_)));
1276    }
1277
1278    #[test]
1279    fn test_entry_to_watcher_event_progress() {
1280        let entry: ConversationEntry = serde_json::from_str(
1281            r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
1282        )
1283        .unwrap();
1284        let event = entry_to_watcher_event(&entry);
1285        assert!(matches!(event, WatcherEvent::Progress { .. }));
1286    }
1287
1288    #[cfg(feature = "watcher")]
1289    #[test]
1290    fn test_watcher_trait_basic() {
1291        let temp = TempDir::new().unwrap();
1292        let claude_dir = temp.path().join(".claude");
1293        let project_dir = claude_dir.join("projects/-test-project");
1294        fs::create_dir_all(&project_dir).unwrap();
1295
1296        let entries = [
1297            r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
1298            r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
1299        ];
1300        fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap();
1301
1302        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1303        let manager = ClaudeConvo::with_resolver(resolver);
1304
1305        let mut watcher = crate::watcher::ConversationWatcher::new(
1306            manager,
1307            "/test/project".to_string(),
1308            "session-1".to_string(),
1309        );
1310
1311        // Use the trait explicitly (inherent poll returns ConversationEntry)
1312        let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1313        assert_eq!(events.len(), 2);
1314        assert!(matches!(&events[0], WatcherEvent::Turn(t) if t.role == Role::User));
1315        assert!(matches!(&events[1], WatcherEvent::Turn(t) if t.role == Role::Assistant));
1316        assert_eq!(toolpath_convo::ConversationWatcher::seen_count(&watcher), 2);
1317
1318        // Second poll returns nothing
1319        let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1320        assert!(events.is_empty());
1321    }
1322
1323    #[cfg(feature = "watcher")]
1324    #[test]
1325    fn test_watcher_trait_assembles_tool_results() {
1326        let temp = TempDir::new().unwrap();
1327        let claude_dir = temp.path().join(".claude");
1328        let project_dir = claude_dir.join("projects/-test-project");
1329        fs::create_dir_all(&project_dir).unwrap();
1330
1331        let entries = [
1332            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read the file"}}"#,
1333            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"}}]}}"#,
1334            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}]}}"#,
1335            r#"{"uuid":"u4","type":"assistant","timestamp":"2024-01-01T00:00:03Z","message":{"role":"assistant","content":"Done!"}}"#,
1336        ];
1337        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1338
1339        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1340        let manager = ClaudeConvo::with_resolver(resolver);
1341
1342        let mut watcher = crate::watcher::ConversationWatcher::new(
1343            manager,
1344            "/test/project".to_string(),
1345            "s1".to_string(),
1346        );
1347
1348        let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1349
1350        // Should get: Turn(user), Turn(assistant), TurnUpdated(assistant), Turn(assistant)
1351        assert_eq!(events.len(), 4);
1352
1353        // First: user turn
1354        assert!(matches!(&events[0], WatcherEvent::Turn(t) if t.role == Role::User));
1355
1356        // Second: assistant turn emitted eagerly (result may not be populated yet in the event)
1357        assert!(matches!(&events[1], WatcherEvent::Turn(t) if t.role == Role::Assistant));
1358
1359        // Third: TurnUpdated with results merged
1360        match &events[2] {
1361            WatcherEvent::TurnUpdated(turn) => {
1362                assert_eq!(turn.id, "u2");
1363                assert_eq!(turn.tool_uses.len(), 1);
1364                let result = turn.tool_uses[0].result.as_ref().unwrap();
1365                assert_eq!(result.content, "fn main() {}");
1366                assert!(!result.is_error);
1367            }
1368            other => panic!("Expected TurnUpdated, got {:?}", other),
1369        }
1370
1371        // Fourth: final assistant turn
1372        assert!(matches!(&events[3], WatcherEvent::Turn(t) if t.text == "Done!"));
1373    }
1374
1375    #[cfg(feature = "watcher")]
1376    #[test]
1377    fn test_watcher_trait_incremental_tool_results() {
1378        // Simulate tool results arriving in a different poll cycle than the tool use
1379        let temp = TempDir::new().unwrap();
1380        let claude_dir = temp.path().join(".claude");
1381        let project_dir = claude_dir.join("projects/-test-project");
1382        fs::create_dir_all(&project_dir).unwrap();
1383
1384        // Start with just the user message and assistant tool use
1385        let entries_phase1 = [
1386            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read file"}}"#,
1387            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"}}]}}"#,
1388        ];
1389        fs::write(
1390            project_dir.join("s1.jsonl"),
1391            entries_phase1.join("\n") + "\n",
1392        )
1393        .unwrap();
1394
1395        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1396        let manager = ClaudeConvo::with_resolver(resolver);
1397
1398        let mut watcher = crate::watcher::ConversationWatcher::new(
1399            manager,
1400            "/test/project".to_string(),
1401            "s1".to_string(),
1402        );
1403
1404        // First poll: get user + assistant turns
1405        let events1 = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1406        assert_eq!(events1.len(), 2);
1407        // Assistant turn emitted eagerly with result: None
1408        if let WatcherEvent::Turn(t) = &events1[1] {
1409            assert!(t.tool_uses[0].result.is_none());
1410        } else {
1411            panic!("Expected Turn");
1412        }
1413
1414        // Now append the tool result entry
1415        use std::io::Write;
1416        let mut file = fs::OpenOptions::new()
1417            .append(true)
1418            .open(project_dir.join("s1.jsonl"))
1419            .unwrap();
1420        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();
1421
1422        // Second poll: tool-result-only entry arrives
1423        let events2 = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1424        // The tool-result-only entry can't find its matching turn in this poll
1425        // cycle (it was emitted in the previous one), so it's silently absorbed.
1426        // This is a known limitation of the eager-emit approach for cross-poll
1427        // boundaries — the batch path (to_view) handles this correctly.
1428        // Consumers needing full fidelity across poll boundaries should
1429        // periodically do a full load_conversation.
1430        assert!(events2.is_empty() || events2.iter().all(|e| !matches!(e, WatcherEvent::Turn(_))));
1431    }
1432
1433    #[test]
1434    fn test_merge_tool_results_by_id() {
1435        // Verify that merge matches by tool_use_id, not position
1436        let mut turns = vec![Turn {
1437            id: "t1".into(),
1438            parent_id: None,
1439            group_id: None,
1440            role: Role::Assistant,
1441            timestamp: "2024-01-01T00:00:00Z".into(),
1442            text: "test".into(),
1443            thinking: None,
1444            tool_uses: vec![
1445                ToolInvocation {
1446                    id: "tool-a".into(),
1447                    name: "Read".into(),
1448                    input: serde_json::json!({}),
1449                    result: None,
1450                    category: Some(ToolCategory::FileRead),
1451                },
1452                ToolInvocation {
1453                    id: "tool-b".into(),
1454                    name: "Write".into(),
1455                    input: serde_json::json!({}),
1456                    result: None,
1457                    category: Some(ToolCategory::FileWrite),
1458                },
1459            ],
1460            model: None,
1461            stop_reason: None,
1462            token_usage: None,
1463            attributed_token_usage: None,
1464            environment: None,
1465            delegations: vec![],
1466            file_mutations: Vec::new(),
1467        }];
1468
1469        // Create a message with results in reversed order
1470        let msg: Message = serde_json::from_str(
1471            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}]}"#,
1472        )
1473        .unwrap();
1474
1475        let merged = merge_tool_results(&mut turns, &msg);
1476        assert!(merged);
1477
1478        // Results should match by ID regardless of order
1479        assert_eq!(
1480            turns[0].tool_uses[0].result.as_ref().unwrap().content,
1481            "read result"
1482        );
1483        assert!(turns[0].tool_uses[0].result.as_ref().unwrap().is_error);
1484
1485        assert_eq!(
1486            turns[0].tool_uses[1].result.as_ref().unwrap().content,
1487            "write result"
1488        );
1489        assert!(!turns[0].tool_uses[1].result.as_ref().unwrap().is_error);
1490    }
1491
1492    #[test]
1493    fn test_is_tool_result_only() {
1494        // Tool-result-only entry
1495        let entry: ConversationEntry = serde_json::from_str(
1496            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}]}}"#,
1497        )
1498        .unwrap();
1499        assert!(is_tool_result_only(&entry));
1500
1501        // Regular user entry with text
1502        let entry: ConversationEntry = serde_json::from_str(
1503            r#"{"uuid":"u2","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
1504        )
1505        .unwrap();
1506        assert!(!is_tool_result_only(&entry));
1507
1508        // Entry without message
1509        let entry: ConversationEntry = serde_json::from_str(
1510            r#"{"uuid":"u3","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
1511        )
1512        .unwrap();
1513        assert!(!is_tool_result_only(&entry));
1514
1515        // Assistant entry (never tool-result-only)
1516        let entry: ConversationEntry = serde_json::from_str(
1517            r#"{"uuid":"u4","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"hi"}}"#,
1518        )
1519        .unwrap();
1520        assert!(!is_tool_result_only(&entry));
1521    }
1522
1523    // ── New enrichment tests ─────────────────────────────────────────
1524
1525    #[test]
1526    fn test_tool_category_mapping() {
1527        assert_eq!(tool_category("Read"), Some(ToolCategory::FileRead));
1528        assert_eq!(tool_category("Glob"), Some(ToolCategory::FileSearch));
1529        assert_eq!(tool_category("Grep"), Some(ToolCategory::FileSearch));
1530        assert_eq!(tool_category("Write"), Some(ToolCategory::FileWrite));
1531        assert_eq!(tool_category("Edit"), Some(ToolCategory::FileWrite));
1532        assert_eq!(tool_category("NotebookEdit"), Some(ToolCategory::FileWrite));
1533        assert_eq!(tool_category("Bash"), Some(ToolCategory::Shell));
1534        assert_eq!(tool_category("WebFetch"), Some(ToolCategory::Network));
1535        assert_eq!(tool_category("WebSearch"), Some(ToolCategory::Network));
1536        assert_eq!(tool_category("Task"), Some(ToolCategory::Delegation));
1537        assert_eq!(tool_category("UnknownTool"), None);
1538    }
1539
1540    #[test]
1541    fn test_turn_has_tool_category() {
1542        let (_temp, provider) = setup_provider();
1543        let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1544            .unwrap();
1545
1546        // Turn 1 (assistant) has a Read tool
1547        assert_eq!(
1548            view.turns[1].tool_uses[0].category,
1549            Some(ToolCategory::FileRead)
1550        );
1551        // Turn 2 (assistant) has an Edit tool
1552        assert_eq!(
1553            view.turns[2].tool_uses[0].category,
1554            Some(ToolCategory::FileWrite)
1555        );
1556    }
1557
1558    #[test]
1559    fn test_environment_populated_from_entry() {
1560        let temp = TempDir::new().unwrap();
1561        let claude_dir = temp.path().join(".claude");
1562        let project_dir = claude_dir.join("projects/-test-project");
1563        fs::create_dir_all(&project_dir).unwrap();
1564
1565        let entries = [
1566            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","cwd":"/project/path","gitBranch":"feat/auth","message":{"role":"user","content":"Hello"}}"#,
1567            r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
1568        ];
1569        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1570
1571        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1572        let provider = ClaudeConvo::with_resolver(resolver);
1573        let view =
1574            ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1575
1576        // User turn has environment (entry has cwd and gitBranch)
1577        let env = view.turns[0].environment.as_ref().unwrap();
1578        assert_eq!(env.working_dir.as_deref(), Some("/project/path"));
1579        assert_eq!(env.vcs_branch.as_deref(), Some("feat/auth"));
1580        assert!(env.vcs_revision.is_none());
1581
1582        // Assistant turn has no environment (entry has no cwd/gitBranch)
1583        assert!(view.turns[1].environment.is_none());
1584    }
1585
1586    #[test]
1587    fn test_cache_tokens_populated() {
1588        let temp = TempDir::new().unwrap();
1589        let claude_dir = temp.path().join(".claude");
1590        let project_dir = claude_dir.join("projects/-test-project");
1591        fs::create_dir_all(&project_dir).unwrap();
1592
1593        let entries = [
1594            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
1595            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}}}"#,
1596        ];
1597        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1598
1599        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1600        let provider = ClaudeConvo::with_resolver(resolver);
1601        let view =
1602            ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1603
1604        let usage = view.turns[1].token_usage.as_ref().unwrap();
1605        assert_eq!(usage.cache_read_tokens, Some(500));
1606        assert_eq!(usage.cache_write_tokens, Some(200));
1607    }
1608
1609    #[test]
1610    fn test_total_usage_aggregated() {
1611        let (_temp, provider) = setup_provider();
1612        let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1613            .unwrap();
1614
1615        let total = view.total_usage.as_ref().unwrap();
1616        // Two assistant turns with usage: (100, 50) and (200, 100)
1617        assert_eq!(total.input_tokens, Some(300));
1618        assert_eq!(total.output_tokens, Some(150));
1619    }
1620
1621    #[test]
1622    fn test_provider_id_set() {
1623        let (_temp, provider) = setup_provider();
1624        let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1625            .unwrap();
1626
1627        assert_eq!(view.provider_id.as_deref(), Some("claude-code"));
1628    }
1629
1630    #[test]
1631    fn test_files_changed_populated() {
1632        let temp = TempDir::new().unwrap();
1633        let claude_dir = temp.path().join(".claude");
1634        let project_dir = claude_dir.join("projects/-test-project");
1635        fs::create_dir_all(&project_dir).unwrap();
1636
1637        let entries = [
1638            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Edit files"}}"#,
1639            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"}}]}}"#,
1640            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}]}}"#,
1641            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"}}]}}"#,
1642        ];
1643        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1644
1645        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1646        let provider = ClaudeConvo::with_resolver(resolver);
1647        let view =
1648            ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1649
1650        // Deduplicated, first-touch order: src/main.rs first, then src/lib.rs
1651        assert_eq!(view.files_changed, vec!["src/main.rs", "src/lib.rs"]);
1652    }
1653
1654    #[test]
1655    fn test_delegations_extracted() {
1656        let temp = TempDir::new().unwrap();
1657        let claude_dir = temp.path().join(".claude");
1658        let project_dir = claude_dir.join("projects/-test-project");
1659        fs::create_dir_all(&project_dir).unwrap();
1660
1661        let entries = [
1662            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Search for bugs"}}"#,
1663            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"}}]}}"#,
1664            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}]}}"#,
1665        ];
1666        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
1667
1668        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1669        let provider = ClaudeConvo::with_resolver(resolver);
1670        let view =
1671            ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
1672
1673        // Assistant turn should have one delegation
1674        assert_eq!(view.turns[1].delegations.len(), 1);
1675        let d = &view.turns[1].delegations[0];
1676        assert_eq!(d.agent_id, "task-1");
1677        assert_eq!(d.prompt, "Find the authentication bug");
1678        assert!(d.turns.is_empty()); // Sub-agent turns are in separate files
1679        // Result gets populated from tool result assembly
1680        assert_eq!(
1681            d.result.as_deref(),
1682            Some("Found the bug in auth.rs line 42")
1683        );
1684    }
1685
1686    #[test]
1687    fn test_progress_data_enriched_with_extras() {
1688        let entry: ConversationEntry = serde_json::from_str(
1689            r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z","data":{"type":"hook_progress","hookName":"pre-commit"}}"#,
1690        )
1691        .unwrap();
1692        let event = entry_to_watcher_event(&entry);
1693        match event {
1694            WatcherEvent::Progress { kind, data } => {
1695                assert_eq!(kind, "progress");
1696                assert_eq!(data["uuid"], "u1");
1697                assert_eq!(data["timestamp"], "2024-01-01T00:00:00Z");
1698                let claude = &data["claude"];
1699                assert_eq!(claude["data"]["type"], "hook_progress");
1700                assert_eq!(claude["data"]["hookName"], "pre-commit");
1701            }
1702            other => panic!(
1703                "Expected Progress, got {:?}",
1704                std::mem::discriminant(&other)
1705            ),
1706        }
1707    }
1708
1709    #[test]
1710    fn test_progress_data_no_claude_key_when_no_extras() {
1711        let entry: ConversationEntry = serde_json::from_str(
1712            r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
1713        )
1714        .unwrap();
1715        let event = entry_to_watcher_event(&entry);
1716        match event {
1717            WatcherEvent::Progress { data, .. } => {
1718                assert!(data.get("claude").is_none());
1719            }
1720            other => panic!(
1721                "Expected Progress, got {:?}",
1722                std::mem::discriminant(&other)
1723            ),
1724        }
1725    }
1726
1727    #[test]
1728    fn test_no_delegations_for_non_task_tools() {
1729        let (_temp, provider) = setup_provider();
1730        let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
1731            .unwrap();
1732
1733        // No turns should have delegations (none use Task tool)
1734        for turn in &view.turns {
1735            assert!(turn.delegations.is_empty());
1736        }
1737    }
1738
1739    // ── Session chain tests ─────────────────────────────────────────
1740
1741    fn setup_chained_provider() -> (TempDir, ClaudeConvo) {
1742        let temp = TempDir::new().unwrap();
1743        let claude_dir = temp.path().join(".claude");
1744        let project_dir = claude_dir.join("projects/-test-project");
1745        fs::create_dir_all(&project_dir).unwrap();
1746
1747        // Session A: original conversation
1748        let entries_a = [
1749            r#"{"uuid":"a1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Fix the bug"}}"#,
1750            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}}}"#,
1751        ];
1752        fs::write(project_dir.join("session-a.jsonl"), entries_a.join("\n")).unwrap();
1753
1754        // Session B: continuation with bridge entry
1755        let entries_b = [
1756            // Bridge entry: session_id points back to session-a
1757            r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Continue the fix"}}"#,
1758            // Real entries in session-b
1759            r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"What about the tests?"}}"#,
1760            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}}}"#,
1761        ];
1762        fs::write(project_dir.join("session-b.jsonl"), entries_b.join("\n")).unwrap();
1763
1764        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1765        (temp, ClaudeConvo::with_resolver(resolver))
1766    }
1767
1768    #[test]
1769    fn test_load_conversation_merges_chain() {
1770        let (_temp, provider) = setup_chained_provider();
1771
1772        // Load from session-a — should merge with session-b
1773        let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-a")
1774            .unwrap();
1775
1776        // Should have turns from both segments (minus the bridge entry)
1777        // session-a: a1 (user), a2 (assistant)
1778        // session-b: b1 (user), b2 (assistant) — b0 is bridge, filtered
1779        assert_eq!(view.turns.len(), 4);
1780        assert_eq!(view.turns[0].text, "Fix the bug");
1781        assert_eq!(view.turns[1].text, "I'll fix that.");
1782        assert_eq!(view.turns[2].text, "What about the tests?");
1783        assert_eq!(view.turns[3].text, "Tests pass now.");
1784
1785        // Session IDs should be set
1786        assert_eq!(view.session_ids, vec!["session-a", "session-b"]);
1787    }
1788
1789    #[test]
1790    fn test_load_conversation_skips_bridge_entries() {
1791        let (_temp, provider) = setup_chained_provider();
1792
1793        let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-a")
1794            .unwrap();
1795
1796        // Bridge entry text "Continue the fix" should NOT appear
1797        for turn in &view.turns {
1798            assert_ne!(turn.text, "Continue the fix");
1799        }
1800    }
1801
1802    #[test]
1803    fn test_load_conversation_single_segment_unchanged() {
1804        let temp = TempDir::new().unwrap();
1805        let claude_dir = temp.path().join(".claude");
1806        let project_dir = claude_dir.join("projects/-test-project");
1807        fs::create_dir_all(&project_dir).unwrap();
1808
1809        let entries = [
1810            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"solo","message":{"role":"user","content":"Hello"}}"#,
1811            r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","sessionId":"solo","message":{"role":"assistant","content":"Hi there!"}}"#,
1812        ];
1813        fs::write(project_dir.join("solo.jsonl"), entries.join("\n")).unwrap();
1814
1815        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1816        let provider = ClaudeConvo::with_resolver(resolver);
1817        let view =
1818            ConversationProvider::load_conversation(&provider, "/test/project", "solo").unwrap();
1819
1820        assert_eq!(view.turns.len(), 2);
1821        assert_eq!(view.turns[0].text, "Hello");
1822        assert_eq!(view.turns[1].text, "Hi there!");
1823        // Single segment — session_ids should be empty
1824        assert!(view.session_ids.is_empty());
1825    }
1826
1827    #[test]
1828    fn test_list_metadata_chain_transparent() {
1829        let (_temp, provider) = setup_chained_provider();
1830
1831        let metas = ConversationProvider::list_metadata(&provider, "/test/project").unwrap();
1832
1833        // Chain-default: only the chain head is returned
1834        assert_eq!(metas.len(), 1);
1835        assert_eq!(metas[0].id, "session-a");
1836
1837        // Chains are transparent — no predecessor/successor links
1838        assert!(metas[0].predecessor.is_none());
1839        assert!(metas[0].successor.is_none());
1840    }
1841
1842    #[cfg(feature = "watcher")]
1843    #[test]
1844    fn test_watcher_emits_rotation_progress() {
1845        let temp = TempDir::new().unwrap();
1846        let claude_dir = temp.path().join(".claude");
1847        let project_dir = claude_dir.join("projects/-test-project");
1848        fs::create_dir_all(&project_dir).unwrap();
1849
1850        // Session A
1851        let entry_a = r#"{"uuid":"a1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Hello"}}"#;
1852        fs::write(
1853            project_dir.join("session-a.jsonl"),
1854            format!("{}\n", entry_a),
1855        )
1856        .unwrap();
1857
1858        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
1859        let manager = ClaudeConvo::with_resolver(resolver);
1860
1861        let mut watcher = crate::watcher::ConversationWatcher::new(
1862            manager,
1863            "/test/project".to_string(),
1864            "session-a".to_string(),
1865        );
1866
1867        // First poll via trait: consume session-a entries
1868        let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1869        assert_eq!(events.len(), 1);
1870        assert!(matches!(&events[0], WatcherEvent::Turn(_)));
1871
1872        // Create successor session-b
1873        let entries_b = [
1874            r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Bridge"}}"#,
1875            r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"New"}}"#,
1876        ];
1877        fs::write(project_dir.join("session-b.jsonl"), entries_b.join("\n")).unwrap();
1878
1879        // Second poll via trait: should include rotation Progress event
1880        let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
1881
1882        // First event: Progress(session_rotated) with from/to
1883        assert!(
1884            events.len() >= 2,
1885            "Expected Progress + Turn, got {} events",
1886            events.len()
1887        );
1888        match &events[0] {
1889            WatcherEvent::Progress { kind, data } => {
1890                assert_eq!(kind, "session_rotated");
1891                assert_eq!(data["from"], "session-a");
1892                assert_eq!(data["to"], "session-b");
1893            }
1894            other => panic!("Expected Progress, got {:?}", std::mem::discriminant(other)),
1895        }
1896
1897        // Second event: Turn for b1 (bridge entry b0 filtered out)
1898        match &events[1] {
1899            WatcherEvent::Turn(turn) => {
1900                assert_eq!(turn.id, "b1");
1901                assert_eq!(turn.text, "New");
1902            }
1903            other => panic!("Expected Turn(b1), got {:?}", std::mem::discriminant(other)),
1904        }
1905
1906        // No bridge entry should appear as a Turn
1907        for event in &events {
1908            if let WatcherEvent::Turn(t) = event {
1909                assert_ne!(t.id, "b0", "Bridge entry should not appear as a Turn");
1910            }
1911        }
1912    }
1913
1914    #[test]
1915    fn test_load_metadata_chain_transparent() {
1916        let (_temp, provider) = setup_chained_provider();
1917
1918        // Load from chain head — aggregated metadata
1919        let meta_a =
1920            ConversationProvider::load_metadata(&provider, "/test/project", "session-a").unwrap();
1921        assert_eq!(meta_a.id, "session-a");
1922        // Aggregated message count across both segments (2 + 3 = 5)
1923        assert_eq!(meta_a.message_count, 5);
1924        // Chains are transparent — no predecessor/successor links
1925        assert!(meta_a.predecessor.is_none());
1926        assert!(meta_a.successor.is_none());
1927
1928        // Load from a successor — still resolves the full chain
1929        let meta_b =
1930            ConversationProvider::load_metadata(&provider, "/test/project", "session-b").unwrap();
1931        assert_eq!(meta_b.id, "session-a"); // head of chain
1932        assert_eq!(meta_b.message_count, 5);
1933        assert!(meta_b.predecessor.is_none());
1934        assert!(meta_b.successor.is_none());
1935    }
1936}