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