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;
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
52const 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 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 if !emitted_preamble {
77 convo.preamble.push(json!({
78 "type": "permission-mode",
79 "permissionMode": "default",
80 "sessionId": view.id,
81 }));
82 }
83
84 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 let mut parent_rewrites: HashMap<String, String> = HashMap::new();
107
108 for turn in &view.turns {
109 let effective_parent = turn
112 .parent_id
113 .as_ref()
114 .and_then(|pid| parent_rewrites.get(pid).cloned())
115 .or_else(|| turn.parent_id.clone());
116
117 match &turn.role {
118 Role::User => {
119 let mut entry = user_turn_to_entry(turn, &view.id);
120 apply_turn_metadata(&mut entry, turn);
121 entry.parent_uuid = effective_parent;
122 convo.add_entry(entry);
123 }
124 Role::Assistant => {
125 let mut assistant_entry = assistant_turn_to_entry(turn, &view.id);
126 apply_turn_metadata(&mut assistant_entry, turn);
127 assistant_entry.parent_uuid = effective_parent;
128 convo.add_entry(assistant_entry);
129
130 let real = tool_result_events_by_parent.remove(&turn.id);
135 if let Some(events) = real {
136 let mut last_uuid = turn.id.clone();
137 for event in events {
138 let entry = tool_result_event_to_entry(event, &view.id);
139 last_uuid = entry.uuid.clone();
140 convo.add_entry(entry);
141 consumed_event_ids.insert(event.id.clone());
142 }
143 if last_uuid != turn.id {
147 parent_rewrites.insert(turn.id.clone(), last_uuid);
148 }
149 } else {
150 let mut last_uuid = turn.id.clone();
153 for mut result_entry in tool_result_entries(turn, &view.id) {
154 apply_turn_metadata(&mut result_entry, turn);
155 last_uuid = result_entry.uuid.clone();
156 convo.add_entry(result_entry);
157 }
158 if last_uuid != turn.id {
159 parent_rewrites.insert(turn.id.clone(), last_uuid);
160 }
161 }
162 }
163 Role::System => {
164 let mut entry = system_turn_to_entry(turn, &view.id);
165 apply_turn_metadata(&mut entry, turn);
166 entry.parent_uuid = effective_parent;
167 convo.add_entry(entry);
168 }
169 Role::Other(_) => {
170 let mut entry = other_turn_to_entry(turn, &view.id);
171 apply_turn_metadata(&mut entry, turn);
172 entry.parent_uuid = effective_parent;
173 convo.add_entry(entry);
174 }
175 }
176 }
177
178 for event in &view.events {
180 if event.data.contains_key("raw") {
181 continue; }
183 if consumed_event_ids.contains(&event.id) {
184 continue;
185 }
186 if event.event_type == TOOL_RESULT_USER_EVENT {
189 let entry = tool_result_event_to_entry(event, &view.id);
190 convo.add_entry(entry);
191 continue;
192 }
193 let entry = project_event(event, &view.id);
194 convo.add_entry(entry);
195 }
196
197 Ok(convo)
198}
199
200fn tool_result_event_to_entry(
207 event: &toolpath_convo::ConversationEvent,
208 session_id: &str,
209) -> ConversationEntry {
210 let mut content_parts: Vec<ContentPart> = Vec::new();
211 if let Some(arr) = event.data.get("tool_results").and_then(|v| v.as_array()) {
212 for v in arr {
213 let tool_use_id = v
214 .get("tool_use_id")
215 .and_then(|x| x.as_str())
216 .unwrap_or_default()
217 .to_string();
218 let content_text = v
219 .get("content")
220 .and_then(|x| x.as_str())
221 .unwrap_or_default()
222 .to_string();
223 let is_error = v.get("is_error").and_then(|x| x.as_bool()).unwrap_or(false);
224 content_parts.push(ContentPart::ToolResult {
225 tool_use_id,
226 content: ToolResultContent::Text(content_text),
227 is_error,
228 });
229 }
230 }
231
232 let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
233 if let Some(map) = event.data.get("entry_extra").and_then(|v| v.as_object()) {
234 for (k, v) in map {
235 extra.insert(k.clone(), v.clone());
236 }
237 }
238
239 ConversationEntry {
240 uuid: event.id.clone(),
241 parent_uuid: event.parent_id.clone(),
242 is_sidechain: false,
243 entry_type: "user".to_string(),
244 timestamp: event.timestamp.clone(),
245 session_id: Some(session_id.to_string()),
246 cwd: event
247 .data
248 .get("cwd")
249 .and_then(|v| v.as_str())
250 .map(|s| s.to_string()),
251 git_branch: event
252 .data
253 .get("git_branch")
254 .and_then(|v| v.as_str())
255 .map(|s| s.to_string()),
256 message: Some(Message {
257 role: MessageRole::User,
258 content: Some(MessageContent::Parts(content_parts)),
259 model: None,
260 id: None,
261 message_type: None,
262 stop_reason: None,
263 stop_sequence: None,
264 usage: None,
265 }),
266 version: event
267 .data
268 .get("version")
269 .and_then(|v| v.as_str())
270 .map(|s| s.to_string()),
271 user_type: event
272 .data
273 .get("user_type")
274 .and_then(|v| v.as_str())
275 .map(|s| s.to_string()),
276 request_id: None,
277 tool_use_result: event.data.get("tool_use_result").cloned(),
278 snapshot: None,
279 message_id: None,
280 extra,
281 }
282}
283
284fn apply_turn_metadata(entry: &mut ConversationEntry, turn: &Turn) {
291 if let Some(env) = &turn.environment {
293 if entry.cwd.is_none() {
294 entry.cwd = env.working_dir.clone();
295 }
296 if entry.git_branch.is_none() {
297 entry.git_branch = env.vcs_branch.clone();
298 }
299 }
300
301 }
307
308fn user_turn_to_entry(turn: &Turn, session_id: &str) -> ConversationEntry {
310 let content = MessageContent::Text(turn.text.clone());
311
312 ConversationEntry {
313 uuid: turn.id.clone(),
314 parent_uuid: turn.parent_id.clone(),
315 is_sidechain: false,
316 entry_type: "user".to_string(),
317 timestamp: turn.timestamp.clone(),
318 session_id: Some(session_id.to_string()),
319 cwd: turn
320 .environment
321 .as_ref()
322 .and_then(|e| e.working_dir.clone()),
323 git_branch: turn.environment.as_ref().and_then(|e| e.vcs_branch.clone()),
324 message: Some(Message {
325 role: MessageRole::User,
326 content: Some(content),
327 model: None,
328 id: None,
329 message_type: None,
330 stop_reason: None,
331 stop_sequence: None,
332 usage: None,
333 }),
334 version: None,
335 user_type: None,
336 request_id: None,
337 tool_use_result: None,
338 snapshot: None,
339 message_id: None,
340 extra: Default::default(),
341 }
342}
343
344fn assistant_turn_to_entry(turn: &Turn, session_id: &str) -> ConversationEntry {
346 let content = build_assistant_content(turn);
347
348 let usage = turn.token_usage.as_ref().map(|u| Usage {
349 input_tokens: u.input_tokens,
350 output_tokens: u.output_tokens,
351 cache_creation_input_tokens: u.cache_write_tokens,
353 cache_read_input_tokens: u.cache_read_tokens,
354 cache_creation: None,
355 service_tier: None,
356 });
357
358 ConversationEntry {
359 uuid: turn.id.clone(),
360 parent_uuid: turn.parent_id.clone(),
361 is_sidechain: false,
362 entry_type: "assistant".to_string(),
363 timestamp: turn.timestamp.clone(),
364 session_id: Some(session_id.to_string()),
365 cwd: None,
366 git_branch: None,
367 message: Some(Message {
368 role: MessageRole::Assistant,
369 content: Some(content),
370 model: turn.model.clone(),
371 id: None,
372 message_type: None,
373 stop_reason: turn.stop_reason.clone(),
374 stop_sequence: None,
375 usage,
376 }),
377 version: None,
378 user_type: None,
379 request_id: None,
380 tool_use_result: None,
381 snapshot: None,
382 message_id: None,
383 extra: Default::default(),
384 }
385}
386
387fn build_assistant_content(turn: &Turn) -> MessageContent {
392 let has_thinking = turn.thinking.is_some();
393 let has_tool_uses = !turn.tool_uses.is_empty();
394
395 if !has_thinking && !has_tool_uses {
396 return MessageContent::Parts(vec![ContentPart::Text {
399 text: turn.text.clone(),
400 }]);
401 }
402
403 let mut parts: Vec<ContentPart> = Vec::new();
404
405 if let Some(thinking) = &turn.thinking {
406 parts.push(ContentPart::Thinking {
407 thinking: thinking.clone(),
408 signature: None,
409 });
410 }
411
412 if !turn.text.is_empty() {
413 parts.push(ContentPart::Text {
414 text: turn.text.clone(),
415 });
416 }
417
418 for tu in &turn.tool_uses {
419 let name = canonical_claude_tool_name(tu);
426 let input = canonical_claude_tool_input(tu, &name);
427 parts.push(ContentPart::ToolUse {
428 id: tu.id.clone(),
429 name,
430 input,
431 });
432 }
433
434 MessageContent::Parts(parts)
435}
436
437fn canonical_claude_tool_name(tu: &ToolInvocation) -> String {
445 if crate::provider::tool_category(&tu.name).is_some() {
446 return tu.name.clone();
447 }
448 if let Some(cat) = tu.category
449 && let Some(remap) = crate::provider::native_name(cat, &tu.input)
450 {
451 return remap.to_string();
452 }
453 tu.name.clone()
454}
455
456fn canonical_claude_tool_input(tu: &ToolInvocation, claude_name: &str) -> serde_json::Value {
464 let get_str = |keys: &[&str]| -> Option<String> {
465 for k in keys {
466 if let Some(v) = tu.input.get(*k).and_then(|v| v.as_str()) {
467 return Some(v.to_string());
468 }
469 }
470 None
471 };
472 let path_alts = ["file_path", "filePath", "path", "absolute_path", "filename"];
473 match claude_name {
474 "Bash" => {
475 let mut obj = serde_json::Map::new();
476 if let Some(cmd) = get_str(&["command", "cmd"]) {
477 obj.insert("command".into(), serde_json::Value::String(cmd));
478 }
479 if let Some(desc) = get_str(&["description", "summary"]) {
480 obj.insert("description".into(), serde_json::Value::String(desc));
481 }
482 if !obj.is_empty() {
483 serde_json::Value::Object(obj)
484 } else {
485 tu.input.clone()
486 }
487 }
488 "Read" => {
489 let mut obj = serde_json::Map::new();
490 if let Some(p) = get_str(&path_alts) {
491 obj.insert("file_path".into(), serde_json::Value::String(p));
492 }
493 if let Some(off) = tu.input.get("offset").or_else(|| tu.input.get("startLine")) {
494 obj.insert("offset".into(), off.clone());
495 }
496 if let Some(lim) = tu.input.get("limit").or_else(|| tu.input.get("numLines")) {
497 obj.insert("limit".into(), lim.clone());
498 }
499 if !obj.is_empty() {
500 serde_json::Value::Object(obj)
501 } else {
502 tu.input.clone()
503 }
504 }
505 "Write" => {
506 let mut obj = serde_json::Map::new();
507 if let Some(p) = get_str(&path_alts) {
508 obj.insert("file_path".into(), serde_json::Value::String(p));
509 }
510 if let Some(c) = get_str(&["content", "text"]) {
511 obj.insert("content".into(), serde_json::Value::String(c));
512 }
513 if !obj.is_empty() {
514 serde_json::Value::Object(obj)
515 } else {
516 tu.input.clone()
517 }
518 }
519 "Edit" | "MultiEdit" => {
520 let mut obj = serde_json::Map::new();
521 if let Some(p) = get_str(&path_alts) {
522 obj.insert("file_path".into(), serde_json::Value::String(p));
523 }
524 if let Some(o) = get_str(&["old_string", "oldString"]) {
525 obj.insert("old_string".into(), serde_json::Value::String(o));
526 }
527 if let Some(n) = get_str(&["new_string", "newString"]) {
528 obj.insert("new_string".into(), serde_json::Value::String(n));
529 }
530 if let Some(r) = tu
531 .input
532 .get("replace_all")
533 .or_else(|| tu.input.get("replaceAll"))
534 {
535 obj.insert("replace_all".into(), r.clone());
536 }
537 if !obj.is_empty() {
538 serde_json::Value::Object(obj)
539 } else {
540 tu.input.clone()
541 }
542 }
543 "Glob" | "Grep" => {
544 let mut obj = serde_json::Map::new();
545 if let Some(p) = get_str(&["pattern", "query", "regex"]) {
546 obj.insert("pattern".into(), serde_json::Value::String(p));
547 }
548 if let Some(p) = get_str(&path_alts) {
549 obj.insert("path".into(), serde_json::Value::String(p));
550 }
551 if !obj.is_empty() {
552 serde_json::Value::Object(obj)
553 } else {
554 tu.input.clone()
555 }
556 }
557 _ => tu.input.clone(),
558 }
559}
560
561fn tool_result_entries(turn: &Turn, session_id: &str) -> Vec<ConversationEntry> {
567 turn.tool_uses
568 .iter()
569 .filter_map(|tu| {
570 let result = tu.result.as_ref()?;
571 let part = ContentPart::ToolResult {
572 tool_use_id: tu.id.clone(),
573 content: ToolResultContent::Text(result.content.clone()),
574 is_error: result.is_error,
575 };
576
577 let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
578 extra.insert("sourceToolAssistantUUID".to_string(), json!(turn.id));
579
580 Some(ConversationEntry {
581 uuid: format!("{}-result-{}", turn.id, tu.id),
582 parent_uuid: Some(turn.id.clone()),
583 is_sidechain: false,
584 entry_type: "user".to_string(),
585 timestamp: turn.timestamp.clone(),
586 session_id: Some(session_id.to_string()),
587 cwd: None,
588 git_branch: None,
589 message: Some(Message {
590 role: MessageRole::User,
591 content: Some(MessageContent::Parts(vec![part])),
592 model: None,
593 id: None,
594 message_type: None,
595 stop_reason: None,
596 stop_sequence: None,
597 usage: None,
598 }),
599 version: None,
600 user_type: None,
601 request_id: None,
602 tool_use_result: tool_use_result_from_invocation(tu),
603 snapshot: None,
604 message_id: None,
605 extra,
606 })
607 })
608 .collect()
609}
610
611fn tool_use_result_from_invocation(tu: &ToolInvocation) -> Option<serde_json::Value> {
623 use toolpath_convo::ToolCategory;
624
625 let str_field = |k: &str| -> Option<String> {
626 tu.input
627 .get(k)
628 .and_then(|v| v.as_str())
629 .map(|s| s.to_string())
630 };
631 let path_field = || -> Option<String> {
635 ["file_path", "filePath", "path", "absolute_path", "filename"]
636 .iter()
637 .find_map(|k| str_field(k))
638 };
639 let result_text = || {
640 tu.result
641 .as_ref()
642 .map(|r| r.content.clone())
643 .unwrap_or_default()
644 };
645
646 enum Kind {
651 Shell,
652 Write,
653 Edit,
654 Read,
655 Search,
656 Other,
657 }
658 let kind = match tu.name.as_str() {
659 "Bash" => Kind::Shell,
660 "Write" => Kind::Write,
661 "Edit" | "MultiEdit" => Kind::Edit,
662 "Read" => Kind::Read,
663 "Glob" | "Grep" => Kind::Search,
664 _ => match tu.category {
665 Some(ToolCategory::Shell) => Kind::Shell,
666 Some(ToolCategory::FileWrite) => {
667 if tu.input.get("old_string").is_some() || tu.input.get("oldString").is_some() {
670 Kind::Edit
671 } else {
672 Kind::Write
673 }
674 }
675 Some(ToolCategory::FileRead) => Kind::Read,
676 Some(ToolCategory::FileSearch) => Kind::Search,
677 _ => Kind::Other,
678 },
679 };
680
681 match kind {
682 Kind::Shell => Some(json!({
683 "stdout": result_text(),
684 "stderr": "",
685 "interrupted": false,
686 "isImage": false,
687 "noOutputExpected": false,
688 })),
689 Kind::Write => {
690 let path = path_field()?;
691 let content = str_field("content").unwrap_or_default();
692 Some(json!({
693 "type": "update",
694 "filePath": path,
695 "content": content,
696 }))
697 }
698 Kind::Edit => {
699 let path = path_field()?;
700 let old = str_field("old_string")
701 .or_else(|| str_field("oldString"))
702 .unwrap_or_default();
703 let new_ = str_field("new_string")
704 .or_else(|| str_field("newString"))
705 .unwrap_or_default();
706 let replace_all = tu
707 .input
708 .get("replace_all")
709 .or_else(|| tu.input.get("replaceAll"))
710 .and_then(|v| v.as_bool())
711 .unwrap_or(false);
712 Some(json!({
713 "filePath": path,
714 "oldString": old,
715 "newString": new_,
716 "originalFile": "",
717 "replaceAll": replace_all,
718 "userModified": false,
719 "structuredPatch": structured_patch_hunks(&old, &new_),
720 }))
721 }
722 Kind::Read => {
723 let Some(path) = path_field() else {
724 return Some(json!(result_text()));
725 };
726 let content = result_text();
727 let stripped = strip_line_numbers(&content);
728 let total_lines = stripped.lines().count();
729 Some(json!({
730 "type": "text",
731 "file": {
732 "filePath": path,
733 "content": stripped,
734 "numLines": total_lines,
735 "startLine": tu.input.get("offset").and_then(|v| v.as_u64()).unwrap_or(1),
736 "totalLines": total_lines,
737 }
738 }))
739 }
740 Kind::Search => {
741 let pattern = str_field("pattern")
742 .or_else(|| str_field("query"))
743 .unwrap_or_default();
744 let filenames: Vec<String> = tu
745 .result
746 .as_ref()
747 .map(|r| {
748 r.content
749 .lines()
750 .filter(|l| !l.is_empty())
751 .map(|s| s.to_string())
752 .collect()
753 })
754 .unwrap_or_default();
755 let num_files = filenames.len();
756 Some(json!({
757 "filenames": filenames,
758 "numFiles": num_files,
759 "pattern": pattern,
760 }))
761 }
762 Kind::Other => tu.result.as_ref().map(|r| json!(r.content)),
763 }
764}
765
766fn structured_patch_hunks(old: &str, new_: &str) -> serde_json::Value {
773 use similar::{ChangeTag, TextDiff};
774 let diff = TextDiff::from_lines(old, new_);
775 let mut hunks = Vec::new();
776 for group in diff.grouped_ops(3) {
777 if group.is_empty() {
778 continue;
779 }
780 let first = group.first().unwrap();
781 let last = group.last().unwrap();
782 let old_start = first.old_range().start + 1;
783 let old_lines = last.old_range().end - first.old_range().start;
784 let new_start = first.new_range().start + 1;
785 let new_lines = last.new_range().end - first.new_range().start;
786 let mut lines: Vec<String> = Vec::new();
787 for op in &group {
788 for change in diff.iter_changes(op) {
789 let prefix = match change.tag() {
790 ChangeTag::Delete => "-",
791 ChangeTag::Insert => "+",
792 ChangeTag::Equal => " ",
793 };
794 let text: &str = change.value();
795 let trimmed = text.trim_end_matches('\n');
796 lines.push(format!("{prefix}{trimmed}"));
797 }
798 }
799 hunks.push(json!({
800 "oldStart": old_start,
801 "oldLines": old_lines,
802 "newStart": new_start,
803 "newLines": new_lines,
804 "lines": lines,
805 }));
806 }
807 serde_json::Value::Array(hunks)
808}
809
810fn strip_line_numbers(s: &str) -> String {
814 let lines: Vec<&str> = s.lines().collect();
815 let mut all_match = !lines.is_empty();
816 for line in &lines {
817 let trimmed = line.trim_start();
818 let mut chars = trimmed.chars();
819 let has_digit = chars.next().map(|c| c.is_ascii_digit()).unwrap_or(false);
820 let has_tab = trimmed.contains('\t');
821 if !has_digit || !has_tab {
822 all_match = false;
823 break;
824 }
825 }
826 if !all_match {
827 return s.to_string();
828 }
829 lines
830 .iter()
831 .map(|l| {
832 let trimmed = l.trim_start();
833 match trimmed.find('\t') {
834 Some(idx) => trimmed[idx + 1..].to_string(),
835 None => l.to_string(),
836 }
837 })
838 .collect::<Vec<_>>()
839 .join("\n")
840}
841
842fn system_turn_to_entry(turn: &Turn, session_id: &str) -> ConversationEntry {
844 ConversationEntry {
845 uuid: turn.id.clone(),
846 parent_uuid: turn.parent_id.clone(),
847 is_sidechain: false,
848 entry_type: "user".to_string(),
849 timestamp: turn.timestamp.clone(),
850 session_id: Some(session_id.to_string()),
851 cwd: None,
852 git_branch: None,
853 message: Some(Message {
854 role: MessageRole::System,
855 content: Some(MessageContent::Text(turn.text.clone())),
856 model: None,
857 id: None,
858 message_type: None,
859 stop_reason: None,
860 stop_sequence: None,
861 usage: None,
862 }),
863 version: None,
864 user_type: None,
865 request_id: None,
866 tool_use_result: None,
867 snapshot: None,
868 message_id: None,
869 extra: Default::default(),
870 }
871}
872
873fn other_turn_to_entry(turn: &Turn, session_id: &str) -> ConversationEntry {
875 ConversationEntry {
876 uuid: turn.id.clone(),
877 parent_uuid: turn.parent_id.clone(),
878 is_sidechain: false,
879 entry_type: "user".to_string(),
880 timestamp: turn.timestamp.clone(),
881 session_id: Some(session_id.to_string()),
882 cwd: None,
883 git_branch: None,
884 message: Some(Message {
885 role: MessageRole::User,
886 content: Some(MessageContent::Text(turn.text.clone())),
887 model: None,
888 id: None,
889 message_type: None,
890 stop_reason: None,
891 stop_sequence: None,
892 usage: None,
893 }),
894 version: None,
895 user_type: None,
896 request_id: None,
897 tool_use_result: None,
898 snapshot: None,
899 message_id: None,
900 extra: Default::default(),
901 }
902}
903
904fn project_event(event: &toolpath_convo::ConversationEvent, session_id: &str) -> ConversationEntry {
909 let mut extra = HashMap::new();
910
911 if let Some(entry_extra) = event.data.get("entry_extra").and_then(|v| v.as_object()) {
913 for (k, v) in entry_extra {
914 extra.insert(k.clone(), v.clone());
915 }
916 }
917
918 let message = event
920 .data
921 .get("text")
922 .and_then(|v| v.as_str())
923 .map(|text| Message {
924 role: if event.event_type == "system" {
925 MessageRole::System
926 } else {
927 MessageRole::User
928 },
929 content: Some(MessageContent::Text(text.to_string())),
930 model: None,
931 id: None,
932 message_type: None,
933 stop_reason: None,
934 stop_sequence: None,
935 usage: None,
936 });
937
938 ConversationEntry {
939 uuid: event.id.clone(),
940 entry_type: event.event_type.clone(),
941 timestamp: event.timestamp.clone(),
942 session_id: Some(session_id.into()),
943 parent_uuid: event.parent_id.clone(),
944 is_sidechain: false,
945 message,
946 cwd: event
947 .data
948 .get("cwd")
949 .and_then(|v| v.as_str())
950 .map(|s| s.to_string()),
951 git_branch: event
952 .data
953 .get("git_branch")
954 .and_then(|v| v.as_str())
955 .map(|s| s.to_string()),
956 version: event
957 .data
958 .get("version")
959 .and_then(|v| v.as_str())
960 .map(|s| s.to_string()),
961 user_type: event
962 .data
963 .get("user_type")
964 .and_then(|v| v.as_str())
965 .map(|s| s.to_string()),
966 request_id: None,
967 tool_use_result: event.data.get("tool_use_result").cloned(),
968 snapshot: event.data.get("snapshot").cloned(),
969 message_id: event
970 .data
971 .get("message_id")
972 .and_then(|v| v.as_str())
973 .map(|s| s.to_string()),
974 extra,
975 }
976}
977
978#[cfg(test)]
981mod tests {
982 use super::*;
983 use toolpath_convo::{EnvironmentSnapshot, TokenUsage, ToolResult};
984
985 fn make_view(id: &str, turns: Vec<Turn>) -> ConversationView {
986 ConversationView {
987 id: id.to_string(),
988 started_at: None,
989 last_activity: None,
990 turns,
991 total_usage: None,
992 provider_id: None,
993 files_changed: vec![],
994 session_ids: vec![],
995 events: vec![],
996 ..Default::default()
997 }
998 }
999
1000 fn user_turn(id: &str, text: &str) -> Turn {
1001 Turn {
1002 id: id.to_string(),
1003 parent_id: None,
1004 role: Role::User,
1005 timestamp: "2024-01-01T00:00:00Z".to_string(),
1006 text: text.to_string(),
1007 thinking: None,
1008 tool_uses: vec![],
1009 model: None,
1010 stop_reason: None,
1011 token_usage: None,
1012 environment: None,
1013 delegations: vec![],
1014 file_mutations: Vec::new(),
1015 }
1016 }
1017
1018 fn assistant_turn(id: &str, text: &str) -> Turn {
1019 Turn {
1020 id: id.to_string(),
1021 parent_id: None,
1022 role: Role::Assistant,
1023 timestamp: "2024-01-01T00:00:01Z".to_string(),
1024 text: text.to_string(),
1025 thinking: None,
1026 tool_uses: vec![],
1027 model: None,
1028 stop_reason: None,
1029 token_usage: None,
1030 environment: None,
1031 delegations: vec![],
1032 file_mutations: Vec::new(),
1033 }
1034 }
1035
1036 fn content_entries(convo: &Conversation) -> &[ConversationEntry] {
1038 &convo.entries
1039 }
1040
1041 #[test]
1044 fn test_permission_mode_in_preamble() {
1045 let view = make_view("sess-1", vec![user_turn("u1", "Hello")]);
1046 let convo = ClaudeProjector.project(&view).unwrap();
1047
1048 assert_eq!(convo.preamble.len(), 1);
1049 let perm = &convo.preamble[0];
1050 assert_eq!(perm["type"], "permission-mode");
1051 assert_eq!(perm["permissionMode"], "default");
1052 assert_eq!(perm["sessionId"], "sess-1");
1053 assert!(perm.get("uuid").is_none());
1055 assert!(perm.get("timestamp").is_none());
1056 }
1057
1058 #[test]
1061 fn test_basic_conversation_entry_count_and_content() {
1062 let view = make_view(
1063 "sess-1",
1064 vec![user_turn("u1", "Hello"), assistant_turn("a1", "Hi there!")],
1065 );
1066 let projector = ClaudeProjector;
1067 let convo = projector.project(&view).unwrap();
1068
1069 assert_eq!(convo.session_id, "sess-1");
1070 let entries = content_entries(&convo);
1071 assert_eq!(entries.len(), 2);
1072
1073 let user_entry = &entries[0];
1074 assert_eq!(user_entry.entry_type, "user");
1075 assert_eq!(user_entry.uuid, "u1");
1076 let msg = user_entry.message.as_ref().unwrap();
1077 assert_eq!(msg.role, MessageRole::User);
1078 assert_eq!(msg.text(), "Hello");
1079
1080 let asst_entry = &entries[1];
1081 assert_eq!(asst_entry.entry_type, "assistant");
1082 assert_eq!(asst_entry.uuid, "a1");
1083 let msg = asst_entry.message.as_ref().unwrap();
1084 assert_eq!(msg.role, MessageRole::Assistant);
1085 assert_eq!(msg.text(), "Hi there!");
1086 assert!(matches!(msg.content, Some(MessageContent::Parts(_))));
1088 }
1089
1090 #[test]
1093 fn test_user_turn_with_environment() {
1094 let mut turn = user_turn("u1", "Hello");
1095 turn.environment = Some(EnvironmentSnapshot {
1096 working_dir: Some("/my/project".to_string()),
1097 vcs_branch: Some("feat/auth".to_string()),
1098 vcs_revision: None,
1099 });
1100
1101 let view = make_view("sess-1", vec![turn]);
1102 let convo = ClaudeProjector.project(&view).unwrap();
1103
1104 let entry = &content_entries(&convo)[0];
1105 assert_eq!(entry.cwd.as_deref(), Some("/my/project"));
1106 assert_eq!(entry.git_branch.as_deref(), Some("feat/auth"));
1107 }
1108
1109 #[test]
1112 fn test_assistant_thinking_text_tool_use_produces_parts() {
1113 let mut turn = assistant_turn("a1", "I'll read the file.");
1114 turn.thinking = Some("Hmm, need to read the file first.".to_string());
1115 turn.tool_uses = vec![ToolInvocation {
1116 id: "t1".to_string(),
1117 name: "Read".to_string(),
1118 input: serde_json::json!({"file_path": "src/main.rs"}),
1119 result: None,
1120 category: None,
1121 }];
1122
1123 let view = make_view("sess-1", vec![turn]);
1124 let convo = ClaudeProjector.project(&view).unwrap();
1125
1126 let entries = content_entries(&convo);
1127 assert_eq!(entries.len(), 1);
1129 let entry = &entries[0];
1130 let msg = entry.message.as_ref().unwrap();
1131
1132 match msg.content.as_ref().unwrap() {
1133 MessageContent::Parts(parts) => {
1134 assert_eq!(parts.len(), 3);
1135 assert!(matches!(parts[0], ContentPart::Thinking { .. }));
1137 assert!(matches!(parts[1], ContentPart::Text { .. }));
1138 assert!(matches!(parts[2], ContentPart::ToolUse { .. }));
1139
1140 if let ContentPart::Thinking { thinking, .. } = &parts[0] {
1141 assert_eq!(thinking, "Hmm, need to read the file first.");
1142 }
1143 if let ContentPart::Text { text } = &parts[1] {
1144 assert_eq!(text, "I'll read the file.");
1145 }
1146 if let ContentPart::ToolUse { id, name, .. } = &parts[2] {
1147 assert_eq!(id, "t1");
1148 assert_eq!(name, "Read");
1149 }
1150 }
1151 other => panic!("Expected Parts, got {:?}", other),
1152 }
1153 }
1154
1155 #[test]
1158 fn test_simple_text_only_assistant_produces_parts_array() {
1159 let turn = assistant_turn("a1", "Just a plain answer.");
1160
1161 let view = make_view("sess-1", vec![turn]);
1162 let convo = ClaudeProjector.project(&view).unwrap();
1163
1164 let entry = &content_entries(&convo)[0];
1165 let msg = entry.message.as_ref().unwrap();
1166 match &msg.content {
1168 Some(MessageContent::Parts(parts)) => {
1169 assert_eq!(parts.len(), 1);
1170 assert!(
1171 matches!(&parts[0], ContentPart::Text { text } if text == "Just a plain answer.")
1172 );
1173 }
1174 other => panic!("Expected Parts([Text]), got {:?}", other),
1175 }
1176 }
1177
1178 #[test]
1181 fn test_tool_results_emitted_as_separate_user_entries() {
1182 let mut turn = assistant_turn("a1", "Reading file.");
1183 turn.tool_uses = vec![ToolInvocation {
1184 id: "t1".to_string(),
1185 name: "Read".to_string(),
1186 input: serde_json::json!({"file_path": "src/main.rs"}),
1187 result: Some(ToolResult {
1188 content: "fn main() {}".to_string(),
1189 is_error: false,
1190 }),
1191 category: None,
1192 }];
1193
1194 let view = make_view("sess-1", vec![user_turn("u1", "Go"), turn]);
1195 let convo = ClaudeProjector.project(&view).unwrap();
1196
1197 let entries = content_entries(&convo);
1198 assert_eq!(entries.len(), 3);
1200
1201 let result_entry = &entries[2];
1202 assert_eq!(result_entry.entry_type, "user");
1203 assert_eq!(result_entry.uuid, "a1-result-t1");
1204 assert_eq!(result_entry.parent_uuid.as_deref(), Some("a1"));
1205
1206 let msg = result_entry.message.as_ref().unwrap();
1207 assert_eq!(msg.role, MessageRole::User);
1208
1209 match msg.content.as_ref().unwrap() {
1210 MessageContent::Parts(parts) => {
1211 assert_eq!(parts.len(), 1);
1212 match &parts[0] {
1213 ContentPart::ToolResult {
1214 tool_use_id,
1215 content,
1216 is_error,
1217 } => {
1218 assert_eq!(tool_use_id, "t1");
1219 assert_eq!(content.text(), "fn main() {}");
1220 assert!(!is_error);
1221 }
1222 other => panic!("Expected ToolResult, got {:?}", other),
1223 }
1224 }
1225 other => panic!("Expected Parts, got {:?}", other),
1226 }
1227 }
1228
1229 #[test]
1232 fn test_no_tool_result_entry_when_no_results() {
1233 let mut turn = assistant_turn("a1", "Reading...");
1234 turn.tool_uses = vec![ToolInvocation {
1235 id: "t1".to_string(),
1236 name: "Read".to_string(),
1237 input: serde_json::json!({}),
1238 result: None, category: None,
1240 }];
1241
1242 let view = make_view("sess-1", vec![turn]);
1243 let convo = ClaudeProjector.project(&view).unwrap();
1244
1245 let entries = content_entries(&convo);
1246 assert_eq!(entries.len(), 1);
1248 assert_eq!(entries[0].entry_type, "assistant");
1249 }
1250
1251 #[test]
1254 fn test_token_usage_mapped_correctly_with_cache_swap() {
1255 let mut turn = assistant_turn("a1", "Done.");
1256 turn.token_usage = Some(TokenUsage {
1257 input_tokens: Some(100),
1258 output_tokens: Some(50),
1259 cache_read_tokens: Some(500), cache_write_tokens: Some(200), });
1262
1263 let view = make_view("sess-1", vec![turn]);
1264 let convo = ClaudeProjector.project(&view).unwrap();
1265
1266 let msg = content_entries(&convo)[0].message.as_ref().unwrap();
1267 let usage = msg.usage.as_ref().unwrap();
1268
1269 assert_eq!(usage.input_tokens, Some(100));
1270 assert_eq!(usage.output_tokens, Some(50));
1271 assert_eq!(usage.cache_read_input_tokens, Some(500));
1272 assert_eq!(usage.cache_creation_input_tokens, Some(200));
1273 }
1274
1275 #[test]
1278 fn test_session_id_and_parent_chain_preserved() {
1279 let mut t2 = assistant_turn("a1", "Reply");
1280 t2.parent_id = Some("u1".to_string());
1281 let mut t3 = user_turn("u2", "Second");
1282 t3.parent_id = Some("a1".to_string());
1283
1284 let view = make_view("my-session", vec![user_turn("u1", "First"), t2, t3]);
1285 let convo = ClaudeProjector.project(&view).unwrap();
1286
1287 assert_eq!(convo.session_id, "my-session");
1288 for entry in &convo.entries {
1289 assert_eq!(entry.session_id.as_deref(), Some("my-session"));
1290 }
1291
1292 let entries = content_entries(&convo);
1293 assert_eq!(entries[0].parent_uuid, None);
1294 assert_eq!(entries[1].parent_uuid.as_deref(), Some("u1"));
1295 assert_eq!(entries[2].parent_uuid.as_deref(), Some("a1"));
1296 }
1297
1298 #[test]
1301 fn test_stop_reason_and_model_preserved() {
1302 let mut turn = assistant_turn("a1", "Done.");
1303 turn.model = Some("claude-opus-4-6".to_string());
1304 turn.stop_reason = Some("end_turn".to_string());
1305
1306 let view = make_view("sess-1", vec![turn]);
1307 let convo = ClaudeProjector.project(&view).unwrap();
1308
1309 let msg = content_entries(&convo)[0].message.as_ref().unwrap();
1310 assert_eq!(msg.model.as_deref(), Some("claude-opus-4-6"));
1311 assert_eq!(msg.stop_reason.as_deref(), Some("end_turn"));
1312 }
1313
1314 #[test]
1317 fn test_is_sidechain_always_false() {
1318 let view = make_view(
1319 "sess-1",
1320 vec![user_turn("u1", "Hi"), assistant_turn("a1", "Hello")],
1321 );
1322 let convo = ClaudeProjector.project(&view).unwrap();
1323
1324 for entry in &convo.entries {
1325 assert!(!entry.is_sidechain);
1326 }
1327 }
1328
1329 #[test]
1332 fn test_assistant_no_text_only_tool_use_produces_parts() {
1333 let mut turn = assistant_turn("a1", "");
1334 turn.tool_uses = vec![ToolInvocation {
1335 id: "t1".to_string(),
1336 name: "Bash".to_string(),
1337 input: serde_json::json!({"command": "ls"}),
1338 result: None,
1339 category: None,
1340 }];
1341
1342 let view = make_view("sess-1", vec![turn]);
1343 let convo = ClaudeProjector.project(&view).unwrap();
1344
1345 let msg = content_entries(&convo)[0].message.as_ref().unwrap();
1346 match msg.content.as_ref().unwrap() {
1347 MessageContent::Parts(parts) => {
1348 assert_eq!(parts.len(), 1);
1350 assert!(matches!(parts[0], ContentPart::ToolUse { .. }));
1351 }
1352 other => panic!("Expected Parts, got {:?}", other),
1353 }
1354 }
1355
1356 #[test]
1359 fn test_multiple_tool_uses_all_with_results() {
1360 let mut turn = assistant_turn("a1", "Reading two files.");
1361 turn.tool_uses = vec![
1362 ToolInvocation {
1363 id: "t1".to_string(),
1364 name: "Read".to_string(),
1365 input: serde_json::json!({}),
1366 result: Some(ToolResult {
1367 content: "file a".to_string(),
1368 is_error: false,
1369 }),
1370 category: None,
1371 },
1372 ToolInvocation {
1373 id: "t2".to_string(),
1374 name: "Read".to_string(),
1375 input: serde_json::json!({}),
1376 result: Some(ToolResult {
1377 content: "file b".to_string(),
1378 is_error: true,
1379 }),
1380 category: None,
1381 },
1382 ];
1383
1384 let view = make_view("sess-1", vec![turn]);
1385 let convo = ClaudeProjector.project(&view).unwrap();
1386
1387 let entries = content_entries(&convo);
1388 assert_eq!(entries.len(), 3);
1390
1391 let r1 = &entries[1];
1392 match r1.message.as_ref().unwrap().content.as_ref().unwrap() {
1393 MessageContent::Parts(parts) => {
1394 assert_eq!(parts.len(), 1);
1395 match &parts[0] {
1396 ContentPart::ToolResult {
1397 tool_use_id,
1398 content,
1399 is_error,
1400 } => {
1401 assert_eq!(tool_use_id, "t1");
1402 assert_eq!(content.text(), "file a");
1403 assert!(!is_error);
1404 }
1405 _ => panic!("Expected ToolResult at index 0"),
1406 }
1407 }
1408 other => panic!("Expected Parts, got {:?}", other),
1409 }
1410
1411 let r2 = &entries[2];
1412 match r2.message.as_ref().unwrap().content.as_ref().unwrap() {
1413 MessageContent::Parts(parts) => {
1414 assert_eq!(parts.len(), 1);
1415 match &parts[0] {
1416 ContentPart::ToolResult {
1417 tool_use_id,
1418 content,
1419 is_error,
1420 } => {
1421 assert_eq!(tool_use_id, "t2");
1422 assert_eq!(content.text(), "file b");
1423 assert!(is_error);
1424 }
1425 _ => panic!("Expected ToolResult at index 0"),
1426 }
1427 }
1428 other => panic!("Expected Parts, got {:?}", other),
1429 }
1430 }
1431
1432 #[test]
1435 fn test_partial_tool_results_only_emits_those_with_results() {
1436 let mut turn = assistant_turn("a1", "Using tools.");
1437 turn.tool_uses = vec![
1438 ToolInvocation {
1439 id: "t1".to_string(),
1440 name: "Read".to_string(),
1441 input: serde_json::json!({}),
1442 result: Some(ToolResult {
1443 content: "file content".to_string(),
1444 is_error: false,
1445 }),
1446 category: None,
1447 },
1448 ToolInvocation {
1449 id: "t2".to_string(),
1450 name: "Write".to_string(),
1451 input: serde_json::json!({}),
1452 result: None, category: None,
1454 },
1455 ];
1456
1457 let view = make_view("sess-1", vec![turn]);
1458 let convo = ClaudeProjector.project(&view).unwrap();
1459
1460 let entries = content_entries(&convo);
1461 assert_eq!(entries.len(), 2);
1463 let result_entry = &entries[1];
1464 let msg = result_entry.message.as_ref().unwrap();
1465 match msg.content.as_ref().unwrap() {
1466 MessageContent::Parts(parts) => {
1467 assert_eq!(parts.len(), 1);
1469 if let ContentPart::ToolResult { tool_use_id, .. } = &parts[0] {
1470 assert_eq!(tool_use_id, "t1");
1471 } else {
1472 panic!("Expected ToolResult");
1473 }
1474 }
1475 other => panic!("Expected Parts, got {:?}", other),
1476 }
1477 }
1478
1479 #[test]
1482 fn test_tool_result_entry_inherits_metadata() {
1483 let mut turn = assistant_turn("a1", "Reading.");
1484 turn.environment = Some(EnvironmentSnapshot {
1485 working_dir: Some("/project".to_string()),
1486 vcs_branch: Some("dev".to_string()),
1487 vcs_revision: None,
1488 });
1489 turn.tool_uses = vec![ToolInvocation {
1490 id: "t1".to_string(),
1491 name: "Read".to_string(),
1492 input: serde_json::json!({}),
1493 result: Some(ToolResult {
1494 content: "contents".to_string(),
1495 is_error: false,
1496 }),
1497 category: None,
1498 }];
1499
1500 let view = make_view("sess-1", vec![turn]);
1501 let convo = ClaudeProjector.project(&view).unwrap();
1502
1503 let entries = content_entries(&convo);
1504 assert_eq!(entries.len(), 2);
1505
1506 let result_entry = &entries[1];
1507 assert_eq!(result_entry.cwd.as_deref(), Some("/project"));
1508 assert_eq!(result_entry.git_branch.as_deref(), Some("dev"));
1509 assert_eq!(
1511 result_entry.extra.get("sourceToolAssistantUUID"),
1512 Some(&json!("a1"))
1513 );
1514 }
1515
1516 #[test]
1519 fn test_missing_metadata_no_nulls_in_json() {
1520 let turn = user_turn("u1", "Hello");
1521 let view = make_view("sess-1", vec![turn]);
1524 let convo = ClaudeProjector.project(&view).unwrap();
1525
1526 let entry = &content_entries(&convo)[0];
1527 let json_str = serde_json::to_string(entry).unwrap();
1528 assert!(!json_str.contains("\"version\""));
1530 assert!(!json_str.contains("\"userType\""));
1531 assert!(!json_str.contains("\"requestId\""));
1532 assert!(!json_str.contains("\"gitBranch\""));
1533 }
1534}