Skip to main content

toolpath_claude/
project.rs

1//! [`ClaudeProjector`] — maps a [`ConversationView`] back to a Claude
2//! [`Conversation`].
3//!
4//! This is the inverse of [`crate::provider::to_view`]: where `to_view`
5//! reads a Claude JSONL conversation into a provider-agnostic view,
6//! `ClaudeProjector` serializes that view back into the Claude wire format.
7
8use crate::types::{
9    ContentPart, Conversation, ConversationEntry, Message, MessageContent, MessageRole,
10    ToolResultContent, Usage,
11};
12use serde_json::json;
13use std::collections::HashMap;
14use toolpath_convo::{
15    ConversationProjector, ConversationView, ConvoError, Result, Role, ToolInvocation, Turn,
16};
17
18// ── ClaudeProjector ───────────────────────────────────────────────────
19
20/// Project a [`ConversationView`] into a Claude [`Conversation`].
21///
22/// Maps the provider-agnostic view back into Claude's JSONL wire format.
23/// Assistant turns with tool uses will produce a separate tool-result user
24/// entry after each assistant entry (one entry per assistant turn that has
25/// tool uses with results).
26///
27/// # Example
28///
29/// ```rust
30/// use toolpath_claude::project::ClaudeProjector;
31/// use toolpath_convo::{ConversationView, ConversationProjector};
32///
33/// let view = ConversationView {
34///     id: "my-session".to_string(),
35///     ..Default::default()
36/// };
37///
38/// let projector = ClaudeProjector;
39/// let convo = projector.project(&view).unwrap();
40/// assert_eq!(convo.session_id, "my-session");
41/// ```
42pub struct ClaudeProjector;
43
44impl ConversationProjector for ClaudeProjector {
45    type Output = Conversation;
46
47    fn project(&self, view: &ConversationView) -> Result<Conversation> {
48        project_view(view).map_err(|e| ConvoError::Provider(e.to_string()))
49    }
50}
51
52// ── Projection logic ─────────────────────────────────────────────────
53
54/// Marker used by Claude's derive to preserve tool-result user entries as
55/// events. Their UUID is what the next assistant turn's `parentUuid`
56/// points at — synthesizing a new one breaks the chain.
57const TOOL_RESULT_USER_EVENT: &str = "tool_result_user";
58
59fn project_view(view: &ConversationView) -> std::result::Result<Conversation, String> {
60    let mut convo = Conversation::new(view.id.clone());
61
62    // Headerless lines (the JSONL "preamble": ai-title, last-prompt,
63    // queue-operation, permission-mode, file-history-snapshot, and anything
64    // unrecognized) ride in `view.events` carrying the original line verbatim
65    // under `data["raw"]`. Dump them straight back. A headerless event is
66    // identified by that `raw` key — no enumerated type list.
67    let mut emitted_preamble = false;
68    for event in &view.events {
69        if let Some(raw) = event.data.get("raw") {
70            convo.preamble.push(raw.clone());
71            emitted_preamble = true;
72        }
73    }
74    // Cross-harness views won't carry a Claude preamble; emit a default
75    // permission-mode line so Claude Code can resume them.
76    if !emitted_preamble {
77        convo.preamble.push(json!({
78            "type": "permission-mode",
79            "permissionMode": "default",
80            "sessionId": view.id,
81        }));
82    }
83
84    // Index tool_result_user events by parent_id so we can re-emit them
85    // inline right after the assistant turn they responded to. This keeps
86    // every downstream `parentUuid` reference valid.
87    let mut tool_result_events_by_parent: HashMap<String, Vec<&toolpath_convo::ConversationEvent>> =
88        HashMap::new();
89    for event in &view.events {
90        if event.event_type != TOOL_RESULT_USER_EVENT {
91            continue;
92        }
93        if let Some(pid) = &event.parent_id {
94            tool_result_events_by_parent
95                .entry(pid.clone())
96                .or_default()
97                .push(event);
98        }
99    }
100    let mut consumed_event_ids: std::collections::HashSet<String> =
101        std::collections::HashSet::new();
102    // For cross-harness sources whose IR doesn't model intermediate tool-
103    // result turns, the next assistant's `parent_id` points at the prior
104    // assistant. Claude expects it to point at the tool_result entry that
105    // ran in between. Track those rewrites so we can patch the chain.
106    let mut parent_rewrites: HashMap<String, String> = HashMap::new();
107
108    for turn in &view.turns {
109        // Pre-rewrite this turn's parent_id if a synthesized tool_result
110        // was emitted between it and its IR-recorded parent.
111        let effective_parent = turn
112            .parent_id
113            .as_ref()
114            .and_then(|pid| parent_rewrites.get(pid).cloned())
115            .or_else(|| turn.parent_id.clone());
116
117        match &turn.role {
118            Role::User => {
119                let mut entry = user_turn_to_entry(turn, &view.id);
120                apply_turn_metadata(&mut entry, turn);
121                entry.parent_uuid = effective_parent;
122                convo.add_entry(entry);
123            }
124            Role::Assistant => {
125                let mut assistant_entry = assistant_turn_to_entry(turn, &view.id);
126                apply_turn_metadata(&mut assistant_entry, turn);
127                assistant_entry.parent_uuid = effective_parent;
128                convo.add_entry(assistant_entry);
129
130                // Prefer the original tool-result user entries (preserved
131                // as events with their source UUIDs) over synthesizing.
132                // Synthesizing rewrites the UUID, which breaks the
133                // parentUuid chain on every subsequent assistant turn.
134                let real = tool_result_events_by_parent.remove(&turn.id);
135                if let Some(events) = real {
136                    let mut last_uuid = turn.id.clone();
137                    for event in events {
138                        let entry = tool_result_event_to_entry(event, &view.id);
139                        last_uuid = entry.uuid.clone();
140                        convo.add_entry(entry);
141                        consumed_event_ids.insert(event.id.clone());
142                    }
143                    // Anything in the IR that pointed at this assistant
144                    // turn should now point at the last tool-result entry
145                    // we emitted, matching Claude's wire convention.
146                    if last_uuid != turn.id {
147                        parent_rewrites.insert(turn.id.clone(), last_uuid);
148                    }
149                } else {
150                    // Cross-harness fallback: synthesize per-tool-use
151                    // result entries.
152                    let mut last_uuid = turn.id.clone();
153                    for mut result_entry in tool_result_entries(turn, &view.id) {
154                        apply_turn_metadata(&mut result_entry, turn);
155                        last_uuid = result_entry.uuid.clone();
156                        convo.add_entry(result_entry);
157                    }
158                    if last_uuid != turn.id {
159                        parent_rewrites.insert(turn.id.clone(), last_uuid);
160                    }
161                }
162            }
163            Role::System => {
164                let mut entry = system_turn_to_entry(turn, &view.id);
165                apply_turn_metadata(&mut entry, turn);
166                entry.parent_uuid = effective_parent;
167                convo.add_entry(entry);
168            }
169            Role::Other(_) => {
170                let mut entry = other_turn_to_entry(turn, &view.id);
171                apply_turn_metadata(&mut entry, turn);
172                entry.parent_uuid = effective_parent;
173                convo.add_entry(entry);
174            }
175        }
176    }
177
178    // Emit non-preamble events (attachments, etc.) as entries.
179    for event in &view.events {
180        if event.data.contains_key("raw") {
181            continue; // headerless line — already pushed onto convo.preamble
182        }
183        if consumed_event_ids.contains(&event.id) {
184            continue;
185        }
186        // Tool-result events without a matching parent turn — emit them
187        // anyway (rare; happens when the assistant turn is dropped).
188        if event.event_type == TOOL_RESULT_USER_EVENT {
189            let entry = tool_result_event_to_entry(event, &view.id);
190            convo.add_entry(entry);
191            continue;
192        }
193        let entry = project_event(event, &view.id);
194        convo.add_entry(entry);
195    }
196
197    Ok(convo)
198}
199
200/// Rebuild a Claude tool-result user entry verbatim from a preserved event.
201///
202/// The event was emitted by [`crate::derive::derive_path`] when reading the
203/// source JSONL — it carries the original UUID, parent UUID, the
204/// `toolUseResult` blob, and any `entry_extra` fields (promptId, slug, …).
205/// Reconstructing those preserves the UUID chain that Claude's UI traverses.
206fn tool_result_event_to_entry(
207    event: &toolpath_convo::ConversationEvent,
208    session_id: &str,
209) -> ConversationEntry {
210    let mut content_parts: Vec<ContentPart> = Vec::new();
211    if let Some(arr) = event.data.get("tool_results").and_then(|v| v.as_array()) {
212        for v in arr {
213            let tool_use_id = v
214                .get("tool_use_id")
215                .and_then(|x| x.as_str())
216                .unwrap_or_default()
217                .to_string();
218            let content_text = v
219                .get("content")
220                .and_then(|x| x.as_str())
221                .unwrap_or_default()
222                .to_string();
223            let is_error = v.get("is_error").and_then(|x| x.as_bool()).unwrap_or(false);
224            content_parts.push(ContentPart::ToolResult {
225                tool_use_id,
226                content: ToolResultContent::Text(content_text),
227                is_error,
228            });
229        }
230    }
231
232    let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
233    if let Some(map) = event.data.get("entry_extra").and_then(|v| v.as_object()) {
234        for (k, v) in map {
235            extra.insert(k.clone(), v.clone());
236        }
237    }
238
239    ConversationEntry {
240        uuid: event.id.clone(),
241        parent_uuid: event.parent_id.clone(),
242        is_sidechain: false,
243        entry_type: "user".to_string(),
244        timestamp: event.timestamp.clone(),
245        session_id: Some(session_id.to_string()),
246        cwd: event
247            .data
248            .get("cwd")
249            .and_then(|v| v.as_str())
250            .map(|s| s.to_string()),
251        git_branch: event
252            .data
253            .get("git_branch")
254            .and_then(|v| v.as_str())
255            .map(|s| s.to_string()),
256        message: Some(Message {
257            role: MessageRole::User,
258            content: Some(MessageContent::Parts(content_parts)),
259            model: None,
260            id: None,
261            message_type: None,
262            stop_reason: None,
263            stop_sequence: None,
264            usage: None,
265        }),
266        version: event
267            .data
268            .get("version")
269            .and_then(|v| v.as_str())
270            .map(|s| s.to_string()),
271        user_type: event
272            .data
273            .get("user_type")
274            .and_then(|v| v.as_str())
275            .map(|s| s.to_string()),
276        request_id: None,
277        tool_use_result: event.data.get("tool_use_result").cloned(),
278        snapshot: None,
279        message_id: None,
280        extra,
281    }
282}
283
284/// Apply Claude-specific metadata from a [`Turn`] onto a [`ConversationEntry`].
285///
286/// Populates `cwd` and `git_branch` from [`Turn::environment`], and
287/// `version`, `user_type`, `request_id` from `Turn::extra["claude"]`.
288/// Remaining keys from the `"claude"` extras are merged into the entry's
289/// own `extra` map so they serialize as top-level fields (via `#[serde(flatten)]`).
290fn apply_turn_metadata(entry: &mut ConversationEntry, turn: &Turn) {
291    // From Turn.environment
292    if let Some(env) = &turn.environment {
293        if entry.cwd.is_none() {
294            entry.cwd = env.working_dir.clone();
295        }
296        if entry.git_branch.is_none() {
297            entry.git_branch = env.vcs_branch.clone();
298        }
299    }
300
301    // Source-format details (`version`, `user_type`, `request_id`,
302    // per-entry catch-all) used to ride through `Turn.extra["claude"]` for
303    // claude → IR → claude round-trip. The IR no longer carries
304    // provider-specific extras; the projected entry's fields stay `None`
305    // and the harness fills in defaults at write time.
306}
307
308/// Build a `ConversationEntry` for a user turn.
309fn user_turn_to_entry(turn: &Turn, session_id: &str) -> ConversationEntry {
310    let content = MessageContent::Text(turn.text.clone());
311
312    ConversationEntry {
313        uuid: turn.id.clone(),
314        parent_uuid: turn.parent_id.clone(),
315        is_sidechain: false,
316        entry_type: "user".to_string(),
317        timestamp: turn.timestamp.clone(),
318        session_id: Some(session_id.to_string()),
319        cwd: turn
320            .environment
321            .as_ref()
322            .and_then(|e| e.working_dir.clone()),
323        git_branch: turn.environment.as_ref().and_then(|e| e.vcs_branch.clone()),
324        message: Some(Message {
325            role: MessageRole::User,
326            content: Some(content),
327            model: None,
328            id: None,
329            message_type: None,
330            stop_reason: None,
331            stop_sequence: None,
332            usage: None,
333        }),
334        version: None,
335        user_type: None,
336        request_id: None,
337        tool_use_result: None,
338        snapshot: None,
339        message_id: None,
340        extra: Default::default(),
341    }
342}
343
344/// Build a `ConversationEntry` for an assistant turn.
345fn assistant_turn_to_entry(turn: &Turn, session_id: &str) -> ConversationEntry {
346    let content = build_assistant_content(turn);
347
348    let usage = turn.token_usage.as_ref().map(|u| Usage {
349        input_tokens: u.input_tokens,
350        output_tokens: u.output_tokens,
351        // TokenUsage uses cache_write_tokens; Usage uses cache_creation_input_tokens
352        cache_creation_input_tokens: u.cache_write_tokens,
353        cache_read_input_tokens: u.cache_read_tokens,
354        cache_creation: None,
355        service_tier: None,
356    });
357
358    ConversationEntry {
359        uuid: turn.id.clone(),
360        parent_uuid: turn.parent_id.clone(),
361        is_sidechain: false,
362        entry_type: "assistant".to_string(),
363        timestamp: turn.timestamp.clone(),
364        session_id: Some(session_id.to_string()),
365        cwd: None,
366        git_branch: None,
367        message: Some(Message {
368            role: MessageRole::Assistant,
369            content: Some(content),
370            model: turn.model.clone(),
371            id: None,
372            message_type: None,
373            stop_reason: turn.stop_reason.clone(),
374            stop_sequence: None,
375            usage,
376        }),
377        version: None,
378        user_type: None,
379        request_id: None,
380        tool_use_result: None,
381        snapshot: None,
382        message_id: None,
383        extra: Default::default(),
384    }
385}
386
387/// Build the `MessageContent` for an assistant turn.
388///
389/// If the turn has ONLY text (no thinking, no tool_uses): returns
390/// `MessageContent::Text`. Otherwise builds `MessageContent::Parts`.
391fn build_assistant_content(turn: &Turn) -> MessageContent {
392    let has_thinking = turn.thinking.is_some();
393    let has_tool_uses = !turn.tool_uses.is_empty();
394
395    if !has_thinking && !has_tool_uses {
396        // Claude Code expects assistant content to always be an array,
397        // even for simple text-only responses.
398        return MessageContent::Parts(vec![ContentPart::Text {
399            text: turn.text.clone(),
400        }]);
401    }
402
403    let mut parts: Vec<ContentPart> = Vec::new();
404
405    if let Some(thinking) = &turn.thinking {
406        parts.push(ContentPart::Thinking {
407            thinking: thinking.clone(),
408            signature: None,
409        });
410    }
411
412    if !turn.text.is_empty() {
413        parts.push(ContentPart::Text {
414            text: turn.text.clone(),
415        });
416    }
417
418    for tu in &turn.tool_uses {
419        // Rename non-Claude tools to Claude's canonical names so the UI's
420        // tool-specific renderers (Bash output panel, Edit diff view, Read
421        // file viewer, Glob/Grep result lists) actually fire. Without
422        // this, names like Codex's `exec_command` / `read_file` /
423        // `write_file` come through as opaque blocks even when their
424        // toolUseResult is well-formed.
425        let name = canonical_claude_tool_name(tu);
426        let input = canonical_claude_tool_input(tu, &name);
427        parts.push(ContentPart::ToolUse {
428            id: tu.id.clone(),
429            name,
430            input,
431        });
432    }
433
434    MessageContent::Parts(parts)
435}
436
437/// Pick Claude's native tool name. Same shape as `tool_native_name` on
438/// codex / opencode / gemini / pi: keep the source name when it's
439/// already Claude-canonical, otherwise route through the IR's
440/// `category` plus [`crate::provider::native_name`] to land on a Claude
441/// tool name; otherwise pass through verbatim. The rename makes
442/// Claude's UI fire its rich result panes (diff view, shell output
443/// box, file viewer) for cross-harness sources.
444fn canonical_claude_tool_name(tu: &ToolInvocation) -> String {
445    if crate::provider::tool_category(&tu.name).is_some() {
446        return tu.name.clone();
447    }
448    if let Some(cat) = tu.category
449        && let Some(remap) = crate::provider::native_name(cat, &tu.input)
450    {
451        return remap.to_string();
452    }
453    tu.name.clone()
454}
455
456/// Translate a non-Claude tool input map to Claude's expected input keys.
457///
458/// Claude's UI renders rich panes by reading specific keys from the input
459/// (`Bash` reads `command`, `Edit` reads `file_path`/`old_string`/
460/// `new_string`, `Read` reads `file_path`). Cross-harness inputs use
461/// different keys (`path` vs `file_path`, etc.); without renaming, the
462/// pane has nothing to display.
463fn canonical_claude_tool_input(tu: &ToolInvocation, claude_name: &str) -> serde_json::Value {
464    let get_str = |keys: &[&str]| -> Option<String> {
465        for k in keys {
466            if let Some(v) = tu.input.get(*k).and_then(|v| v.as_str()) {
467                return Some(v.to_string());
468            }
469        }
470        None
471    };
472    let path_alts = ["file_path", "filePath", "path", "absolute_path", "filename"];
473    match claude_name {
474        "Bash" => {
475            let mut obj = serde_json::Map::new();
476            if let Some(cmd) = get_str(&["command", "cmd"]) {
477                obj.insert("command".into(), serde_json::Value::String(cmd));
478            }
479            if let Some(desc) = get_str(&["description", "summary"]) {
480                obj.insert("description".into(), serde_json::Value::String(desc));
481            }
482            if !obj.is_empty() {
483                serde_json::Value::Object(obj)
484            } else {
485                tu.input.clone()
486            }
487        }
488        "Read" => {
489            let mut obj = serde_json::Map::new();
490            if let Some(p) = get_str(&path_alts) {
491                obj.insert("file_path".into(), serde_json::Value::String(p));
492            }
493            if let Some(off) = tu.input.get("offset").or_else(|| tu.input.get("startLine")) {
494                obj.insert("offset".into(), off.clone());
495            }
496            if let Some(lim) = tu.input.get("limit").or_else(|| tu.input.get("numLines")) {
497                obj.insert("limit".into(), lim.clone());
498            }
499            if !obj.is_empty() {
500                serde_json::Value::Object(obj)
501            } else {
502                tu.input.clone()
503            }
504        }
505        "Write" => {
506            let mut obj = serde_json::Map::new();
507            if let Some(p) = get_str(&path_alts) {
508                obj.insert("file_path".into(), serde_json::Value::String(p));
509            }
510            if let Some(c) = get_str(&["content", "text"]) {
511                obj.insert("content".into(), serde_json::Value::String(c));
512            }
513            if !obj.is_empty() {
514                serde_json::Value::Object(obj)
515            } else {
516                tu.input.clone()
517            }
518        }
519        "Edit" | "MultiEdit" => {
520            let mut obj = serde_json::Map::new();
521            if let Some(p) = get_str(&path_alts) {
522                obj.insert("file_path".into(), serde_json::Value::String(p));
523            }
524            if let Some(o) = get_str(&["old_string", "oldString"]) {
525                obj.insert("old_string".into(), serde_json::Value::String(o));
526            }
527            if let Some(n) = get_str(&["new_string", "newString"]) {
528                obj.insert("new_string".into(), serde_json::Value::String(n));
529            }
530            if let Some(r) = tu
531                .input
532                .get("replace_all")
533                .or_else(|| tu.input.get("replaceAll"))
534            {
535                obj.insert("replace_all".into(), r.clone());
536            }
537            if !obj.is_empty() {
538                serde_json::Value::Object(obj)
539            } else {
540                tu.input.clone()
541            }
542        }
543        "Glob" | "Grep" => {
544            let mut obj = serde_json::Map::new();
545            if let Some(p) = get_str(&["pattern", "query", "regex"]) {
546                obj.insert("pattern".into(), serde_json::Value::String(p));
547            }
548            if let Some(p) = get_str(&path_alts) {
549                obj.insert("path".into(), serde_json::Value::String(p));
550            }
551            if !obj.is_empty() {
552                serde_json::Value::Object(obj)
553            } else {
554                tu.input.clone()
555            }
556        }
557        _ => tu.input.clone(),
558    }
559}
560
561/// Build one tool-result user entry per `ToolInvocation` that has a result.
562///
563/// Claude's wire format uses a separate user entry per tool result so that
564/// the top-level `toolUseResult` field (which carries the rich UI display
565/// blob — Bash stdout/stderr, Edit's structuredPatch, etc.) is unambiguous.
566fn tool_result_entries(turn: &Turn, session_id: &str) -> Vec<ConversationEntry> {
567    turn.tool_uses
568        .iter()
569        .filter_map(|tu| {
570            let result = tu.result.as_ref()?;
571            let part = ContentPart::ToolResult {
572                tool_use_id: tu.id.clone(),
573                content: ToolResultContent::Text(result.content.clone()),
574                is_error: result.is_error,
575            };
576
577            let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
578            extra.insert("sourceToolAssistantUUID".to_string(), json!(turn.id));
579
580            Some(ConversationEntry {
581                uuid: format!("{}-result-{}", turn.id, tu.id),
582                parent_uuid: Some(turn.id.clone()),
583                is_sidechain: false,
584                entry_type: "user".to_string(),
585                timestamp: turn.timestamp.clone(),
586                session_id: Some(session_id.to_string()),
587                cwd: None,
588                git_branch: None,
589                message: Some(Message {
590                    role: MessageRole::User,
591                    content: Some(MessageContent::Parts(vec![part])),
592                    model: None,
593                    id: None,
594                    message_type: None,
595                    stop_reason: None,
596                    stop_sequence: None,
597                    usage: None,
598                }),
599                version: None,
600                user_type: None,
601                request_id: None,
602                tool_use_result: tool_use_result_from_invocation(tu),
603                snapshot: None,
604                message_id: None,
605                extra,
606            })
607        })
608        .collect()
609}
610
611/// Reconstruct Claude's `toolUseResult` JSON from the tool's name, input,
612/// and result content.
613///
614/// This is what drives Claude Code's diff view, shell output box, and
615/// agent stat panel. The data was already in the IR — `name` says what
616/// kind of tool it was, `input` carries the args (file path, command,
617/// pattern, …), and `result.content` is the model-visible text. We just
618/// switch on the name and fan the fields out to Claude's expected shape.
619///
620/// For unrecognized tools we fall back to the content as a plain string
621/// so the UI at least shows *something*.
622fn tool_use_result_from_invocation(tu: &ToolInvocation) -> Option<serde_json::Value> {
623    use toolpath_convo::ToolCategory;
624
625    let str_field = |k: &str| -> Option<String> {
626        tu.input
627            .get(k)
628            .and_then(|v| v.as_str())
629            .map(|s| s.to_string())
630    };
631    // Cross-harness tool inputs name the same concept differently —
632    // Claude has `file_path`, Codex has `path`, Gemini has `absolute_path`,
633    // opencode uses `filePath` / `path` depending on tool. Try each.
634    let path_field = || -> Option<String> {
635        ["file_path", "filePath", "path", "absolute_path", "filename"]
636            .iter()
637            .find_map(|k| str_field(k))
638    };
639    let result_text = || {
640        tu.result
641            .as_ref()
642            .map(|r| r.content.clone())
643            .unwrap_or_default()
644    };
645
646    // Pick the dispatch key: name takes precedence (lets us recognize
647    // Claude-specific quirks like Read's offset/limit), category is the
648    // cross-harness fallback so Codex's `exec_command` and opencode's
649    // `bash` and Pi's `bash` all land on the shell shape.
650    enum Kind {
651        Shell,
652        Write,
653        Edit,
654        Read,
655        Search,
656        Other,
657    }
658    let kind = match tu.name.as_str() {
659        "Bash" => Kind::Shell,
660        "Write" => Kind::Write,
661        "Edit" | "MultiEdit" => Kind::Edit,
662        "Read" => Kind::Read,
663        "Glob" | "Grep" => Kind::Search,
664        _ => match tu.category {
665            Some(ToolCategory::Shell) => Kind::Shell,
666            Some(ToolCategory::FileWrite) => {
667                // Disambiguate write vs edit by input shape: presence of
668                // `old_string` signals an in-place edit.
669                if tu.input.get("old_string").is_some() || tu.input.get("oldString").is_some() {
670                    Kind::Edit
671                } else {
672                    Kind::Write
673                }
674            }
675            Some(ToolCategory::FileRead) => Kind::Read,
676            Some(ToolCategory::FileSearch) => Kind::Search,
677            _ => Kind::Other,
678        },
679    };
680
681    match kind {
682        Kind::Shell => Some(json!({
683            "stdout": result_text(),
684            "stderr": "",
685            "interrupted": false,
686            "isImage": false,
687            "noOutputExpected": false,
688        })),
689        Kind::Write => {
690            let path = path_field()?;
691            let content = str_field("content").unwrap_or_default();
692            Some(json!({
693                "type": "update",
694                "filePath": path,
695                "content": content,
696            }))
697        }
698        Kind::Edit => {
699            let path = path_field()?;
700            let old = str_field("old_string")
701                .or_else(|| str_field("oldString"))
702                .unwrap_or_default();
703            let new_ = str_field("new_string")
704                .or_else(|| str_field("newString"))
705                .unwrap_or_default();
706            let replace_all = tu
707                .input
708                .get("replace_all")
709                .or_else(|| tu.input.get("replaceAll"))
710                .and_then(|v| v.as_bool())
711                .unwrap_or(false);
712            Some(json!({
713                "filePath": path,
714                "oldString": old,
715                "newString": new_,
716                "originalFile": "",
717                "replaceAll": replace_all,
718                "userModified": false,
719                "structuredPatch": structured_patch_hunks(&old, &new_),
720            }))
721        }
722        Kind::Read => {
723            let Some(path) = path_field() else {
724                return Some(json!(result_text()));
725            };
726            let content = result_text();
727            let stripped = strip_line_numbers(&content);
728            let total_lines = stripped.lines().count();
729            Some(json!({
730                "type": "text",
731                "file": {
732                    "filePath": path,
733                    "content": stripped,
734                    "numLines": total_lines,
735                    "startLine": tu.input.get("offset").and_then(|v| v.as_u64()).unwrap_or(1),
736                    "totalLines": total_lines,
737                }
738            }))
739        }
740        Kind::Search => {
741            let pattern = str_field("pattern")
742                .or_else(|| str_field("query"))
743                .unwrap_or_default();
744            let filenames: Vec<String> = tu
745                .result
746                .as_ref()
747                .map(|r| {
748                    r.content
749                        .lines()
750                        .filter(|l| !l.is_empty())
751                        .map(|s| s.to_string())
752                        .collect()
753                })
754                .unwrap_or_default();
755            let num_files = filenames.len();
756            Some(json!({
757                "filenames": filenames,
758                "numFiles": num_files,
759                "pattern": pattern,
760            }))
761        }
762        Kind::Other => tu.result.as_ref().map(|r| json!(r.content)),
763    }
764}
765
766/// Build Claude's `structuredPatch` array — a list of hunks each containing
767/// `{oldStart, oldLines, newStart, newLines, lines: ["-…", "+…", " …"]}` —
768/// from `old_string` and `new_string`.
769///
770/// Drives the side-by-side diff view in Claude Code's UI. Without this the
771/// diff panel renders empty even though the change went through.
772fn structured_patch_hunks(old: &str, new_: &str) -> serde_json::Value {
773    use similar::{ChangeTag, TextDiff};
774    let diff = TextDiff::from_lines(old, new_);
775    let mut hunks = Vec::new();
776    for group in diff.grouped_ops(3) {
777        if group.is_empty() {
778            continue;
779        }
780        let first = group.first().unwrap();
781        let last = group.last().unwrap();
782        let old_start = first.old_range().start + 1;
783        let old_lines = last.old_range().end - first.old_range().start;
784        let new_start = first.new_range().start + 1;
785        let new_lines = last.new_range().end - first.new_range().start;
786        let mut lines: Vec<String> = Vec::new();
787        for op in &group {
788            for change in diff.iter_changes(op) {
789                let prefix = match change.tag() {
790                    ChangeTag::Delete => "-",
791                    ChangeTag::Insert => "+",
792                    ChangeTag::Equal => " ",
793                };
794                let text: &str = change.value();
795                let trimmed = text.trim_end_matches('\n');
796                lines.push(format!("{prefix}{trimmed}"));
797            }
798        }
799        hunks.push(json!({
800            "oldStart": old_start,
801            "oldLines": old_lines,
802            "newStart": new_start,
803            "newLines": new_lines,
804            "lines": lines,
805        }));
806    }
807    serde_json::Value::Array(hunks)
808}
809
810/// Strip Claude's `cat -n`-style line numbering ("    1\tfoo") from a Read
811/// result so the round-tripped `file.content` is the raw file text.
812/// Leaves content alone when no leading-number pattern is detected.
813fn strip_line_numbers(s: &str) -> String {
814    let lines: Vec<&str> = s.lines().collect();
815    let mut all_match = !lines.is_empty();
816    for line in &lines {
817        let trimmed = line.trim_start();
818        let mut chars = trimmed.chars();
819        let has_digit = chars.next().map(|c| c.is_ascii_digit()).unwrap_or(false);
820        let has_tab = trimmed.contains('\t');
821        if !has_digit || !has_tab {
822            all_match = false;
823            break;
824        }
825    }
826    if !all_match {
827        return s.to_string();
828    }
829    lines
830        .iter()
831        .map(|l| {
832            let trimmed = l.trim_start();
833            match trimmed.find('\t') {
834                Some(idx) => trimmed[idx + 1..].to_string(),
835                None => l.to_string(),
836            }
837        })
838        .collect::<Vec<_>>()
839        .join("\n")
840}
841
842/// Build a user entry for a System turn.
843fn system_turn_to_entry(turn: &Turn, session_id: &str) -> ConversationEntry {
844    ConversationEntry {
845        uuid: turn.id.clone(),
846        parent_uuid: turn.parent_id.clone(),
847        is_sidechain: false,
848        entry_type: "user".to_string(),
849        timestamp: turn.timestamp.clone(),
850        session_id: Some(session_id.to_string()),
851        cwd: None,
852        git_branch: None,
853        message: Some(Message {
854            role: MessageRole::System,
855            content: Some(MessageContent::Text(turn.text.clone())),
856            model: None,
857            id: None,
858            message_type: None,
859            stop_reason: None,
860            stop_sequence: None,
861            usage: None,
862        }),
863        version: None,
864        user_type: None,
865        request_id: None,
866        tool_use_result: None,
867        snapshot: None,
868        message_id: None,
869        extra: Default::default(),
870    }
871}
872
873/// Build a user entry for an Other-role turn.
874fn other_turn_to_entry(turn: &Turn, session_id: &str) -> ConversationEntry {
875    ConversationEntry {
876        uuid: turn.id.clone(),
877        parent_uuid: turn.parent_id.clone(),
878        is_sidechain: false,
879        entry_type: "user".to_string(),
880        timestamp: turn.timestamp.clone(),
881        session_id: Some(session_id.to_string()),
882        cwd: None,
883        git_branch: None,
884        message: Some(Message {
885            role: MessageRole::User,
886            content: Some(MessageContent::Text(turn.text.clone())),
887            model: None,
888            id: None,
889            message_type: None,
890            stop_reason: None,
891            stop_sequence: None,
892            usage: None,
893        }),
894        version: None,
895        user_type: None,
896        request_id: None,
897        tool_use_result: None,
898        snapshot: None,
899        message_id: None,
900        extra: Default::default(),
901    }
902}
903
904/// Build a `ConversationEntry` from a [`ConversationEvent`].
905///
906/// Reconstructs the original JSONL entry from the event's data map.
907/// For system events with text, a message is created.
908fn project_event(event: &toolpath_convo::ConversationEvent, session_id: &str) -> ConversationEntry {
909    let mut extra = HashMap::new();
910
911    // Extract entry_extra and merge into top-level extras
912    if let Some(entry_extra) = event.data.get("entry_extra").and_then(|v| v.as_object()) {
913        for (k, v) in entry_extra {
914            extra.insert(k.clone(), v.clone());
915        }
916    }
917
918    // If the event has text (system messages), create a message
919    let message = event
920        .data
921        .get("text")
922        .and_then(|v| v.as_str())
923        .map(|text| Message {
924            role: if event.event_type == "system" {
925                MessageRole::System
926            } else {
927                MessageRole::User
928            },
929            content: Some(MessageContent::Text(text.to_string())),
930            model: None,
931            id: None,
932            message_type: None,
933            stop_reason: None,
934            stop_sequence: None,
935            usage: None,
936        });
937
938    ConversationEntry {
939        uuid: event.id.clone(),
940        entry_type: event.event_type.clone(),
941        timestamp: event.timestamp.clone(),
942        session_id: Some(session_id.into()),
943        parent_uuid: event.parent_id.clone(),
944        is_sidechain: false,
945        message,
946        cwd: event
947            .data
948            .get("cwd")
949            .and_then(|v| v.as_str())
950            .map(|s| s.to_string()),
951        git_branch: event
952            .data
953            .get("git_branch")
954            .and_then(|v| v.as_str())
955            .map(|s| s.to_string()),
956        version: event
957            .data
958            .get("version")
959            .and_then(|v| v.as_str())
960            .map(|s| s.to_string()),
961        user_type: event
962            .data
963            .get("user_type")
964            .and_then(|v| v.as_str())
965            .map(|s| s.to_string()),
966        request_id: None,
967        tool_use_result: event.data.get("tool_use_result").cloned(),
968        snapshot: event.data.get("snapshot").cloned(),
969        message_id: event
970            .data
971            .get("message_id")
972            .and_then(|v| v.as_str())
973            .map(|s| s.to_string()),
974        extra,
975    }
976}
977
978// ── Tests ─────────────────────────────────────────────────────────────
979
980#[cfg(test)]
981mod tests {
982    use super::*;
983    use toolpath_convo::{EnvironmentSnapshot, TokenUsage, ToolResult};
984
985    fn make_view(id: &str, turns: Vec<Turn>) -> ConversationView {
986        ConversationView {
987            id: id.to_string(),
988            started_at: None,
989            last_activity: None,
990            turns,
991            total_usage: None,
992            provider_id: None,
993            files_changed: vec![],
994            session_ids: vec![],
995            events: vec![],
996            ..Default::default()
997        }
998    }
999
1000    fn user_turn(id: &str, text: &str) -> Turn {
1001        Turn {
1002            id: id.to_string(),
1003            parent_id: None,
1004            role: Role::User,
1005            timestamp: "2024-01-01T00:00:00Z".to_string(),
1006            text: text.to_string(),
1007            thinking: None,
1008            tool_uses: vec![],
1009            model: None,
1010            stop_reason: None,
1011            token_usage: None,
1012            environment: None,
1013            delegations: vec![],
1014            file_mutations: Vec::new(),
1015        }
1016    }
1017
1018    fn assistant_turn(id: &str, text: &str) -> Turn {
1019        Turn {
1020            id: id.to_string(),
1021            parent_id: None,
1022            role: Role::Assistant,
1023            timestamp: "2024-01-01T00:00:01Z".to_string(),
1024            text: text.to_string(),
1025            thinking: None,
1026            tool_uses: vec![],
1027            model: None,
1028            stop_reason: None,
1029            token_usage: None,
1030            environment: None,
1031            delegations: vec![],
1032            file_mutations: Vec::new(),
1033        }
1034    }
1035
1036    /// Helper: return all conversation entries (preamble is separate).
1037    fn content_entries(convo: &Conversation) -> &[ConversationEntry] {
1038        &convo.entries
1039    }
1040
1041    // ── Permission-mode preamble ─────────────────────────────────────
1042
1043    #[test]
1044    fn test_permission_mode_in_preamble() {
1045        let view = make_view("sess-1", vec![user_turn("u1", "Hello")]);
1046        let convo = ClaudeProjector.project(&view).unwrap();
1047
1048        assert_eq!(convo.preamble.len(), 1);
1049        let perm = &convo.preamble[0];
1050        assert_eq!(perm["type"], "permission-mode");
1051        assert_eq!(perm["permissionMode"], "default");
1052        assert_eq!(perm["sessionId"], "sess-1");
1053        // Should NOT have uuid, timestamp, isSidechain etc.
1054        assert!(perm.get("uuid").is_none());
1055        assert!(perm.get("timestamp").is_none());
1056    }
1057
1058    // ── Test 1: Basic conversation (user + assistant, no tools) ───────
1059
1060    #[test]
1061    fn test_basic_conversation_entry_count_and_content() {
1062        let view = make_view(
1063            "sess-1",
1064            vec![user_turn("u1", "Hello"), assistant_turn("a1", "Hi there!")],
1065        );
1066        let projector = ClaudeProjector;
1067        let convo = projector.project(&view).unwrap();
1068
1069        assert_eq!(convo.session_id, "sess-1");
1070        let entries = content_entries(&convo);
1071        assert_eq!(entries.len(), 2);
1072
1073        let user_entry = &entries[0];
1074        assert_eq!(user_entry.entry_type, "user");
1075        assert_eq!(user_entry.uuid, "u1");
1076        let msg = user_entry.message.as_ref().unwrap();
1077        assert_eq!(msg.role, MessageRole::User);
1078        assert_eq!(msg.text(), "Hello");
1079
1080        let asst_entry = &entries[1];
1081        assert_eq!(asst_entry.entry_type, "assistant");
1082        assert_eq!(asst_entry.uuid, "a1");
1083        let msg = asst_entry.message.as_ref().unwrap();
1084        assert_eq!(msg.role, MessageRole::Assistant);
1085        assert_eq!(msg.text(), "Hi there!");
1086        // Claude Code requires assistant content to always be an array
1087        assert!(matches!(msg.content, Some(MessageContent::Parts(_))));
1088    }
1089
1090    // ── Test 2: User turn with environment → cwd and git_branch ──────
1091
1092    #[test]
1093    fn test_user_turn_with_environment() {
1094        let mut turn = user_turn("u1", "Hello");
1095        turn.environment = Some(EnvironmentSnapshot {
1096            working_dir: Some("/my/project".to_string()),
1097            vcs_branch: Some("feat/auth".to_string()),
1098            vcs_revision: None,
1099        });
1100
1101        let view = make_view("sess-1", vec![turn]);
1102        let convo = ClaudeProjector.project(&view).unwrap();
1103
1104        let entry = &content_entries(&convo)[0];
1105        assert_eq!(entry.cwd.as_deref(), Some("/my/project"));
1106        assert_eq!(entry.git_branch.as_deref(), Some("feat/auth"));
1107    }
1108
1109    // ── Test 3: Assistant with thinking + text + tool_use → Parts ────
1110
1111    #[test]
1112    fn test_assistant_thinking_text_tool_use_produces_parts() {
1113        let mut turn = assistant_turn("a1", "I'll read the file.");
1114        turn.thinking = Some("Hmm, need to read the file first.".to_string());
1115        turn.tool_uses = vec![ToolInvocation {
1116            id: "t1".to_string(),
1117            name: "Read".to_string(),
1118            input: serde_json::json!({"file_path": "src/main.rs"}),
1119            result: None,
1120            category: None,
1121        }];
1122
1123        let view = make_view("sess-1", vec![turn]);
1124        let convo = ClaudeProjector.project(&view).unwrap();
1125
1126        let entries = content_entries(&convo);
1127        // One assistant entry (no results → no tool-result entry)
1128        assert_eq!(entries.len(), 1);
1129        let entry = &entries[0];
1130        let msg = entry.message.as_ref().unwrap();
1131
1132        match msg.content.as_ref().unwrap() {
1133            MessageContent::Parts(parts) => {
1134                assert_eq!(parts.len(), 3);
1135                // Order: Thinking, Text, ToolUse
1136                assert!(matches!(parts[0], ContentPart::Thinking { .. }));
1137                assert!(matches!(parts[1], ContentPart::Text { .. }));
1138                assert!(matches!(parts[2], ContentPart::ToolUse { .. }));
1139
1140                if let ContentPart::Thinking { thinking, .. } = &parts[0] {
1141                    assert_eq!(thinking, "Hmm, need to read the file first.");
1142                }
1143                if let ContentPart::Text { text } = &parts[1] {
1144                    assert_eq!(text, "I'll read the file.");
1145                }
1146                if let ContentPart::ToolUse { id, name, .. } = &parts[2] {
1147                    assert_eq!(id, "t1");
1148                    assert_eq!(name, "Read");
1149                }
1150            }
1151            other => panic!("Expected Parts, got {:?}", other),
1152        }
1153    }
1154
1155    // ── Test 4: Simple text-only assistant → always Parts (Claude Code requires arrays) ─
1156
1157    #[test]
1158    fn test_simple_text_only_assistant_produces_parts_array() {
1159        let turn = assistant_turn("a1", "Just a plain answer.");
1160
1161        let view = make_view("sess-1", vec![turn]);
1162        let convo = ClaudeProjector.project(&view).unwrap();
1163
1164        let entry = &content_entries(&convo)[0];
1165        let msg = entry.message.as_ref().unwrap();
1166        // Claude Code expects assistant content to always be an array
1167        match &msg.content {
1168            Some(MessageContent::Parts(parts)) => {
1169                assert_eq!(parts.len(), 1);
1170                assert!(
1171                    matches!(&parts[0], ContentPart::Text { text } if text == "Just a plain answer.")
1172                );
1173            }
1174            other => panic!("Expected Parts([Text]), got {:?}", other),
1175        }
1176    }
1177
1178    // ── Test 5: Tool results emitted as separate user entries ─────────
1179
1180    #[test]
1181    fn test_tool_results_emitted_as_separate_user_entries() {
1182        let mut turn = assistant_turn("a1", "Reading file.");
1183        turn.tool_uses = vec![ToolInvocation {
1184            id: "t1".to_string(),
1185            name: "Read".to_string(),
1186            input: serde_json::json!({"file_path": "src/main.rs"}),
1187            result: Some(ToolResult {
1188                content: "fn main() {}".to_string(),
1189                is_error: false,
1190            }),
1191            category: None,
1192        }];
1193
1194        let view = make_view("sess-1", vec![user_turn("u1", "Go"), turn]);
1195        let convo = ClaudeProjector.project(&view).unwrap();
1196
1197        let entries = content_entries(&convo);
1198        // user + assistant + tool-result user
1199        assert_eq!(entries.len(), 3);
1200
1201        let result_entry = &entries[2];
1202        assert_eq!(result_entry.entry_type, "user");
1203        assert_eq!(result_entry.uuid, "a1-result-t1");
1204        assert_eq!(result_entry.parent_uuid.as_deref(), Some("a1"));
1205
1206        let msg = result_entry.message.as_ref().unwrap();
1207        assert_eq!(msg.role, MessageRole::User);
1208
1209        match msg.content.as_ref().unwrap() {
1210            MessageContent::Parts(parts) => {
1211                assert_eq!(parts.len(), 1);
1212                match &parts[0] {
1213                    ContentPart::ToolResult {
1214                        tool_use_id,
1215                        content,
1216                        is_error,
1217                    } => {
1218                        assert_eq!(tool_use_id, "t1");
1219                        assert_eq!(content.text(), "fn main() {}");
1220                        assert!(!is_error);
1221                    }
1222                    other => panic!("Expected ToolResult, got {:?}", other),
1223                }
1224            }
1225            other => panic!("Expected Parts, got {:?}", other),
1226        }
1227    }
1228
1229    // ── Test 6: No tool result entry when tool uses have no results ───
1230
1231    #[test]
1232    fn test_no_tool_result_entry_when_no_results() {
1233        let mut turn = assistant_turn("a1", "Reading...");
1234        turn.tool_uses = vec![ToolInvocation {
1235            id: "t1".to_string(),
1236            name: "Read".to_string(),
1237            input: serde_json::json!({}),
1238            result: None, // no result
1239            category: None,
1240        }];
1241
1242        let view = make_view("sess-1", vec![turn]);
1243        let convo = ClaudeProjector.project(&view).unwrap();
1244
1245        let entries = content_entries(&convo);
1246        // Only the assistant entry, no tool-result entry
1247        assert_eq!(entries.len(), 1);
1248        assert_eq!(entries[0].entry_type, "assistant");
1249    }
1250
1251    // ── Test 7: Token usage mapped correctly (cache field name swap) ──
1252
1253    #[test]
1254    fn test_token_usage_mapped_correctly_with_cache_swap() {
1255        let mut turn = assistant_turn("a1", "Done.");
1256        turn.token_usage = Some(TokenUsage {
1257            input_tokens: Some(100),
1258            output_tokens: Some(50),
1259            cache_read_tokens: Some(500),  // → cache_read_input_tokens
1260            cache_write_tokens: Some(200), // → cache_creation_input_tokens
1261        });
1262
1263        let view = make_view("sess-1", vec![turn]);
1264        let convo = ClaudeProjector.project(&view).unwrap();
1265
1266        let msg = content_entries(&convo)[0].message.as_ref().unwrap();
1267        let usage = msg.usage.as_ref().unwrap();
1268
1269        assert_eq!(usage.input_tokens, Some(100));
1270        assert_eq!(usage.output_tokens, Some(50));
1271        assert_eq!(usage.cache_read_input_tokens, Some(500));
1272        assert_eq!(usage.cache_creation_input_tokens, Some(200));
1273    }
1274
1275    // ── Test 8: Session ID and parent chain preserved ─────────────────
1276
1277    #[test]
1278    fn test_session_id_and_parent_chain_preserved() {
1279        let mut t2 = assistant_turn("a1", "Reply");
1280        t2.parent_id = Some("u1".to_string());
1281        let mut t3 = user_turn("u2", "Second");
1282        t3.parent_id = Some("a1".to_string());
1283
1284        let view = make_view("my-session", vec![user_turn("u1", "First"), t2, t3]);
1285        let convo = ClaudeProjector.project(&view).unwrap();
1286
1287        assert_eq!(convo.session_id, "my-session");
1288        for entry in &convo.entries {
1289            assert_eq!(entry.session_id.as_deref(), Some("my-session"));
1290        }
1291
1292        let entries = content_entries(&convo);
1293        assert_eq!(entries[0].parent_uuid, None);
1294        assert_eq!(entries[1].parent_uuid.as_deref(), Some("u1"));
1295        assert_eq!(entries[2].parent_uuid.as_deref(), Some("a1"));
1296    }
1297
1298    // ── Test 9: Stop reason and model preserved ───────────────────────
1299
1300    #[test]
1301    fn test_stop_reason_and_model_preserved() {
1302        let mut turn = assistant_turn("a1", "Done.");
1303        turn.model = Some("claude-opus-4-6".to_string());
1304        turn.stop_reason = Some("end_turn".to_string());
1305
1306        let view = make_view("sess-1", vec![turn]);
1307        let convo = ClaudeProjector.project(&view).unwrap();
1308
1309        let msg = content_entries(&convo)[0].message.as_ref().unwrap();
1310        assert_eq!(msg.model.as_deref(), Some("claude-opus-4-6"));
1311        assert_eq!(msg.stop_reason.as_deref(), Some("end_turn"));
1312    }
1313
1314    // ── Additional edge case: is_sidechain always false ───────────────
1315
1316    #[test]
1317    fn test_is_sidechain_always_false() {
1318        let view = make_view(
1319            "sess-1",
1320            vec![user_turn("u1", "Hi"), assistant_turn("a1", "Hello")],
1321        );
1322        let convo = ClaudeProjector.project(&view).unwrap();
1323
1324        for entry in &convo.entries {
1325            assert!(!entry.is_sidechain);
1326        }
1327    }
1328
1329    // ── Additional edge case: empty text assistant with tool use ──────
1330
1331    #[test]
1332    fn test_assistant_no_text_only_tool_use_produces_parts() {
1333        let mut turn = assistant_turn("a1", "");
1334        turn.tool_uses = vec![ToolInvocation {
1335            id: "t1".to_string(),
1336            name: "Bash".to_string(),
1337            input: serde_json::json!({"command": "ls"}),
1338            result: None,
1339            category: None,
1340        }];
1341
1342        let view = make_view("sess-1", vec![turn]);
1343        let convo = ClaudeProjector.project(&view).unwrap();
1344
1345        let msg = content_entries(&convo)[0].message.as_ref().unwrap();
1346        match msg.content.as_ref().unwrap() {
1347            MessageContent::Parts(parts) => {
1348                // Empty text not included, just the ToolUse
1349                assert_eq!(parts.len(), 1);
1350                assert!(matches!(parts[0], ContentPart::ToolUse { .. }));
1351            }
1352            other => panic!("Expected Parts, got {:?}", other),
1353        }
1354    }
1355
1356    // ── Additional: multiple tool uses, all with results ─────────────
1357
1358    #[test]
1359    fn test_multiple_tool_uses_all_with_results() {
1360        let mut turn = assistant_turn("a1", "Reading two files.");
1361        turn.tool_uses = vec![
1362            ToolInvocation {
1363                id: "t1".to_string(),
1364                name: "Read".to_string(),
1365                input: serde_json::json!({}),
1366                result: Some(ToolResult {
1367                    content: "file a".to_string(),
1368                    is_error: false,
1369                }),
1370                category: None,
1371            },
1372            ToolInvocation {
1373                id: "t2".to_string(),
1374                name: "Read".to_string(),
1375                input: serde_json::json!({}),
1376                result: Some(ToolResult {
1377                    content: "file b".to_string(),
1378                    is_error: true,
1379                }),
1380                category: None,
1381            },
1382        ];
1383
1384        let view = make_view("sess-1", vec![turn]);
1385        let convo = ClaudeProjector.project(&view).unwrap();
1386
1387        let entries = content_entries(&convo);
1388        // assistant + one tool-result entry per tool_use
1389        assert_eq!(entries.len(), 3);
1390
1391        let r1 = &entries[1];
1392        match r1.message.as_ref().unwrap().content.as_ref().unwrap() {
1393            MessageContent::Parts(parts) => {
1394                assert_eq!(parts.len(), 1);
1395                match &parts[0] {
1396                    ContentPart::ToolResult {
1397                        tool_use_id,
1398                        content,
1399                        is_error,
1400                    } => {
1401                        assert_eq!(tool_use_id, "t1");
1402                        assert_eq!(content.text(), "file a");
1403                        assert!(!is_error);
1404                    }
1405                    _ => panic!("Expected ToolResult at index 0"),
1406                }
1407            }
1408            other => panic!("Expected Parts, got {:?}", other),
1409        }
1410
1411        let r2 = &entries[2];
1412        match r2.message.as_ref().unwrap().content.as_ref().unwrap() {
1413            MessageContent::Parts(parts) => {
1414                assert_eq!(parts.len(), 1);
1415                match &parts[0] {
1416                    ContentPart::ToolResult {
1417                        tool_use_id,
1418                        content,
1419                        is_error,
1420                    } => {
1421                        assert_eq!(tool_use_id, "t2");
1422                        assert_eq!(content.text(), "file b");
1423                        assert!(is_error);
1424                    }
1425                    _ => panic!("Expected ToolResult at index 0"),
1426                }
1427            }
1428            other => panic!("Expected Parts, got {:?}", other),
1429        }
1430    }
1431
1432    // ── Additional: mixed results (some with, some without) ──────────
1433
1434    #[test]
1435    fn test_partial_tool_results_only_emits_those_with_results() {
1436        let mut turn = assistant_turn("a1", "Using tools.");
1437        turn.tool_uses = vec![
1438            ToolInvocation {
1439                id: "t1".to_string(),
1440                name: "Read".to_string(),
1441                input: serde_json::json!({}),
1442                result: Some(ToolResult {
1443                    content: "file content".to_string(),
1444                    is_error: false,
1445                }),
1446                category: None,
1447            },
1448            ToolInvocation {
1449                id: "t2".to_string(),
1450                name: "Write".to_string(),
1451                input: serde_json::json!({}),
1452                result: None, // no result for this one
1453                category: None,
1454            },
1455        ];
1456
1457        let view = make_view("sess-1", vec![turn]);
1458        let convo = ClaudeProjector.project(&view).unwrap();
1459
1460        let entries = content_entries(&convo);
1461        // assistant + tool-result entry (only t1 has a result)
1462        assert_eq!(entries.len(), 2);
1463        let result_entry = &entries[1];
1464        let msg = result_entry.message.as_ref().unwrap();
1465        match msg.content.as_ref().unwrap() {
1466            MessageContent::Parts(parts) => {
1467                // Only one result (t1), not two
1468                assert_eq!(parts.len(), 1);
1469                if let ContentPart::ToolResult { tool_use_id, .. } = &parts[0] {
1470                    assert_eq!(tool_use_id, "t1");
1471                } else {
1472                    panic!("Expected ToolResult");
1473                }
1474            }
1475            other => panic!("Expected Parts, got {:?}", other),
1476        }
1477    }
1478
1479    // ── Tool result entries inherit metadata from parent turn ─────────
1480
1481    #[test]
1482    fn test_tool_result_entry_inherits_metadata() {
1483        let mut turn = assistant_turn("a1", "Reading.");
1484        turn.environment = Some(EnvironmentSnapshot {
1485            working_dir: Some("/project".to_string()),
1486            vcs_branch: Some("dev".to_string()),
1487            vcs_revision: None,
1488        });
1489        turn.tool_uses = vec![ToolInvocation {
1490            id: "t1".to_string(),
1491            name: "Read".to_string(),
1492            input: serde_json::json!({}),
1493            result: Some(ToolResult {
1494                content: "contents".to_string(),
1495                is_error: false,
1496            }),
1497            category: None,
1498        }];
1499
1500        let view = make_view("sess-1", vec![turn]);
1501        let convo = ClaudeProjector.project(&view).unwrap();
1502
1503        let entries = content_entries(&convo);
1504        assert_eq!(entries.len(), 2);
1505
1506        let result_entry = &entries[1];
1507        assert_eq!(result_entry.cwd.as_deref(), Some("/project"));
1508        assert_eq!(result_entry.git_branch.as_deref(), Some("dev"));
1509        // sourceToolAssistantUUID should be the parent turn's ID
1510        assert_eq!(
1511            result_entry.extra.get("sourceToolAssistantUUID"),
1512            Some(&json!("a1"))
1513        );
1514    }
1515
1516    // ── Missing metadata fields don't appear (no nulls) ──────────────
1517
1518    #[test]
1519    fn test_missing_metadata_no_nulls_in_json() {
1520        let turn = user_turn("u1", "Hello");
1521        // No environment, no extra — metadata fields should be absent
1522
1523        let view = make_view("sess-1", vec![turn]);
1524        let convo = ClaudeProjector.project(&view).unwrap();
1525
1526        let entry = &content_entries(&convo)[0];
1527        let json_str = serde_json::to_string(entry).unwrap();
1528        // None fields with skip_serializing_if should not appear
1529        assert!(!json_str.contains("\"version\""));
1530        assert!(!json_str.contains("\"userType\""));
1531        assert!(!json_str.contains("\"requestId\""));
1532        assert!(!json_str.contains("\"gitBranch\""));
1533    }
1534}