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