1use 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
18pub 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
59fn project_view(view: &ConversationView) -> std::result::Result<Conversation, String> {
62 let mut convo = Conversation::new(view.id.clone());
63
64 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 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 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
112fn apply_turn_metadata(entry: &mut ConversationEntry, turn: &Turn) {
119 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 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 for (k, v) in claude {
142 match k.as_str() {
143 "version" | "user_type" | "request_id" => {} _ => {
145 entry.extra.entry(k.clone()).or_insert_with(|| v.clone());
146 }
147 }
148 }
149 }
150}
151
152fn 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
188fn 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 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
231fn 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 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
273fn 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
319fn 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
328fn 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
359fn 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
390fn project_event(event: &toolpath_convo::ConversationEvent, session_id: &str) -> ConversationEntry {
395 let mut extra = HashMap::new();
396
397 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 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#[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 fn content_entries(convo: &Conversation) -> &[ConversationEntry] {
523 &convo.entries
524 }
525
526 #[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 assert!(perm.get("uuid").is_none());
540 assert!(perm.get("timestamp").is_none());
541 }
542
543 #[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 assert!(matches!(msg.content, Some(MessageContent::Parts(_))));
573 }
574
575 #[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]
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 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 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]
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 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]
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 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]
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, 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 assert_eq!(entries.len(), 1);
733 assert_eq!(entries[0].entry_type, "assistant");
734 }
735
736 #[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_write_tokens: Some(200), });
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]
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]
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 #[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 #[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 assert_eq!(parts.len(), 1);
835 assert!(matches!(parts[0], ContentPart::ToolUse { .. }));
836 }
837 other => panic!("Expected Parts, got {:?}", other),
838 }
839 }
840
841 #[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 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 #[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, 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 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 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 #[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 #[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 #[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 #[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 assert_eq!(
1074 result_entry.extra.get("sourceToolAssistantUUID"),
1075 Some(&json!("a1"))
1076 );
1077 }
1078
1079 #[test]
1082 fn test_missing_metadata_no_nulls_in_json() {
1083 let turn = user_turn("u1", "Hello");
1084 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 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}