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///     started_at: None,
36///     last_activity: None,
37///     turns: vec![],
38///     total_usage: None,
39///     provider_id: None,
40///     files_changed: vec![],
41///     session_ids: vec![],
42///     events: vec![],
43/// };
44///
45/// let projector = ClaudeProjector;
46/// let convo = projector.project(&view).unwrap();
47/// assert_eq!(convo.session_id, "my-session");
48/// ```
49pub struct ClaudeProjector;
50
51impl ConversationProjector for ClaudeProjector {
52    type Output = Conversation;
53
54    fn project(&self, view: &ConversationView) -> Result<Conversation> {
55        project_view(view).map_err(|e| ConvoError::Provider(e.to_string()))
56    }
57}
58
59// ── Projection logic ─────────────────────────────────────────────────
60
61fn project_view(view: &ConversationView) -> std::result::Result<Conversation, String> {
62    let mut convo = Conversation::new(view.id.clone());
63
64    // Emit permission-mode preamble as raw JSON (not a ConversationEntry —
65    // real permission-mode lines have only type/permissionMode/sessionId)
66    convo.preamble.push(json!({
67        "type": "permission-mode",
68        "permissionMode": "default",
69        "sessionId": view.id,
70    }));
71
72    for turn in &view.turns {
73        match &turn.role {
74            Role::User => {
75                let mut entry = user_turn_to_entry(turn, &view.id);
76                apply_turn_metadata(&mut entry, turn);
77                convo.add_entry(entry);
78            }
79            Role::Assistant => {
80                let mut assistant_entry = assistant_turn_to_entry(turn, &view.id);
81                apply_turn_metadata(&mut assistant_entry, turn);
82                convo.add_entry(assistant_entry);
83
84                // Emit a separate tool-result user entry if any tool uses have results
85                if let Some(mut result_entry) = tool_result_entry(turn, &view.id) {
86                    apply_turn_metadata(&mut result_entry, turn);
87                    convo.add_entry(result_entry);
88                }
89            }
90            Role::System => {
91                let mut entry = system_turn_to_entry(turn, &view.id);
92                apply_turn_metadata(&mut entry, turn);
93                convo.add_entry(entry);
94            }
95            Role::Other(_) => {
96                let mut entry = other_turn_to_entry(turn, &view.id);
97                apply_turn_metadata(&mut entry, turn);
98                convo.add_entry(entry);
99            }
100        }
101    }
102
103    // Emit non-message events
104    for event in &view.events {
105        let entry = project_event(event, &view.id);
106        convo.add_entry(entry);
107    }
108
109    Ok(convo)
110}
111
112/// Apply Claude-specific metadata from a [`Turn`] onto a [`ConversationEntry`].
113///
114/// Populates `cwd` and `git_branch` from [`Turn::environment`], and
115/// `version`, `user_type`, `request_id` from `Turn::extra["claude"]`.
116/// Remaining keys from the `"claude"` extras are merged into the entry's
117/// own `extra` map so they serialize as top-level fields (via `#[serde(flatten)]`).
118fn apply_turn_metadata(entry: &mut ConversationEntry, turn: &Turn) {
119    // From Turn.environment
120    if let Some(env) = &turn.environment {
121        if entry.cwd.is_none() {
122            entry.cwd = env.working_dir.clone();
123        }
124        if entry.git_branch.is_none() {
125            entry.git_branch = env.vcs_branch.clone();
126        }
127    }
128
129    // From Turn.extra["claude"]
130    if let Some(claude) = turn.extra.get("claude").and_then(|v| v.as_object()) {
131        if let Some(v) = claude.get("version").and_then(|v| v.as_str()) {
132            entry.version = entry.version.take().or_else(|| Some(v.to_string()));
133        }
134        if let Some(v) = claude.get("user_type").and_then(|v| v.as_str()) {
135            entry.user_type = entry.user_type.take().or_else(|| Some(v.to_string()));
136        }
137        if let Some(v) = claude.get("request_id").and_then(|v| v.as_str()) {
138            entry.request_id = entry.request_id.take().or_else(|| Some(v.to_string()));
139        }
140        // Merge remaining fields into entry.extra
141        for (k, v) in claude {
142            match k.as_str() {
143                "version" | "user_type" | "request_id" => {} // Already handled above
144                _ => {
145                    entry.extra.entry(k.clone()).or_insert_with(|| v.clone());
146                }
147            }
148        }
149    }
150}
151
152/// Build a `ConversationEntry` for a user turn.
153fn user_turn_to_entry(turn: &Turn, session_id: &str) -> ConversationEntry {
154    let content = MessageContent::Text(turn.text.clone());
155
156    ConversationEntry {
157        uuid: turn.id.clone(),
158        parent_uuid: turn.parent_id.clone(),
159        is_sidechain: false,
160        entry_type: "user".to_string(),
161        timestamp: turn.timestamp.clone(),
162        session_id: Some(session_id.to_string()),
163        cwd: turn
164            .environment
165            .as_ref()
166            .and_then(|e| e.working_dir.clone()),
167        git_branch: turn.environment.as_ref().and_then(|e| e.vcs_branch.clone()),
168        message: Some(Message {
169            role: MessageRole::User,
170            content: Some(content),
171            model: None,
172            id: None,
173            message_type: None,
174            stop_reason: None,
175            stop_sequence: None,
176            usage: None,
177        }),
178        version: None,
179        user_type: None,
180        request_id: None,
181        tool_use_result: None,
182        snapshot: None,
183        message_id: None,
184        extra: Default::default(),
185    }
186}
187
188/// Build a `ConversationEntry` for an assistant turn.
189fn assistant_turn_to_entry(turn: &Turn, session_id: &str) -> ConversationEntry {
190    let content = build_assistant_content(turn);
191
192    let usage = turn.token_usage.as_ref().map(|u| Usage {
193        input_tokens: u.input_tokens,
194        output_tokens: u.output_tokens,
195        // TokenUsage uses cache_write_tokens; Usage uses cache_creation_input_tokens
196        cache_creation_input_tokens: u.cache_write_tokens,
197        cache_read_input_tokens: u.cache_read_tokens,
198        cache_creation: None,
199        service_tier: None,
200    });
201
202    ConversationEntry {
203        uuid: turn.id.clone(),
204        parent_uuid: turn.parent_id.clone(),
205        is_sidechain: false,
206        entry_type: "assistant".to_string(),
207        timestamp: turn.timestamp.clone(),
208        session_id: Some(session_id.to_string()),
209        cwd: None,
210        git_branch: None,
211        message: Some(Message {
212            role: MessageRole::Assistant,
213            content: Some(content),
214            model: turn.model.clone(),
215            id: None,
216            message_type: None,
217            stop_reason: turn.stop_reason.clone(),
218            stop_sequence: None,
219            usage,
220        }),
221        version: None,
222        user_type: None,
223        request_id: None,
224        tool_use_result: None,
225        snapshot: None,
226        message_id: None,
227        extra: Default::default(),
228    }
229}
230
231/// Build the `MessageContent` for an assistant turn.
232///
233/// If the turn has ONLY text (no thinking, no tool_uses): returns
234/// `MessageContent::Text`. Otherwise builds `MessageContent::Parts`.
235fn build_assistant_content(turn: &Turn) -> MessageContent {
236    let has_thinking = turn.thinking.is_some();
237    let has_tool_uses = !turn.tool_uses.is_empty();
238
239    if !has_thinking && !has_tool_uses {
240        // Claude Code expects assistant content to always be an array,
241        // even for simple text-only responses.
242        return MessageContent::Parts(vec![ContentPart::Text {
243            text: turn.text.clone(),
244        }]);
245    }
246
247    let mut parts: Vec<ContentPart> = Vec::new();
248
249    if let Some(thinking) = &turn.thinking {
250        parts.push(ContentPart::Thinking {
251            thinking: thinking.clone(),
252            signature: None,
253        });
254    }
255
256    if !turn.text.is_empty() {
257        parts.push(ContentPart::Text {
258            text: turn.text.clone(),
259        });
260    }
261
262    for tu in &turn.tool_uses {
263        parts.push(ContentPart::ToolUse {
264            id: tu.id.clone(),
265            name: tu.name.clone(),
266            input: tu.input.clone(),
267        });
268    }
269
270    MessageContent::Parts(parts)
271}
272
273/// Build a tool-result user entry for tool uses that have results.
274///
275/// Returns `None` if no tool use has a result.
276fn tool_result_entry(turn: &Turn, session_id: &str) -> Option<ConversationEntry> {
277    let result_parts: Vec<ContentPart> = turn
278        .tool_uses
279        .iter()
280        .filter_map(build_tool_result_part)
281        .collect();
282
283    if result_parts.is_empty() {
284        return None;
285    }
286
287    let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
288    extra.insert("sourceToolAssistantUUID".to_string(), json!(turn.id));
289
290    Some(ConversationEntry {
291        uuid: format!("{}-result", turn.id),
292        parent_uuid: Some(turn.id.clone()),
293        is_sidechain: false,
294        entry_type: "user".to_string(),
295        timestamp: turn.timestamp.clone(),
296        session_id: Some(session_id.to_string()),
297        cwd: None,
298        git_branch: None,
299        message: Some(Message {
300            role: MessageRole::User,
301            content: Some(MessageContent::Parts(result_parts)),
302            model: None,
303            id: None,
304            message_type: None,
305            stop_reason: None,
306            stop_sequence: None,
307            usage: None,
308        }),
309        version: None,
310        user_type: None,
311        request_id: None,
312        tool_use_result: None,
313        snapshot: None,
314        message_id: None,
315        extra,
316    })
317}
318
319/// Build a `ContentPart::ToolResult` from a `ToolInvocation` if it has a result.
320fn build_tool_result_part(tu: &ToolInvocation) -> Option<ContentPart> {
321    tu.result.as_ref().map(|r| ContentPart::ToolResult {
322        tool_use_id: tu.id.clone(),
323        content: ToolResultContent::Text(r.content.clone()),
324        is_error: r.is_error,
325    })
326}
327
328/// Build a user entry for a System turn.
329fn system_turn_to_entry(turn: &Turn, session_id: &str) -> ConversationEntry {
330    ConversationEntry {
331        uuid: turn.id.clone(),
332        parent_uuid: turn.parent_id.clone(),
333        is_sidechain: false,
334        entry_type: "user".to_string(),
335        timestamp: turn.timestamp.clone(),
336        session_id: Some(session_id.to_string()),
337        cwd: None,
338        git_branch: None,
339        message: Some(Message {
340            role: MessageRole::System,
341            content: Some(MessageContent::Text(turn.text.clone())),
342            model: None,
343            id: None,
344            message_type: None,
345            stop_reason: None,
346            stop_sequence: None,
347            usage: None,
348        }),
349        version: None,
350        user_type: None,
351        request_id: None,
352        tool_use_result: None,
353        snapshot: None,
354        message_id: None,
355        extra: Default::default(),
356    }
357}
358
359/// Build a user entry for an Other-role turn.
360fn other_turn_to_entry(turn: &Turn, session_id: &str) -> ConversationEntry {
361    ConversationEntry {
362        uuid: turn.id.clone(),
363        parent_uuid: turn.parent_id.clone(),
364        is_sidechain: false,
365        entry_type: "user".to_string(),
366        timestamp: turn.timestamp.clone(),
367        session_id: Some(session_id.to_string()),
368        cwd: None,
369        git_branch: None,
370        message: Some(Message {
371            role: MessageRole::User,
372            content: Some(MessageContent::Text(turn.text.clone())),
373            model: None,
374            id: None,
375            message_type: None,
376            stop_reason: None,
377            stop_sequence: None,
378            usage: None,
379        }),
380        version: None,
381        user_type: None,
382        request_id: None,
383        tool_use_result: None,
384        snapshot: None,
385        message_id: None,
386        extra: Default::default(),
387    }
388}
389
390/// Build a `ConversationEntry` from a [`ConversationEvent`].
391///
392/// Reconstructs the original JSONL entry from the event's data map.
393/// For system events with text, a message is created.
394fn project_event(event: &toolpath_convo::ConversationEvent, session_id: &str) -> ConversationEntry {
395    let mut extra = HashMap::new();
396
397    // Extract entry_extra and merge into top-level extras
398    if let Some(entry_extra) = event.data.get("entry_extra").and_then(|v| v.as_object()) {
399        for (k, v) in entry_extra {
400            extra.insert(k.clone(), v.clone());
401        }
402    }
403
404    // If the event has text (system messages), create a message
405    let message = event
406        .data
407        .get("text")
408        .and_then(|v| v.as_str())
409        .map(|text| Message {
410            role: if event.event_type == "system" {
411                MessageRole::System
412            } else {
413                MessageRole::User
414            },
415            content: Some(MessageContent::Text(text.to_string())),
416            model: None,
417            id: None,
418            message_type: None,
419            stop_reason: None,
420            stop_sequence: None,
421            usage: None,
422        });
423
424    ConversationEntry {
425        uuid: event.id.clone(),
426        entry_type: event.event_type.clone(),
427        timestamp: event.timestamp.clone(),
428        session_id: Some(session_id.into()),
429        parent_uuid: event.parent_id.clone(),
430        is_sidechain: false,
431        message,
432        cwd: event
433            .data
434            .get("cwd")
435            .and_then(|v| v.as_str())
436            .map(|s| s.to_string()),
437        git_branch: event
438            .data
439            .get("git_branch")
440            .and_then(|v| v.as_str())
441            .map(|s| s.to_string()),
442        version: event
443            .data
444            .get("version")
445            .and_then(|v| v.as_str())
446            .map(|s| s.to_string()),
447        user_type: event
448            .data
449            .get("user_type")
450            .and_then(|v| v.as_str())
451            .map(|s| s.to_string()),
452        request_id: None,
453        tool_use_result: event.data.get("tool_use_result").cloned(),
454        snapshot: event.data.get("snapshot").cloned(),
455        message_id: event
456            .data
457            .get("message_id")
458            .and_then(|v| v.as_str())
459            .map(|s| s.to_string()),
460        extra,
461    }
462}
463
464// ── Tests ─────────────────────────────────────────────────────────────
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use toolpath_convo::{EnvironmentSnapshot, TokenUsage, ToolResult};
470
471    fn make_view(id: &str, turns: Vec<Turn>) -> ConversationView {
472        ConversationView {
473            id: id.to_string(),
474            started_at: None,
475            last_activity: None,
476            turns,
477            total_usage: None,
478            provider_id: None,
479            files_changed: vec![],
480            session_ids: vec![],
481            events: vec![],
482        }
483    }
484
485    fn user_turn(id: &str, text: &str) -> Turn {
486        Turn {
487            id: id.to_string(),
488            parent_id: None,
489            role: Role::User,
490            timestamp: "2024-01-01T00:00:00Z".to_string(),
491            text: text.to_string(),
492            thinking: None,
493            tool_uses: vec![],
494            model: None,
495            stop_reason: None,
496            token_usage: None,
497            environment: None,
498            delegations: vec![],
499            extra: Default::default(),
500        }
501    }
502
503    fn assistant_turn(id: &str, text: &str) -> Turn {
504        Turn {
505            id: id.to_string(),
506            parent_id: None,
507            role: Role::Assistant,
508            timestamp: "2024-01-01T00:00:01Z".to_string(),
509            text: text.to_string(),
510            thinking: None,
511            tool_uses: vec![],
512            model: None,
513            stop_reason: None,
514            token_usage: None,
515            environment: None,
516            delegations: vec![],
517            extra: Default::default(),
518        }
519    }
520
521    /// Helper: return all conversation entries (preamble is separate).
522    fn content_entries(convo: &Conversation) -> &[ConversationEntry] {
523        &convo.entries
524    }
525
526    // ── Permission-mode preamble ─────────────────────────────────────
527
528    #[test]
529    fn test_permission_mode_in_preamble() {
530        let view = make_view("sess-1", vec![user_turn("u1", "Hello")]);
531        let convo = ClaudeProjector.project(&view).unwrap();
532
533        assert_eq!(convo.preamble.len(), 1);
534        let perm = &convo.preamble[0];
535        assert_eq!(perm["type"], "permission-mode");
536        assert_eq!(perm["permissionMode"], "default");
537        assert_eq!(perm["sessionId"], "sess-1");
538        // Should NOT have uuid, timestamp, isSidechain etc.
539        assert!(perm.get("uuid").is_none());
540        assert!(perm.get("timestamp").is_none());
541    }
542
543    // ── Test 1: Basic conversation (user + assistant, no tools) ───────
544
545    #[test]
546    fn test_basic_conversation_entry_count_and_content() {
547        let view = make_view(
548            "sess-1",
549            vec![user_turn("u1", "Hello"), assistant_turn("a1", "Hi there!")],
550        );
551        let projector = ClaudeProjector;
552        let convo = projector.project(&view).unwrap();
553
554        assert_eq!(convo.session_id, "sess-1");
555        let entries = content_entries(&convo);
556        assert_eq!(entries.len(), 2);
557
558        let user_entry = &entries[0];
559        assert_eq!(user_entry.entry_type, "user");
560        assert_eq!(user_entry.uuid, "u1");
561        let msg = user_entry.message.as_ref().unwrap();
562        assert_eq!(msg.role, MessageRole::User);
563        assert_eq!(msg.text(), "Hello");
564
565        let asst_entry = &entries[1];
566        assert_eq!(asst_entry.entry_type, "assistant");
567        assert_eq!(asst_entry.uuid, "a1");
568        let msg = asst_entry.message.as_ref().unwrap();
569        assert_eq!(msg.role, MessageRole::Assistant);
570        assert_eq!(msg.text(), "Hi there!");
571        // Claude Code requires assistant content to always be an array
572        assert!(matches!(msg.content, Some(MessageContent::Parts(_))));
573    }
574
575    // ── Test 2: User turn with environment → cwd and git_branch ──────
576
577    #[test]
578    fn test_user_turn_with_environment() {
579        let mut turn = user_turn("u1", "Hello");
580        turn.environment = Some(EnvironmentSnapshot {
581            working_dir: Some("/my/project".to_string()),
582            vcs_branch: Some("feat/auth".to_string()),
583            vcs_revision: None,
584        });
585
586        let view = make_view("sess-1", vec![turn]);
587        let convo = ClaudeProjector.project(&view).unwrap();
588
589        let entry = &content_entries(&convo)[0];
590        assert_eq!(entry.cwd.as_deref(), Some("/my/project"));
591        assert_eq!(entry.git_branch.as_deref(), Some("feat/auth"));
592    }
593
594    // ── Test 3: Assistant with thinking + text + tool_use → Parts ────
595
596    #[test]
597    fn test_assistant_thinking_text_tool_use_produces_parts() {
598        let mut turn = assistant_turn("a1", "I'll read the file.");
599        turn.thinking = Some("Hmm, need to read the file first.".to_string());
600        turn.tool_uses = vec![ToolInvocation {
601            id: "t1".to_string(),
602            name: "Read".to_string(),
603            input: serde_json::json!({"file_path": "src/main.rs"}),
604            result: None,
605            category: None,
606        }];
607
608        let view = make_view("sess-1", vec![turn]);
609        let convo = ClaudeProjector.project(&view).unwrap();
610
611        let entries = content_entries(&convo);
612        // One assistant entry (no results → no tool-result entry)
613        assert_eq!(entries.len(), 1);
614        let entry = &entries[0];
615        let msg = entry.message.as_ref().unwrap();
616
617        match msg.content.as_ref().unwrap() {
618            MessageContent::Parts(parts) => {
619                assert_eq!(parts.len(), 3);
620                // Order: Thinking, Text, ToolUse
621                assert!(matches!(parts[0], ContentPart::Thinking { .. }));
622                assert!(matches!(parts[1], ContentPart::Text { .. }));
623                assert!(matches!(parts[2], ContentPart::ToolUse { .. }));
624
625                if let ContentPart::Thinking { thinking, .. } = &parts[0] {
626                    assert_eq!(thinking, "Hmm, need to read the file first.");
627                }
628                if let ContentPart::Text { text } = &parts[1] {
629                    assert_eq!(text, "I'll read the file.");
630                }
631                if let ContentPart::ToolUse { id, name, .. } = &parts[2] {
632                    assert_eq!(id, "t1");
633                    assert_eq!(name, "Read");
634                }
635            }
636            other => panic!("Expected Parts, got {:?}", other),
637        }
638    }
639
640    // ── Test 4: Simple text-only assistant → always Parts (Claude Code requires arrays) ─
641
642    #[test]
643    fn test_simple_text_only_assistant_produces_parts_array() {
644        let turn = assistant_turn("a1", "Just a plain answer.");
645
646        let view = make_view("sess-1", vec![turn]);
647        let convo = ClaudeProjector.project(&view).unwrap();
648
649        let entry = &content_entries(&convo)[0];
650        let msg = entry.message.as_ref().unwrap();
651        // Claude Code expects assistant content to always be an array
652        match &msg.content {
653            Some(MessageContent::Parts(parts)) => {
654                assert_eq!(parts.len(), 1);
655                assert!(
656                    matches!(&parts[0], ContentPart::Text { text } if text == "Just a plain answer.")
657                );
658            }
659            other => panic!("Expected Parts([Text]), got {:?}", other),
660        }
661    }
662
663    // ── Test 5: Tool results emitted as separate user entries ─────────
664
665    #[test]
666    fn test_tool_results_emitted_as_separate_user_entries() {
667        let mut turn = assistant_turn("a1", "Reading file.");
668        turn.tool_uses = vec![ToolInvocation {
669            id: "t1".to_string(),
670            name: "Read".to_string(),
671            input: serde_json::json!({"file_path": "src/main.rs"}),
672            result: Some(ToolResult {
673                content: "fn main() {}".to_string(),
674                is_error: false,
675            }),
676            category: None,
677        }];
678
679        let view = make_view("sess-1", vec![user_turn("u1", "Go"), turn]);
680        let convo = ClaudeProjector.project(&view).unwrap();
681
682        let entries = content_entries(&convo);
683        // user + assistant + tool-result user
684        assert_eq!(entries.len(), 3);
685
686        let result_entry = &entries[2];
687        assert_eq!(result_entry.entry_type, "user");
688        assert_eq!(result_entry.uuid, "a1-result");
689        assert_eq!(result_entry.parent_uuid.as_deref(), Some("a1"));
690
691        let msg = result_entry.message.as_ref().unwrap();
692        assert_eq!(msg.role, MessageRole::User);
693
694        match msg.content.as_ref().unwrap() {
695            MessageContent::Parts(parts) => {
696                assert_eq!(parts.len(), 1);
697                match &parts[0] {
698                    ContentPart::ToolResult {
699                        tool_use_id,
700                        content,
701                        is_error,
702                    } => {
703                        assert_eq!(tool_use_id, "t1");
704                        assert_eq!(content.text(), "fn main() {}");
705                        assert!(!is_error);
706                    }
707                    other => panic!("Expected ToolResult, got {:?}", other),
708                }
709            }
710            other => panic!("Expected Parts, got {:?}", other),
711        }
712    }
713
714    // ── Test 6: No tool result entry when tool uses have no results ───
715
716    #[test]
717    fn test_no_tool_result_entry_when_no_results() {
718        let mut turn = assistant_turn("a1", "Reading...");
719        turn.tool_uses = vec![ToolInvocation {
720            id: "t1".to_string(),
721            name: "Read".to_string(),
722            input: serde_json::json!({}),
723            result: None, // no result
724            category: None,
725        }];
726
727        let view = make_view("sess-1", vec![turn]);
728        let convo = ClaudeProjector.project(&view).unwrap();
729
730        let entries = content_entries(&convo);
731        // Only the assistant entry, no tool-result entry
732        assert_eq!(entries.len(), 1);
733        assert_eq!(entries[0].entry_type, "assistant");
734    }
735
736    // ── Test 7: Token usage mapped correctly (cache field name swap) ──
737
738    #[test]
739    fn test_token_usage_mapped_correctly_with_cache_swap() {
740        let mut turn = assistant_turn("a1", "Done.");
741        turn.token_usage = Some(TokenUsage {
742            input_tokens: Some(100),
743            output_tokens: Some(50),
744            cache_read_tokens: Some(500),  // → cache_read_input_tokens
745            cache_write_tokens: Some(200), // → cache_creation_input_tokens
746        });
747
748        let view = make_view("sess-1", vec![turn]);
749        let convo = ClaudeProjector.project(&view).unwrap();
750
751        let msg = content_entries(&convo)[0].message.as_ref().unwrap();
752        let usage = msg.usage.as_ref().unwrap();
753
754        assert_eq!(usage.input_tokens, Some(100));
755        assert_eq!(usage.output_tokens, Some(50));
756        assert_eq!(usage.cache_read_input_tokens, Some(500));
757        assert_eq!(usage.cache_creation_input_tokens, Some(200));
758    }
759
760    // ── Test 8: Session ID and parent chain preserved ─────────────────
761
762    #[test]
763    fn test_session_id_and_parent_chain_preserved() {
764        let mut t2 = assistant_turn("a1", "Reply");
765        t2.parent_id = Some("u1".to_string());
766        let mut t3 = user_turn("u2", "Second");
767        t3.parent_id = Some("a1".to_string());
768
769        let view = make_view("my-session", vec![user_turn("u1", "First"), t2, t3]);
770        let convo = ClaudeProjector.project(&view).unwrap();
771
772        assert_eq!(convo.session_id, "my-session");
773        for entry in &convo.entries {
774            assert_eq!(entry.session_id.as_deref(), Some("my-session"));
775        }
776
777        let entries = content_entries(&convo);
778        assert_eq!(entries[0].parent_uuid, None);
779        assert_eq!(entries[1].parent_uuid.as_deref(), Some("u1"));
780        assert_eq!(entries[2].parent_uuid.as_deref(), Some("a1"));
781    }
782
783    // ── Test 9: Stop reason and model preserved ───────────────────────
784
785    #[test]
786    fn test_stop_reason_and_model_preserved() {
787        let mut turn = assistant_turn("a1", "Done.");
788        turn.model = Some("claude-opus-4-6".to_string());
789        turn.stop_reason = Some("end_turn".to_string());
790
791        let view = make_view("sess-1", vec![turn]);
792        let convo = ClaudeProjector.project(&view).unwrap();
793
794        let msg = content_entries(&convo)[0].message.as_ref().unwrap();
795        assert_eq!(msg.model.as_deref(), Some("claude-opus-4-6"));
796        assert_eq!(msg.stop_reason.as_deref(), Some("end_turn"));
797    }
798
799    // ── Additional edge case: is_sidechain always false ───────────────
800
801    #[test]
802    fn test_is_sidechain_always_false() {
803        let view = make_view(
804            "sess-1",
805            vec![user_turn("u1", "Hi"), assistant_turn("a1", "Hello")],
806        );
807        let convo = ClaudeProjector.project(&view).unwrap();
808
809        for entry in &convo.entries {
810            assert!(!entry.is_sidechain);
811        }
812    }
813
814    // ── Additional edge case: empty text assistant with tool use ──────
815
816    #[test]
817    fn test_assistant_no_text_only_tool_use_produces_parts() {
818        let mut turn = assistant_turn("a1", "");
819        turn.tool_uses = vec![ToolInvocation {
820            id: "t1".to_string(),
821            name: "Bash".to_string(),
822            input: serde_json::json!({"command": "ls"}),
823            result: None,
824            category: None,
825        }];
826
827        let view = make_view("sess-1", vec![turn]);
828        let convo = ClaudeProjector.project(&view).unwrap();
829
830        let msg = content_entries(&convo)[0].message.as_ref().unwrap();
831        match msg.content.as_ref().unwrap() {
832            MessageContent::Parts(parts) => {
833                // Empty text not included, just the ToolUse
834                assert_eq!(parts.len(), 1);
835                assert!(matches!(parts[0], ContentPart::ToolUse { .. }));
836            }
837            other => panic!("Expected Parts, got {:?}", other),
838        }
839    }
840
841    // ── Additional: multiple tool uses, all with results ─────────────
842
843    #[test]
844    fn test_multiple_tool_uses_all_with_results() {
845        let mut turn = assistant_turn("a1", "Reading two files.");
846        turn.tool_uses = vec![
847            ToolInvocation {
848                id: "t1".to_string(),
849                name: "Read".to_string(),
850                input: serde_json::json!({}),
851                result: Some(ToolResult {
852                    content: "file a".to_string(),
853                    is_error: false,
854                }),
855                category: None,
856            },
857            ToolInvocation {
858                id: "t2".to_string(),
859                name: "Read".to_string(),
860                input: serde_json::json!({}),
861                result: Some(ToolResult {
862                    content: "file b".to_string(),
863                    is_error: true,
864                }),
865                category: None,
866            },
867        ];
868
869        let view = make_view("sess-1", vec![turn]);
870        let convo = ClaudeProjector.project(&view).unwrap();
871
872        let entries = content_entries(&convo);
873        // assistant + tool-result entry
874        assert_eq!(entries.len(), 2);
875
876        let result_entry = &entries[1];
877        let msg = result_entry.message.as_ref().unwrap();
878        match msg.content.as_ref().unwrap() {
879            MessageContent::Parts(parts) => {
880                assert_eq!(parts.len(), 2);
881                match &parts[0] {
882                    ContentPart::ToolResult {
883                        tool_use_id,
884                        content,
885                        is_error,
886                    } => {
887                        assert_eq!(tool_use_id, "t1");
888                        assert_eq!(content.text(), "file a");
889                        assert!(!is_error);
890                    }
891                    _ => panic!("Expected ToolResult at index 0"),
892                }
893                match &parts[1] {
894                    ContentPart::ToolResult {
895                        tool_use_id,
896                        content,
897                        is_error,
898                    } => {
899                        assert_eq!(tool_use_id, "t2");
900                        assert_eq!(content.text(), "file b");
901                        assert!(is_error);
902                    }
903                    _ => panic!("Expected ToolResult at index 1"),
904                }
905            }
906            other => panic!("Expected Parts, got {:?}", other),
907        }
908    }
909
910    // ── Additional: mixed results (some with, some without) ──────────
911
912    #[test]
913    fn test_partial_tool_results_only_emits_those_with_results() {
914        let mut turn = assistant_turn("a1", "Using tools.");
915        turn.tool_uses = vec![
916            ToolInvocation {
917                id: "t1".to_string(),
918                name: "Read".to_string(),
919                input: serde_json::json!({}),
920                result: Some(ToolResult {
921                    content: "file content".to_string(),
922                    is_error: false,
923                }),
924                category: None,
925            },
926            ToolInvocation {
927                id: "t2".to_string(),
928                name: "Write".to_string(),
929                input: serde_json::json!({}),
930                result: None, // no result for this one
931                category: None,
932            },
933        ];
934
935        let view = make_view("sess-1", vec![turn]);
936        let convo = ClaudeProjector.project(&view).unwrap();
937
938        let entries = content_entries(&convo);
939        // assistant + tool-result entry (only t1 has a result)
940        assert_eq!(entries.len(), 2);
941        let result_entry = &entries[1];
942        let msg = result_entry.message.as_ref().unwrap();
943        match msg.content.as_ref().unwrap() {
944            MessageContent::Parts(parts) => {
945                // Only one result (t1), not two
946                assert_eq!(parts.len(), 1);
947                if let ContentPart::ToolResult { tool_use_id, .. } = &parts[0] {
948                    assert_eq!(tool_use_id, "t1");
949                } else {
950                    panic!("Expected ToolResult");
951                }
952            }
953            other => panic!("Expected Parts, got {:?}", other),
954        }
955    }
956
957    // ── Metadata: user entries get cwd, gitBranch, version, userType ─
958
959    #[test]
960    fn test_user_entry_metadata_from_turn() {
961        let mut turn = user_turn("u1", "Hello");
962        turn.environment = Some(EnvironmentSnapshot {
963            working_dir: Some("/home/user/project".to_string()),
964            vcs_branch: Some("main".to_string()),
965            vcs_revision: None,
966        });
967        turn.extra.insert(
968            "claude".to_string(),
969            json!({
970                "version": "2.1.37",
971                "user_type": "external",
972                "entrypoint": "cli",
973            }),
974        );
975
976        let view = make_view("sess-1", vec![turn]);
977        let convo = ClaudeProjector.project(&view).unwrap();
978
979        let entry = &content_entries(&convo)[0];
980        assert_eq!(entry.cwd.as_deref(), Some("/home/user/project"));
981        assert_eq!(entry.git_branch.as_deref(), Some("main"));
982        assert_eq!(entry.version.as_deref(), Some("2.1.37"));
983        assert_eq!(entry.user_type.as_deref(), Some("external"));
984        assert_eq!(entry.extra.get("entrypoint"), Some(&json!("cli")));
985    }
986
987    // ── Metadata: assistant entries get requestId ─────────────────────
988
989    #[test]
990    fn test_assistant_entry_metadata_request_id() {
991        let mut turn = assistant_turn("a1", "Done.");
992        turn.extra.insert(
993            "claude".to_string(),
994            json!({
995                "request_id": "req_abc123",
996                "version": "2.1.37",
997            }),
998        );
999
1000        let view = make_view("sess-1", vec![turn]);
1001        let convo = ClaudeProjector.project(&view).unwrap();
1002
1003        let entry = &content_entries(&convo)[0];
1004        assert_eq!(entry.request_id.as_deref(), Some("req_abc123"));
1005        assert_eq!(entry.version.as_deref(), Some("2.1.37"));
1006    }
1007
1008    // ── Metadata: extras (entrypoint, isMeta, slug) appear ───────────
1009
1010    #[test]
1011    fn test_entry_extras_appear_in_projected_entries() {
1012        let mut turn = user_turn("u1", "Hello");
1013        turn.extra.insert(
1014            "claude".to_string(),
1015            json!({
1016                "entrypoint": "cli",
1017                "isMeta": true,
1018                "slug": "my-slug",
1019            }),
1020        );
1021
1022        let view = make_view("sess-1", vec![turn]);
1023        let convo = ClaudeProjector.project(&view).unwrap();
1024
1025        let entry = &content_entries(&convo)[0];
1026        assert_eq!(entry.extra.get("entrypoint"), Some(&json!("cli")));
1027        assert_eq!(entry.extra.get("isMeta"), Some(&json!(true)));
1028        assert_eq!(entry.extra.get("slug"), Some(&json!("my-slug")));
1029    }
1030
1031    // ── Tool result entries inherit metadata from parent turn ─────────
1032
1033    #[test]
1034    fn test_tool_result_entry_inherits_metadata() {
1035        let mut turn = assistant_turn("a1", "Reading.");
1036        turn.environment = Some(EnvironmentSnapshot {
1037            working_dir: Some("/project".to_string()),
1038            vcs_branch: Some("dev".to_string()),
1039            vcs_revision: None,
1040        });
1041        turn.extra.insert(
1042            "claude".to_string(),
1043            json!({
1044                "version": "2.1.37",
1045                "user_type": "external",
1046                "entrypoint": "cli",
1047            }),
1048        );
1049        turn.tool_uses = vec![ToolInvocation {
1050            id: "t1".to_string(),
1051            name: "Read".to_string(),
1052            input: serde_json::json!({}),
1053            result: Some(ToolResult {
1054                content: "contents".to_string(),
1055                is_error: false,
1056            }),
1057            category: None,
1058        }];
1059
1060        let view = make_view("sess-1", vec![turn]);
1061        let convo = ClaudeProjector.project(&view).unwrap();
1062
1063        let entries = content_entries(&convo);
1064        assert_eq!(entries.len(), 2);
1065
1066        let result_entry = &entries[1];
1067        assert_eq!(result_entry.cwd.as_deref(), Some("/project"));
1068        assert_eq!(result_entry.git_branch.as_deref(), Some("dev"));
1069        assert_eq!(result_entry.version.as_deref(), Some("2.1.37"));
1070        assert_eq!(result_entry.user_type.as_deref(), Some("external"));
1071        assert_eq!(result_entry.extra.get("entrypoint"), Some(&json!("cli")));
1072        // sourceToolAssistantUUID should be the parent turn's ID
1073        assert_eq!(
1074            result_entry.extra.get("sourceToolAssistantUUID"),
1075            Some(&json!("a1"))
1076        );
1077    }
1078
1079    // ── Missing metadata fields don't appear (no nulls) ──────────────
1080
1081    #[test]
1082    fn test_missing_metadata_no_nulls_in_json() {
1083        let turn = user_turn("u1", "Hello");
1084        // No environment, no extra — metadata fields should be absent
1085
1086        let view = make_view("sess-1", vec![turn]);
1087        let convo = ClaudeProjector.project(&view).unwrap();
1088
1089        let entry = &content_entries(&convo)[0];
1090        let json_str = serde_json::to_string(entry).unwrap();
1091        // None fields with skip_serializing_if should not appear
1092        assert!(!json_str.contains("\"version\""));
1093        assert!(!json_str.contains("\"userType\""));
1094        assert!(!json_str.contains("\"requestId\""));
1095        assert!(!json_str.contains("\"gitBranch\""));
1096    }
1097}