Skip to main content

toolpath_claude/
provider.rs

1//! Implementation of `toolpath-convo` traits for Claude conversations.
2//!
3//! Handles cross-entry tool result assembly: Claude's JSONL format writes
4//! tool invocations and their results as separate entries. This module
5//! pairs them by `tool_use_id` so consumers get complete `Turn` values
6//! with `ToolInvocation.result` populated.
7
8use crate::ClaudeConvo;
9use crate::types::{Conversation, ConversationEntry, Message, MessageContent, MessageRole};
10use toolpath_convo::{
11    ConversationMeta, ConversationProvider, ConversationView, ConvoError, Role, TokenUsage,
12    ToolInvocation, ToolResult, Turn, WatcherEvent,
13};
14
15// ── Conversion helpers ───────────────────────────────────────────────
16
17fn claude_role_to_role(role: &MessageRole) -> Role {
18    match role {
19        MessageRole::User => Role::User,
20        MessageRole::Assistant => Role::Assistant,
21        MessageRole::System => Role::System,
22    }
23}
24
25/// Convert a single entry to a Turn without cross-entry assembly.
26/// Tool results within the same message are still matched.
27fn message_to_turn(entry: &ConversationEntry, msg: &Message) -> Turn {
28    let text = msg.text();
29
30    let thinking = msg.thinking().map(|parts| parts.join("\n"));
31
32    let tool_uses = msg
33        .tool_uses()
34        .into_iter()
35        .map(|tu| {
36            let result = find_tool_result_in_parts(msg, tu.id);
37            ToolInvocation {
38                id: tu.id.to_string(),
39                name: tu.name.to_string(),
40                input: tu.input.clone(),
41                result,
42            }
43        })
44        .collect();
45
46    let token_usage = msg.usage.as_ref().map(|u| TokenUsage {
47        input_tokens: u.input_tokens,
48        output_tokens: u.output_tokens,
49    });
50
51    Turn {
52        id: entry.uuid.clone(),
53        parent_id: entry.parent_uuid.clone(),
54        role: claude_role_to_role(&msg.role),
55        timestamp: entry.timestamp.clone(),
56        text,
57        thinking,
58        tool_uses,
59        model: msg.model.clone(),
60        stop_reason: msg.stop_reason.clone(),
61        token_usage,
62        extra: Default::default(),
63    }
64}
65
66fn find_tool_result_in_parts(msg: &Message, tool_use_id: &str) -> Option<ToolResult> {
67    let parts = match &msg.content {
68        Some(MessageContent::Parts(parts)) => parts,
69        _ => return None,
70    };
71    parts.iter().find_map(|p| match p {
72        crate::types::ContentPart::ToolResult {
73            tool_use_id: id,
74            content,
75            is_error,
76        } if id == tool_use_id => Some(ToolResult {
77            content: content.text(),
78            is_error: *is_error,
79        }),
80        _ => None,
81    })
82}
83
84/// Returns true if this entry is a tool-result-only user message
85/// (no human-authored text, only tool_result parts).
86fn is_tool_result_only(entry: &ConversationEntry) -> bool {
87    let Some(msg) = &entry.message else {
88        return false;
89    };
90    msg.role == MessageRole::User && msg.text().is_empty() && !msg.tool_results().is_empty()
91}
92
93/// Merge tool results from a tool-result-only message into existing turns.
94///
95/// Matches by `tool_use_id` — scans backwards through turns to find the
96/// `ToolInvocation` with a matching `id` for each result. This handles
97/// cases where a single result entry carries results for tool uses from
98/// different assistant turns.
99///
100/// Returns true if any results were merged.
101fn merge_tool_results(turns: &mut [Turn], msg: &Message) -> bool {
102    let mut merged = false;
103    for tr in msg.tool_results() {
104        for turn in turns.iter_mut().rev() {
105            if let Some(invocation) = turn
106                .tool_uses
107                .iter_mut()
108                .find(|tu| tu.id == tr.tool_use_id && tu.result.is_none())
109            {
110                invocation.result = Some(ToolResult {
111                    content: tr.content.text(),
112                    is_error: tr.is_error,
113                });
114                merged = true;
115                break;
116            }
117        }
118    }
119    merged
120}
121
122fn entry_to_turn(entry: &ConversationEntry) -> Option<Turn> {
123    entry
124        .message
125        .as_ref()
126        .map(|msg| message_to_turn(entry, msg))
127}
128
129/// Convert a full conversation to a view with cross-entry tool result assembly.
130///
131/// Tool-result-only user entries are absorbed into the preceding assistant
132/// turn's `ToolInvocation.result` fields rather than emitted as separate turns.
133fn conversation_to_view(convo: &Conversation) -> ConversationView {
134    let mut turns: Vec<Turn> = Vec::new();
135
136    for entry in &convo.entries {
137        let Some(msg) = &entry.message else {
138            continue;
139        };
140
141        // Tool-result-only user entries get merged into existing turns
142        if is_tool_result_only(entry) {
143            merge_tool_results(&mut turns, msg);
144            continue;
145        }
146
147        turns.push(message_to_turn(entry, msg));
148    }
149
150    ConversationView {
151        id: convo.session_id.clone(),
152        started_at: convo.started_at,
153        last_activity: convo.last_activity,
154        turns,
155    }
156}
157
158fn entry_to_watcher_event(entry: &ConversationEntry) -> WatcherEvent {
159    match entry_to_turn(entry) {
160        Some(turn) => WatcherEvent::Turn(Box::new(turn)),
161        None => WatcherEvent::Progress {
162            kind: entry.entry_type.clone(),
163            data: serde_json::json!({
164                "uuid": entry.uuid,
165                "timestamp": entry.timestamp,
166            }),
167        },
168    }
169}
170
171// ── ConversationProvider for ClaudeConvo ──────────────────────────────
172
173impl ConversationProvider for ClaudeConvo {
174    fn list_conversations(&self, project: &str) -> toolpath_convo::Result<Vec<String>> {
175        crate::ClaudeConvo::list_conversations(self, project)
176            .map_err(|e| ConvoError::Provider(e.to_string()))
177    }
178
179    fn load_conversation(
180        &self,
181        project: &str,
182        conversation_id: &str,
183    ) -> toolpath_convo::Result<ConversationView> {
184        let convo = self
185            .read_conversation(project, conversation_id)
186            .map_err(|e| ConvoError::Provider(e.to_string()))?;
187        Ok(conversation_to_view(&convo))
188    }
189
190    fn load_metadata(
191        &self,
192        project: &str,
193        conversation_id: &str,
194    ) -> toolpath_convo::Result<ConversationMeta> {
195        let meta = self
196            .read_conversation_metadata(project, conversation_id)
197            .map_err(|e| ConvoError::Provider(e.to_string()))?;
198        Ok(ConversationMeta {
199            id: meta.session_id,
200            started_at: meta.started_at,
201            last_activity: meta.last_activity,
202            message_count: meta.message_count,
203            file_path: Some(meta.file_path),
204        })
205    }
206
207    fn list_metadata(&self, project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
208        let metas = self
209            .list_conversation_metadata(project)
210            .map_err(|e| ConvoError::Provider(e.to_string()))?;
211        Ok(metas
212            .into_iter()
213            .map(|m| ConversationMeta {
214                id: m.session_id,
215                started_at: m.started_at,
216                last_activity: m.last_activity,
217                message_count: m.message_count,
218                file_path: Some(m.file_path),
219            })
220            .collect())
221    }
222}
223
224// ── ConversationWatcher with eager emit + TurnUpdated ────────────────
225
226#[cfg(feature = "watcher")]
227impl toolpath_convo::ConversationWatcher for crate::watcher::ConversationWatcher {
228    fn poll(&mut self) -> toolpath_convo::Result<Vec<WatcherEvent>> {
229        let entries = crate::watcher::ConversationWatcher::poll(self)
230            .map_err(|e| ConvoError::Provider(e.to_string()))?;
231
232        let mut events: Vec<WatcherEvent> = Vec::new();
233
234        for entry in &entries {
235            let Some(msg) = &entry.message else {
236                events.push(entry_to_watcher_event(entry));
237                continue;
238            };
239
240            if is_tool_result_only(entry) {
241                // Find matching turns in previously emitted events and in
242                // our assembled state, merge results, emit TurnUpdated.
243                // Walk events in reverse to find the turn to update.
244                let mut updated_turn: Option<Turn> = None;
245
246                // Search backwards through events emitted this poll cycle
247                for event in events.iter_mut().rev() {
248                    if let WatcherEvent::Turn(turn) | WatcherEvent::TurnUpdated(turn) = event
249                        && turn.tool_uses.iter().any(|tu| {
250                            tu.result.is_none()
251                                && msg.tool_results().iter().any(|tr| tr.tool_use_id == tu.id)
252                        })
253                    {
254                        // Merge results into this turn
255                        let mut updated = (**turn).clone();
256                        merge_tool_results(std::slice::from_mut(&mut updated), msg);
257                        updated_turn = Some(updated.clone());
258                        // Also update the existing event in-place so later
259                        // result entries can find the right state
260                        **turn = updated;
261                        break;
262                    }
263                }
264
265                if let Some(turn) = updated_turn {
266                    events.push(WatcherEvent::TurnUpdated(Box::new(turn)));
267                }
268                // If no matching turn found, the tool-result-only entry
269                // is silently dropped (the matching turn was emitted in a
270                // prior poll cycle and can't be updated from here).
271                continue;
272            }
273
274            events.push(entry_to_watcher_event(entry));
275        }
276
277        Ok(events)
278    }
279
280    fn seen_count(&self) -> usize {
281        crate::watcher::ConversationWatcher::seen_count(self)
282    }
283}
284
285// ── Public re-exports for convenience ────────────────────────────────
286
287/// Convert a Claude [`Conversation`] directly into a [`ConversationView`].
288///
289/// This performs cross-entry tool result assembly: tool-result-only user
290/// entries are merged into the preceding assistant turn rather than emitted
291/// as separate turns.
292pub fn to_view(convo: &Conversation) -> ConversationView {
293    conversation_to_view(convo)
294}
295
296/// Convert a single Claude [`ConversationEntry`] into a [`Turn`], if it
297/// contains a message.
298///
299/// Note: this does *not* perform cross-entry assembly. For assembled
300/// results, use [`to_view`] instead.
301pub fn to_turn(entry: &ConversationEntry) -> Option<Turn> {
302    entry_to_turn(entry)
303}
304
305// ── Tests ────────────────────────────────────────────────────────────
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use crate::PathResolver;
311    use std::fs;
312    use tempfile::TempDir;
313
314    fn setup_provider() -> (TempDir, ClaudeConvo) {
315        let temp = TempDir::new().unwrap();
316        let claude_dir = temp.path().join(".claude");
317        let project_dir = claude_dir.join("projects/-test-project");
318        fs::create_dir_all(&project_dir).unwrap();
319
320        let entries = vec![
321            r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Fix the bug"}}"#,
322            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":"src/main.rs"}}],"model":"claude-opus-4-6","stop_reason":"tool_use","usage":{"input_tokens":100,"output_tokens":50}}}"#,
323            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}]}}"#,
324            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":"src/main.rs","content":"fixed"}}],"model":"claude-opus-4-6","stop_reason":"tool_use","usage":{"input_tokens":200,"output_tokens":100}}}"#,
325            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}]}}"#,
326            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"}}"#,
327            r#"{"uuid":"uuid-7","type":"user","parentUuid":"uuid-6","timestamp":"2024-01-01T00:00:06Z","message":{"role":"user","content":"Thanks!"}}"#,
328        ];
329        fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap();
330
331        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
332        (temp, ClaudeConvo::with_resolver(resolver))
333    }
334
335    #[test]
336    fn test_load_conversation_assembles_tool_results() {
337        let (_temp, provider) = setup_provider();
338        let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
339            .unwrap();
340
341        assert_eq!(view.id, "session-1");
342        // 7 entries collapse to 5 turns (2 tool-result-only entries absorbed)
343        assert_eq!(view.turns.len(), 5);
344
345        // Turn 0: user "Fix the bug"
346        assert_eq!(view.turns[0].role, Role::User);
347        assert_eq!(view.turns[0].text, "Fix the bug");
348        assert!(view.turns[0].parent_id.is_none());
349
350        // Turn 1: assistant with tool use + assembled result
351        assert_eq!(view.turns[1].role, Role::Assistant);
352        assert_eq!(view.turns[1].text, "I'll fix that.");
353        assert_eq!(
354            view.turns[1].thinking.as_deref(),
355            Some("The bug is in auth")
356        );
357        assert_eq!(view.turns[1].tool_uses.len(), 1);
358        assert_eq!(view.turns[1].tool_uses[0].name, "Read");
359        assert_eq!(view.turns[1].tool_uses[0].id, "t1");
360        // Key assertion: result is populated from the next entry
361        let result = view.turns[1].tool_uses[0].result.as_ref().unwrap();
362        assert!(!result.is_error);
363        assert!(result.content.contains("fn main()"));
364        assert_eq!(view.turns[1].model.as_deref(), Some("claude-opus-4-6"));
365        assert_eq!(view.turns[1].stop_reason.as_deref(), Some("tool_use"));
366        assert_eq!(view.turns[1].parent_id.as_deref(), Some("uuid-1"));
367
368        // Token usage
369        let usage = view.turns[1].token_usage.as_ref().unwrap();
370        assert_eq!(usage.input_tokens, Some(100));
371        assert_eq!(usage.output_tokens, Some(50));
372
373        // Turn 2: second assistant with tool use + assembled result
374        assert_eq!(view.turns[2].role, Role::Assistant);
375        assert_eq!(view.turns[2].text, "I see the issue. Let me fix it.");
376        assert_eq!(view.turns[2].tool_uses[0].name, "Edit");
377        let result2 = view.turns[2].tool_uses[0].result.as_ref().unwrap();
378        assert_eq!(result2.content, "File written successfully");
379
380        // Turn 3: final assistant (no tools)
381        assert_eq!(view.turns[3].role, Role::Assistant);
382        assert_eq!(view.turns[3].text, "Done! The bug is fixed.");
383        assert!(view.turns[3].tool_uses.is_empty());
384
385        // Turn 4: user "Thanks!"
386        assert_eq!(view.turns[4].role, Role::User);
387        assert_eq!(view.turns[4].text, "Thanks!");
388    }
389
390    #[test]
391    fn test_no_phantom_empty_turns() {
392        let (_temp, provider) = setup_provider();
393        let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
394            .unwrap();
395
396        // No turns should have empty text with User role (phantom turns)
397        for turn in &view.turns {
398            if turn.role == Role::User {
399                assert!(
400                    !turn.text.is_empty(),
401                    "Found phantom empty user turn: {:?}",
402                    turn.id
403                );
404            }
405        }
406    }
407
408    #[test]
409    fn test_tool_result_error_flag() {
410        let temp = TempDir::new().unwrap();
411        let claude_dir = temp.path().join(".claude");
412        let project_dir = claude_dir.join("projects/-test-project");
413        fs::create_dir_all(&project_dir).unwrap();
414
415        let entries = vec![
416            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read a file"}}"#,
417            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"}}"#,
418            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}]}}"#,
419        ];
420        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
421
422        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
423        let provider = ClaudeConvo::with_resolver(resolver);
424        let view =
425            ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
426
427        assert_eq!(view.turns.len(), 2); // user + assistant (tool-result absorbed)
428        let result = view.turns[1].tool_uses[0].result.as_ref().unwrap();
429        assert!(result.is_error);
430        assert_eq!(result.content, "File not found");
431    }
432
433    #[test]
434    fn test_multiple_tool_uses_single_result_entry() {
435        let temp = TempDir::new().unwrap();
436        let claude_dir = temp.path().join(".claude");
437        let project_dir = claude_dir.join("projects/-test-project");
438        fs::create_dir_all(&project_dir).unwrap();
439
440        let entries = vec![
441            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Check two files"}}"#,
442            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"}}]}}"#,
443            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}]}}"#,
444        ];
445        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
446
447        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
448        let provider = ClaudeConvo::with_resolver(resolver);
449        let view =
450            ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
451
452        assert_eq!(view.turns.len(), 2);
453        assert_eq!(view.turns[1].tool_uses.len(), 2);
454
455        let r1 = view.turns[1].tool_uses[0].result.as_ref().unwrap();
456        assert_eq!(r1.content, "file a contents");
457
458        let r2 = view.turns[1].tool_uses[1].result.as_ref().unwrap();
459        assert_eq!(r2.content, "file b contents");
460    }
461
462    #[test]
463    fn test_conversation_without_tool_use_unchanged() {
464        let temp = TempDir::new().unwrap();
465        let claude_dir = temp.path().join(".claude");
466        let project_dir = claude_dir.join("projects/-test-project");
467        fs::create_dir_all(&project_dir).unwrap();
468
469        let entries = vec![
470            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
471            r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi there!"}}"#,
472        ];
473        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
474
475        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
476        let provider = ClaudeConvo::with_resolver(resolver);
477        let view =
478            ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
479
480        assert_eq!(view.turns.len(), 2);
481        assert_eq!(view.turns[0].text, "Hello");
482        assert_eq!(view.turns[1].text, "Hi there!");
483    }
484
485    #[test]
486    fn test_assistant_turn_without_result_has_none() {
487        // Tool use at end of conversation with no result entry
488        let temp = TempDir::new().unwrap();
489        let claude_dir = temp.path().join(".claude");
490        let project_dir = claude_dir.join("projects/-test-project");
491        fs::create_dir_all(&project_dir).unwrap();
492
493        let entries = vec![
494            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read a file"}}"#,
495            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"}}]}}"#,
496        ];
497        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
498
499        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
500        let provider = ClaudeConvo::with_resolver(resolver);
501        let view =
502            ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap();
503
504        assert_eq!(view.turns.len(), 2);
505        assert!(view.turns[1].tool_uses[0].result.is_none());
506    }
507
508    #[test]
509    fn test_list_conversations() {
510        let (_temp, provider) = setup_provider();
511        let ids = ConversationProvider::list_conversations(&provider, "/test/project").unwrap();
512        assert_eq!(ids, vec!["session-1"]);
513    }
514
515    #[test]
516    fn test_load_metadata() {
517        let (_temp, provider) = setup_provider();
518        let meta =
519            ConversationProvider::load_metadata(&provider, "/test/project", "session-1").unwrap();
520        assert_eq!(meta.id, "session-1");
521        assert_eq!(meta.message_count, 7);
522        assert!(meta.file_path.is_some());
523    }
524
525    #[test]
526    fn test_list_metadata() {
527        let (_temp, provider) = setup_provider();
528        let metas = ConversationProvider::list_metadata(&provider, "/test/project").unwrap();
529        assert_eq!(metas.len(), 1);
530        assert_eq!(metas[0].id, "session-1");
531    }
532
533    #[test]
534    fn test_to_view() {
535        let (_temp, manager) = setup_provider();
536        let convo = manager
537            .read_conversation("/test/project", "session-1")
538            .unwrap();
539        let view = to_view(&convo);
540        assert_eq!(view.turns.len(), 5);
541        assert_eq!(view.title(20).unwrap(), "Fix the bug");
542    }
543
544    #[test]
545    fn test_to_turn_with_message() {
546        let entry: ConversationEntry = serde_json::from_str(
547            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
548        )
549        .unwrap();
550        let turn = to_turn(&entry).unwrap();
551        assert_eq!(turn.id, "u1");
552        assert_eq!(turn.text, "hello");
553        assert_eq!(turn.role, Role::User);
554    }
555
556    #[test]
557    fn test_to_turn_without_message() {
558        let entry: ConversationEntry = serde_json::from_str(
559            r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
560        )
561        .unwrap();
562        assert!(to_turn(&entry).is_none());
563    }
564
565    #[test]
566    fn test_entry_to_watcher_event_turn() {
567        let entry: ConversationEntry = serde_json::from_str(
568            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hi"}}"#,
569        )
570        .unwrap();
571        let event = entry_to_watcher_event(&entry);
572        assert!(matches!(event, WatcherEvent::Turn(_)));
573    }
574
575    #[test]
576    fn test_entry_to_watcher_event_progress() {
577        let entry: ConversationEntry = serde_json::from_str(
578            r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
579        )
580        .unwrap();
581        let event = entry_to_watcher_event(&entry);
582        assert!(matches!(event, WatcherEvent::Progress { .. }));
583    }
584
585    #[cfg(feature = "watcher")]
586    #[test]
587    fn test_watcher_trait_basic() {
588        let temp = TempDir::new().unwrap();
589        let claude_dir = temp.path().join(".claude");
590        let project_dir = claude_dir.join("projects/-test-project");
591        fs::create_dir_all(&project_dir).unwrap();
592
593        let entries = vec![
594            r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
595            r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
596        ];
597        fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap();
598
599        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
600        let manager = ClaudeConvo::with_resolver(resolver);
601
602        let mut watcher = crate::watcher::ConversationWatcher::new(
603            manager,
604            "/test/project".to_string(),
605            "session-1".to_string(),
606        );
607
608        // Use the trait explicitly (inherent poll returns ConversationEntry)
609        let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
610        assert_eq!(events.len(), 2);
611        assert!(matches!(&events[0], WatcherEvent::Turn(t) if t.role == Role::User));
612        assert!(matches!(&events[1], WatcherEvent::Turn(t) if t.role == Role::Assistant));
613        assert_eq!(toolpath_convo::ConversationWatcher::seen_count(&watcher), 2);
614
615        // Second poll returns nothing
616        let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
617        assert!(events.is_empty());
618    }
619
620    #[cfg(feature = "watcher")]
621    #[test]
622    fn test_watcher_trait_assembles_tool_results() {
623        let temp = TempDir::new().unwrap();
624        let claude_dir = temp.path().join(".claude");
625        let project_dir = claude_dir.join("projects/-test-project");
626        fs::create_dir_all(&project_dir).unwrap();
627
628        let entries = vec![
629            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read the file"}}"#,
630            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"}}]}}"#,
631            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}]}}"#,
632            r#"{"uuid":"u4","type":"assistant","timestamp":"2024-01-01T00:00:03Z","message":{"role":"assistant","content":"Done!"}}"#,
633        ];
634        fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap();
635
636        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
637        let manager = ClaudeConvo::with_resolver(resolver);
638
639        let mut watcher = crate::watcher::ConversationWatcher::new(
640            manager,
641            "/test/project".to_string(),
642            "s1".to_string(),
643        );
644
645        let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
646
647        // Should get: Turn(user), Turn(assistant), TurnUpdated(assistant), Turn(assistant)
648        assert_eq!(events.len(), 4);
649
650        // First: user turn
651        assert!(matches!(&events[0], WatcherEvent::Turn(t) if t.role == Role::User));
652
653        // Second: assistant turn emitted eagerly (result may not be populated yet in the event)
654        assert!(matches!(&events[1], WatcherEvent::Turn(t) if t.role == Role::Assistant));
655
656        // Third: TurnUpdated with results merged
657        match &events[2] {
658            WatcherEvent::TurnUpdated(turn) => {
659                assert_eq!(turn.id, "u2");
660                assert_eq!(turn.tool_uses.len(), 1);
661                let result = turn.tool_uses[0].result.as_ref().unwrap();
662                assert_eq!(result.content, "fn main() {}");
663                assert!(!result.is_error);
664            }
665            other => panic!("Expected TurnUpdated, got {:?}", other),
666        }
667
668        // Fourth: final assistant turn
669        assert!(matches!(&events[3], WatcherEvent::Turn(t) if t.text == "Done!"));
670    }
671
672    #[cfg(feature = "watcher")]
673    #[test]
674    fn test_watcher_trait_incremental_tool_results() {
675        // Simulate tool results arriving in a different poll cycle than the tool use
676        let temp = TempDir::new().unwrap();
677        let claude_dir = temp.path().join(".claude");
678        let project_dir = claude_dir.join("projects/-test-project");
679        fs::create_dir_all(&project_dir).unwrap();
680
681        // Start with just the user message and assistant tool use
682        let entries_phase1 = vec![
683            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Read file"}}"#,
684            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"}}]}}"#,
685        ];
686        fs::write(
687            project_dir.join("s1.jsonl"),
688            entries_phase1.join("\n") + "\n",
689        )
690        .unwrap();
691
692        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
693        let manager = ClaudeConvo::with_resolver(resolver);
694
695        let mut watcher = crate::watcher::ConversationWatcher::new(
696            manager,
697            "/test/project".to_string(),
698            "s1".to_string(),
699        );
700
701        // First poll: get user + assistant turns
702        let events1 = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
703        assert_eq!(events1.len(), 2);
704        // Assistant turn emitted eagerly with result: None
705        if let WatcherEvent::Turn(t) = &events1[1] {
706            assert!(t.tool_uses[0].result.is_none());
707        } else {
708            panic!("Expected Turn");
709        }
710
711        // Now append the tool result entry
712        use std::io::Write;
713        let mut file = fs::OpenOptions::new()
714            .append(true)
715            .open(project_dir.join("s1.jsonl"))
716            .unwrap();
717        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();
718
719        // Second poll: tool-result-only entry arrives
720        let events2 = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
721        // The tool-result-only entry can't find its matching turn in this poll
722        // cycle (it was emitted in the previous one), so it's silently absorbed.
723        // This is a known limitation of the eager-emit approach for cross-poll
724        // boundaries — the batch path (to_view) handles this correctly.
725        // Consumers needing full fidelity across poll boundaries should
726        // periodically do a full load_conversation.
727        assert!(events2.is_empty() || events2.iter().all(|e| !matches!(e, WatcherEvent::Turn(_))));
728    }
729
730    #[test]
731    fn test_merge_tool_results_by_id() {
732        // Verify that merge matches by tool_use_id, not position
733        let mut turns = vec![Turn {
734            id: "t1".into(),
735            parent_id: None,
736            role: Role::Assistant,
737            timestamp: "2024-01-01T00:00:00Z".into(),
738            text: "test".into(),
739            thinking: None,
740            tool_uses: vec![
741                ToolInvocation {
742                    id: "tool-a".into(),
743                    name: "Read".into(),
744                    input: serde_json::json!({}),
745                    result: None,
746                },
747                ToolInvocation {
748                    id: "tool-b".into(),
749                    name: "Write".into(),
750                    input: serde_json::json!({}),
751                    result: None,
752                },
753            ],
754            model: None,
755            stop_reason: None,
756            token_usage: None,
757            extra: Default::default(),
758        }];
759
760        // Create a message with results in reversed order
761        let msg: Message = serde_json::from_str(
762            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}]}"#,
763        )
764        .unwrap();
765
766        let merged = merge_tool_results(&mut turns, &msg);
767        assert!(merged);
768
769        // Results should match by ID regardless of order
770        assert_eq!(
771            turns[0].tool_uses[0].result.as_ref().unwrap().content,
772            "read result"
773        );
774        assert!(turns[0].tool_uses[0].result.as_ref().unwrap().is_error);
775
776        assert_eq!(
777            turns[0].tool_uses[1].result.as_ref().unwrap().content,
778            "write result"
779        );
780        assert!(!turns[0].tool_uses[1].result.as_ref().unwrap().is_error);
781    }
782
783    #[test]
784    fn test_is_tool_result_only() {
785        // Tool-result-only entry
786        let entry: ConversationEntry = serde_json::from_str(
787            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}]}}"#,
788        )
789        .unwrap();
790        assert!(is_tool_result_only(&entry));
791
792        // Regular user entry with text
793        let entry: ConversationEntry = serde_json::from_str(
794            r#"{"uuid":"u2","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
795        )
796        .unwrap();
797        assert!(!is_tool_result_only(&entry));
798
799        // Entry without message
800        let entry: ConversationEntry = serde_json::from_str(
801            r#"{"uuid":"u3","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
802        )
803        .unwrap();
804        assert!(!is_tool_result_only(&entry));
805
806        // Assistant entry (never tool-result-only)
807        let entry: ConversationEntry = serde_json::from_str(
808            r#"{"uuid":"u4","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"hi"}}"#,
809        )
810        .unwrap();
811        assert!(!is_tool_result_only(&entry));
812    }
813}