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 let mut group_total: HashMap<&str, toolpath_convo::TokenUsage> = HashMap::new();
117 for turn in &view.turns {
118 if let (Some(mid), Some(usage)) = (turn.group_id.as_deref(), &turn.token_usage) {
119 group_total
120 .entry(mid)
121 .and_modify(|acc| *acc = crate::provider::max_usage(acc, usage))
122 .or_insert_with(|| usage.clone());
123 }
124 }
125
126 for turn in &view.turns {
127 let effective_parent = turn
130 .parent_id
131 .as_ref()
132 .and_then(|pid| parent_rewrites.get(pid).cloned())
133 .or_else(|| turn.parent_id.clone());
134
135 match &turn.role {
136 Role::User => {
137 let mut entry = user_turn_to_entry(turn, &view.id);
138 apply_turn_metadata(&mut entry, turn);
139 entry.parent_uuid = effective_parent;
140 convo.add_entry(entry);
141 }
142 Role::Assistant => {
143 let wire_usage: Option<toolpath_convo::TokenUsage> = match turn.group_id.as_deref()
146 {
147 Some(mid) => group_total.get(mid).cloned(),
148 None => turn.token_usage.clone(),
149 };
150 let mut assistant_entry =
151 assistant_turn_to_entry_with_usage(turn, &view.id, wire_usage.as_ref());
152 apply_turn_metadata(&mut assistant_entry, turn);
153 assistant_entry.parent_uuid = effective_parent;
154 convo.add_entry(assistant_entry);
155
156 let real = tool_result_events_by_parent.remove(&turn.id);
161 if let Some(events) = real {
162 let mut last_uuid = turn.id.clone();
163 for event in events {
164 let entry = tool_result_event_to_entry(event, &view.id);
165 last_uuid = entry.uuid.clone();
166 convo.add_entry(entry);
167 consumed_event_ids.insert(event.id.clone());
168 }
169 if last_uuid != turn.id {
173 parent_rewrites.insert(turn.id.clone(), last_uuid);
174 }
175 } else {
176 let mut last_uuid = turn.id.clone();
179 for mut result_entry in tool_result_entries(turn, &view.id) {
180 apply_turn_metadata(&mut result_entry, turn);
181 last_uuid = result_entry.uuid.clone();
182 convo.add_entry(result_entry);
183 }
184 if last_uuid != turn.id {
185 parent_rewrites.insert(turn.id.clone(), last_uuid);
186 }
187 }
188 }
189 Role::System => {
190 let mut entry = system_turn_to_entry(turn, &view.id);
191 apply_turn_metadata(&mut entry, turn);
192 entry.parent_uuid = effective_parent;
193 convo.add_entry(entry);
194 }
195 Role::Other(_) => {
196 let mut entry = other_turn_to_entry(turn, &view.id);
197 apply_turn_metadata(&mut entry, turn);
198 entry.parent_uuid = effective_parent;
199 convo.add_entry(entry);
200 }
201 }
202 }
203
204 for event in &view.events {
206 if event.data.contains_key("raw") {
207 continue; }
209 if consumed_event_ids.contains(&event.id) {
210 continue;
211 }
212 if event.event_type == TOOL_RESULT_USER_EVENT {
215 let entry = tool_result_event_to_entry(event, &view.id);
216 convo.add_entry(entry);
217 continue;
218 }
219 let entry = project_event(event, &view.id);
220 convo.add_entry(entry);
221 }
222
223 Ok(convo)
224}
225
226fn tool_result_event_to_entry(
233 event: &toolpath_convo::ConversationEvent,
234 session_id: &str,
235) -> ConversationEntry {
236 let mut content_parts: Vec<ContentPart> = Vec::new();
237 if let Some(arr) = event.data.get("tool_results").and_then(|v| v.as_array()) {
238 for v in arr {
239 let tool_use_id = v
240 .get("tool_use_id")
241 .and_then(|x| x.as_str())
242 .unwrap_or_default()
243 .to_string();
244 let content_text = v
245 .get("content")
246 .and_then(|x| x.as_str())
247 .unwrap_or_default()
248 .to_string();
249 let is_error = v.get("is_error").and_then(|x| x.as_bool()).unwrap_or(false);
250 content_parts.push(ContentPart::ToolResult {
251 tool_use_id,
252 content: ToolResultContent::Text(content_text),
253 is_error,
254 });
255 }
256 }
257
258 let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
259 if let Some(map) = event.data.get("entry_extra").and_then(|v| v.as_object()) {
260 for (k, v) in map {
261 extra.insert(k.clone(), v.clone());
262 }
263 }
264
265 ConversationEntry {
266 uuid: event.id.clone(),
267 parent_uuid: event.parent_id.clone(),
268 is_sidechain: false,
269 entry_type: "user".to_string(),
270 timestamp: event.timestamp.clone(),
271 session_id: Some(session_id.to_string()),
272 cwd: event
273 .data
274 .get("cwd")
275 .and_then(|v| v.as_str())
276 .map(|s| s.to_string()),
277 git_branch: event
278 .data
279 .get("git_branch")
280 .and_then(|v| v.as_str())
281 .map(|s| s.to_string()),
282 message: Some(Message {
283 role: MessageRole::User,
284 content: Some(MessageContent::Parts(content_parts)),
285 model: None,
286 id: None,
287 message_type: None,
288 stop_reason: None,
289 stop_sequence: None,
290 usage: None,
291 }),
292 version: event
293 .data
294 .get("version")
295 .and_then(|v| v.as_str())
296 .map(|s| s.to_string()),
297 user_type: event
298 .data
299 .get("user_type")
300 .and_then(|v| v.as_str())
301 .map(|s| s.to_string()),
302 request_id: None,
303 tool_use_result: event.data.get("tool_use_result").cloned(),
304 snapshot: None,
305 message_id: None,
306 extra,
307 }
308}
309
310fn apply_turn_metadata(entry: &mut ConversationEntry, turn: &Turn) {
317 if let Some(env) = &turn.environment {
319 if entry.cwd.is_none() {
320 entry.cwd = env.working_dir.clone();
321 }
322 if entry.git_branch.is_none() {
323 entry.git_branch = env.vcs_branch.clone();
324 }
325 }
326
327 }
333
334fn user_turn_to_entry(turn: &Turn, session_id: &str) -> ConversationEntry {
336 let content = MessageContent::Text(turn.text.clone());
337
338 ConversationEntry {
339 uuid: turn.id.clone(),
340 parent_uuid: turn.parent_id.clone(),
341 is_sidechain: false,
342 entry_type: "user".to_string(),
343 timestamp: turn.timestamp.clone(),
344 session_id: Some(session_id.to_string()),
345 cwd: turn
346 .environment
347 .as_ref()
348 .and_then(|e| e.working_dir.clone()),
349 git_branch: turn.environment.as_ref().and_then(|e| e.vcs_branch.clone()),
350 message: Some(Message {
351 role: MessageRole::User,
352 content: Some(content),
353 model: None,
354 id: None,
355 message_type: None,
356 stop_reason: None,
357 stop_sequence: None,
358 usage: None,
359 }),
360 version: None,
361 user_type: None,
362 request_id: None,
363 tool_use_result: None,
364 snapshot: None,
365 message_id: None,
366 extra: Default::default(),
367 }
368}
369
370fn assistant_turn_to_entry_with_usage(
376 turn: &Turn,
377 session_id: &str,
378 wire_usage: Option<&toolpath_convo::TokenUsage>,
379) -> ConversationEntry {
380 let content = build_assistant_content(turn);
381
382 let usage = wire_usage.map(|u| Usage {
383 input_tokens: u.input_tokens,
384 output_tokens: u.output_tokens,
385 cache_creation_input_tokens: u.cache_write_tokens,
387 cache_read_input_tokens: u.cache_read_tokens,
388 cache_creation: None,
389 service_tier: None,
390 });
391
392 ConversationEntry {
393 uuid: turn.id.clone(),
394 parent_uuid: turn.parent_id.clone(),
395 is_sidechain: false,
396 entry_type: "assistant".to_string(),
397 timestamp: turn.timestamp.clone(),
398 session_id: Some(session_id.to_string()),
399 cwd: None,
400 git_branch: None,
401 message: Some(Message {
402 role: MessageRole::Assistant,
403 content: Some(content),
404 model: turn.model.clone(),
405 id: turn.group_id.clone(),
406 message_type: None,
407 stop_reason: turn.stop_reason.clone(),
408 stop_sequence: None,
409 usage,
410 }),
411 version: None,
412 user_type: None,
413 request_id: None,
414 tool_use_result: None,
415 snapshot: None,
416 message_id: None,
417 extra: Default::default(),
418 }
419}
420
421fn build_assistant_content(turn: &Turn) -> MessageContent {
426 let has_thinking = turn.thinking.is_some();
427 let has_tool_uses = !turn.tool_uses.is_empty();
428
429 if !has_thinking && !has_tool_uses {
430 return MessageContent::Parts(vec![ContentPart::Text {
433 text: turn.text.clone(),
434 }]);
435 }
436
437 let mut parts: Vec<ContentPart> = Vec::new();
438
439 if let Some(thinking) = &turn.thinking {
440 parts.push(ContentPart::Thinking {
441 thinking: thinking.clone(),
442 signature: None,
443 });
444 }
445
446 if !turn.text.is_empty() {
447 parts.push(ContentPart::Text {
448 text: turn.text.clone(),
449 });
450 }
451
452 for tu in &turn.tool_uses {
453 let name = canonical_claude_tool_name(tu);
460 let input = canonical_claude_tool_input(tu, &name);
461 parts.push(ContentPart::ToolUse {
462 id: tu.id.clone(),
463 name,
464 input,
465 });
466 }
467
468 MessageContent::Parts(parts)
469}
470
471fn canonical_claude_tool_name(tu: &ToolInvocation) -> String {
479 if crate::provider::tool_category(&tu.name).is_some() {
480 return tu.name.clone();
481 }
482 if let Some(cat) = tu.category
483 && let Some(remap) = crate::provider::native_name(cat, &tu.input)
484 {
485 return remap.to_string();
486 }
487 tu.name.clone()
488}
489
490fn canonical_claude_tool_input(tu: &ToolInvocation, claude_name: &str) -> serde_json::Value {
498 let get_str = |keys: &[&str]| -> Option<String> {
499 for k in keys {
500 if let Some(v) = tu.input.get(*k).and_then(|v| v.as_str()) {
501 return Some(v.to_string());
502 }
503 }
504 None
505 };
506 let path_alts = ["file_path", "filePath", "path", "absolute_path", "filename"];
507 match claude_name {
508 "Bash" => {
509 let mut obj = serde_json::Map::new();
510 if let Some(cmd) = get_str(&["command", "cmd"]) {
511 obj.insert("command".into(), serde_json::Value::String(cmd));
512 }
513 if let Some(desc) = get_str(&["description", "summary"]) {
514 obj.insert("description".into(), serde_json::Value::String(desc));
515 }
516 if !obj.is_empty() {
517 serde_json::Value::Object(obj)
518 } else {
519 tu.input.clone()
520 }
521 }
522 "Read" => {
523 let mut obj = serde_json::Map::new();
524 if let Some(p) = get_str(&path_alts) {
525 obj.insert("file_path".into(), serde_json::Value::String(p));
526 }
527 if let Some(off) = tu.input.get("offset").or_else(|| tu.input.get("startLine")) {
528 obj.insert("offset".into(), off.clone());
529 }
530 if let Some(lim) = tu.input.get("limit").or_else(|| tu.input.get("numLines")) {
531 obj.insert("limit".into(), lim.clone());
532 }
533 if !obj.is_empty() {
534 serde_json::Value::Object(obj)
535 } else {
536 tu.input.clone()
537 }
538 }
539 "Write" => {
540 let mut obj = serde_json::Map::new();
541 if let Some(p) = get_str(&path_alts) {
542 obj.insert("file_path".into(), serde_json::Value::String(p));
543 }
544 if let Some(c) = get_str(&["content", "text"]) {
545 obj.insert("content".into(), serde_json::Value::String(c));
546 }
547 if !obj.is_empty() {
548 serde_json::Value::Object(obj)
549 } else {
550 tu.input.clone()
551 }
552 }
553 "Edit" | "MultiEdit" => {
554 let mut obj = serde_json::Map::new();
555 if let Some(p) = get_str(&path_alts) {
556 obj.insert("file_path".into(), serde_json::Value::String(p));
557 }
558 if let Some(o) = get_str(&["old_string", "oldString"]) {
559 obj.insert("old_string".into(), serde_json::Value::String(o));
560 }
561 if let Some(n) = get_str(&["new_string", "newString"]) {
562 obj.insert("new_string".into(), serde_json::Value::String(n));
563 }
564 if let Some(r) = tu
565 .input
566 .get("replace_all")
567 .or_else(|| tu.input.get("replaceAll"))
568 {
569 obj.insert("replace_all".into(), r.clone());
570 }
571 if !obj.is_empty() {
572 serde_json::Value::Object(obj)
573 } else {
574 tu.input.clone()
575 }
576 }
577 "Glob" | "Grep" => {
578 let mut obj = serde_json::Map::new();
579 if let Some(p) = get_str(&["pattern", "query", "regex"]) {
580 obj.insert("pattern".into(), serde_json::Value::String(p));
581 }
582 if let Some(p) = get_str(&path_alts) {
583 obj.insert("path".into(), serde_json::Value::String(p));
584 }
585 if !obj.is_empty() {
586 serde_json::Value::Object(obj)
587 } else {
588 tu.input.clone()
589 }
590 }
591 _ => tu.input.clone(),
592 }
593}
594
595fn tool_result_entries(turn: &Turn, session_id: &str) -> Vec<ConversationEntry> {
601 turn.tool_uses
602 .iter()
603 .filter_map(|tu| {
604 let result = tu.result.as_ref()?;
605 let part = ContentPart::ToolResult {
606 tool_use_id: tu.id.clone(),
607 content: ToolResultContent::Text(result.content.clone()),
608 is_error: result.is_error,
609 };
610
611 let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
612 extra.insert("sourceToolAssistantUUID".to_string(), json!(turn.id));
613
614 Some(ConversationEntry {
615 uuid: format!("{}-result-{}", turn.id, tu.id),
616 parent_uuid: Some(turn.id.clone()),
617 is_sidechain: false,
618 entry_type: "user".to_string(),
619 timestamp: turn.timestamp.clone(),
620 session_id: Some(session_id.to_string()),
621 cwd: None,
622 git_branch: None,
623 message: Some(Message {
624 role: MessageRole::User,
625 content: Some(MessageContent::Parts(vec![part])),
626 model: None,
627 id: None,
628 message_type: None,
629 stop_reason: None,
630 stop_sequence: None,
631 usage: None,
632 }),
633 version: None,
634 user_type: None,
635 request_id: None,
636 tool_use_result: tool_use_result_from_invocation(tu),
637 snapshot: None,
638 message_id: None,
639 extra,
640 })
641 })
642 .collect()
643}
644
645fn tool_use_result_from_invocation(tu: &ToolInvocation) -> Option<serde_json::Value> {
657 use toolpath_convo::ToolCategory;
658
659 let str_field = |k: &str| -> Option<String> {
660 tu.input
661 .get(k)
662 .and_then(|v| v.as_str())
663 .map(|s| s.to_string())
664 };
665 let path_field = || -> Option<String> {
669 ["file_path", "filePath", "path", "absolute_path", "filename"]
670 .iter()
671 .find_map(|k| str_field(k))
672 };
673 let result_text = || {
674 tu.result
675 .as_ref()
676 .map(|r| r.content.clone())
677 .unwrap_or_default()
678 };
679
680 enum Kind {
685 Shell,
686 Write,
687 Edit,
688 Read,
689 Search,
690 Other,
691 }
692 let kind = match tu.name.as_str() {
693 "Bash" => Kind::Shell,
694 "Write" => Kind::Write,
695 "Edit" | "MultiEdit" => Kind::Edit,
696 "Read" => Kind::Read,
697 "Glob" | "Grep" => Kind::Search,
698 _ => match tu.category {
699 Some(ToolCategory::Shell) => Kind::Shell,
700 Some(ToolCategory::FileWrite) => {
701 if tu.input.get("old_string").is_some() || tu.input.get("oldString").is_some() {
704 Kind::Edit
705 } else {
706 Kind::Write
707 }
708 }
709 Some(ToolCategory::FileRead) => Kind::Read,
710 Some(ToolCategory::FileSearch) => Kind::Search,
711 _ => Kind::Other,
712 },
713 };
714
715 match kind {
716 Kind::Shell => Some(json!({
717 "stdout": result_text(),
718 "stderr": "",
719 "interrupted": false,
720 "isImage": false,
721 "noOutputExpected": false,
722 })),
723 Kind::Write => {
724 let path = path_field()?;
725 let content = str_field("content").unwrap_or_default();
726 Some(json!({
727 "type": "update",
728 "filePath": path,
729 "content": content,
730 }))
731 }
732 Kind::Edit => {
733 let path = path_field()?;
734 let old = str_field("old_string")
735 .or_else(|| str_field("oldString"))
736 .unwrap_or_default();
737 let new_ = str_field("new_string")
738 .or_else(|| str_field("newString"))
739 .unwrap_or_default();
740 let replace_all = tu
741 .input
742 .get("replace_all")
743 .or_else(|| tu.input.get("replaceAll"))
744 .and_then(|v| v.as_bool())
745 .unwrap_or(false);
746 Some(json!({
747 "filePath": path,
748 "oldString": old,
749 "newString": new_,
750 "originalFile": "",
751 "replaceAll": replace_all,
752 "userModified": false,
753 "structuredPatch": structured_patch_hunks(&old, &new_),
754 }))
755 }
756 Kind::Read => {
757 let Some(path) = path_field() else {
758 return Some(json!(result_text()));
759 };
760 let content = result_text();
761 let stripped = strip_line_numbers(&content);
762 let total_lines = stripped.lines().count();
763 Some(json!({
764 "type": "text",
765 "file": {
766 "filePath": path,
767 "content": stripped,
768 "numLines": total_lines,
769 "startLine": tu.input.get("offset").and_then(|v| v.as_u64()).unwrap_or(1),
770 "totalLines": total_lines,
771 }
772 }))
773 }
774 Kind::Search => {
775 let pattern = str_field("pattern")
776 .or_else(|| str_field("query"))
777 .unwrap_or_default();
778 let filenames: Vec<String> = tu
779 .result
780 .as_ref()
781 .map(|r| {
782 r.content
783 .lines()
784 .filter(|l| !l.is_empty())
785 .map(|s| s.to_string())
786 .collect()
787 })
788 .unwrap_or_default();
789 let num_files = filenames.len();
790 Some(json!({
791 "filenames": filenames,
792 "numFiles": num_files,
793 "pattern": pattern,
794 }))
795 }
796 Kind::Other => tu.result.as_ref().map(|r| json!(r.content)),
797 }
798}
799
800fn structured_patch_hunks(old: &str, new_: &str) -> serde_json::Value {
807 use similar::{ChangeTag, TextDiff};
808 let diff = TextDiff::from_lines(old, new_);
809 let mut hunks = Vec::new();
810 for group in diff.grouped_ops(3) {
811 if group.is_empty() {
812 continue;
813 }
814 let first = group.first().unwrap();
815 let last = group.last().unwrap();
816 let old_start = first.old_range().start + 1;
817 let old_lines = last.old_range().end - first.old_range().start;
818 let new_start = first.new_range().start + 1;
819 let new_lines = last.new_range().end - first.new_range().start;
820 let mut lines: Vec<String> = Vec::new();
821 for op in &group {
822 for change in diff.iter_changes(op) {
823 let prefix = match change.tag() {
824 ChangeTag::Delete => "-",
825 ChangeTag::Insert => "+",
826 ChangeTag::Equal => " ",
827 };
828 let text: &str = change.value();
829 let trimmed = text.trim_end_matches('\n');
830 lines.push(format!("{prefix}{trimmed}"));
831 }
832 }
833 hunks.push(json!({
834 "oldStart": old_start,
835 "oldLines": old_lines,
836 "newStart": new_start,
837 "newLines": new_lines,
838 "lines": lines,
839 }));
840 }
841 serde_json::Value::Array(hunks)
842}
843
844fn strip_line_numbers(s: &str) -> String {
848 let lines: Vec<&str> = s.lines().collect();
849 let mut all_match = !lines.is_empty();
850 for line in &lines {
851 let trimmed = line.trim_start();
852 let mut chars = trimmed.chars();
853 let has_digit = chars.next().map(|c| c.is_ascii_digit()).unwrap_or(false);
854 let has_tab = trimmed.contains('\t');
855 if !has_digit || !has_tab {
856 all_match = false;
857 break;
858 }
859 }
860 if !all_match {
861 return s.to_string();
862 }
863 lines
864 .iter()
865 .map(|l| {
866 let trimmed = l.trim_start();
867 match trimmed.find('\t') {
868 Some(idx) => trimmed[idx + 1..].to_string(),
869 None => l.to_string(),
870 }
871 })
872 .collect::<Vec<_>>()
873 .join("\n")
874}
875
876fn system_turn_to_entry(turn: &Turn, session_id: &str) -> ConversationEntry {
878 ConversationEntry {
879 uuid: turn.id.clone(),
880 parent_uuid: turn.parent_id.clone(),
881 is_sidechain: false,
882 entry_type: "user".to_string(),
883 timestamp: turn.timestamp.clone(),
884 session_id: Some(session_id.to_string()),
885 cwd: None,
886 git_branch: None,
887 message: Some(Message {
888 role: MessageRole::System,
889 content: Some(MessageContent::Text(turn.text.clone())),
890 model: None,
891 id: None,
892 message_type: None,
893 stop_reason: None,
894 stop_sequence: None,
895 usage: None,
896 }),
897 version: None,
898 user_type: None,
899 request_id: None,
900 tool_use_result: None,
901 snapshot: None,
902 message_id: None,
903 extra: Default::default(),
904 }
905}
906
907fn other_turn_to_entry(turn: &Turn, session_id: &str) -> ConversationEntry {
909 ConversationEntry {
910 uuid: turn.id.clone(),
911 parent_uuid: turn.parent_id.clone(),
912 is_sidechain: false,
913 entry_type: "user".to_string(),
914 timestamp: turn.timestamp.clone(),
915 session_id: Some(session_id.to_string()),
916 cwd: None,
917 git_branch: None,
918 message: Some(Message {
919 role: MessageRole::User,
920 content: Some(MessageContent::Text(turn.text.clone())),
921 model: None,
922 id: None,
923 message_type: None,
924 stop_reason: None,
925 stop_sequence: None,
926 usage: None,
927 }),
928 version: None,
929 user_type: None,
930 request_id: None,
931 tool_use_result: None,
932 snapshot: None,
933 message_id: None,
934 extra: Default::default(),
935 }
936}
937
938fn project_event(event: &toolpath_convo::ConversationEvent, session_id: &str) -> ConversationEntry {
943 let mut extra = HashMap::new();
944
945 if let Some(entry_extra) = event.data.get("entry_extra").and_then(|v| v.as_object()) {
947 for (k, v) in entry_extra {
948 extra.insert(k.clone(), v.clone());
949 }
950 }
951
952 let message = event
954 .data
955 .get("text")
956 .and_then(|v| v.as_str())
957 .map(|text| Message {
958 role: if event.event_type == "system" {
959 MessageRole::System
960 } else {
961 MessageRole::User
962 },
963 content: Some(MessageContent::Text(text.to_string())),
964 model: None,
965 id: None,
966 message_type: None,
967 stop_reason: None,
968 stop_sequence: None,
969 usage: None,
970 });
971
972 ConversationEntry {
973 uuid: event.id.clone(),
974 entry_type: event.event_type.clone(),
975 timestamp: event.timestamp.clone(),
976 session_id: Some(session_id.into()),
977 parent_uuid: event.parent_id.clone(),
978 is_sidechain: false,
979 message,
980 cwd: event
981 .data
982 .get("cwd")
983 .and_then(|v| v.as_str())
984 .map(|s| s.to_string()),
985 git_branch: event
986 .data
987 .get("git_branch")
988 .and_then(|v| v.as_str())
989 .map(|s| s.to_string()),
990 version: event
991 .data
992 .get("version")
993 .and_then(|v| v.as_str())
994 .map(|s| s.to_string()),
995 user_type: event
996 .data
997 .get("user_type")
998 .and_then(|v| v.as_str())
999 .map(|s| s.to_string()),
1000 request_id: None,
1001 tool_use_result: event.data.get("tool_use_result").cloned(),
1002 snapshot: event.data.get("snapshot").cloned(),
1003 message_id: event
1004 .data
1005 .get("message_id")
1006 .and_then(|v| v.as_str())
1007 .map(|s| s.to_string()),
1008 extra,
1009 }
1010}
1011
1012#[cfg(test)]
1015mod tests {
1016 use super::*;
1017 use toolpath_convo::{EnvironmentSnapshot, TokenUsage, ToolResult};
1018
1019 fn make_view(id: &str, turns: Vec<Turn>) -> ConversationView {
1020 ConversationView {
1021 id: id.to_string(),
1022 started_at: None,
1023 last_activity: None,
1024 turns,
1025 total_usage: None,
1026 provider_id: None,
1027 files_changed: vec![],
1028 session_ids: vec![],
1029 events: vec![],
1030 ..Default::default()
1031 }
1032 }
1033
1034 fn user_turn(id: &str, text: &str) -> Turn {
1035 Turn {
1036 id: id.to_string(),
1037 parent_id: None,
1038 group_id: None,
1039 role: Role::User,
1040 timestamp: "2024-01-01T00:00:00Z".to_string(),
1041 text: text.to_string(),
1042 thinking: None,
1043 tool_uses: vec![],
1044 model: None,
1045 stop_reason: None,
1046 token_usage: None,
1047 attributed_token_usage: None,
1048 environment: None,
1049 delegations: vec![],
1050 file_mutations: Vec::new(),
1051 }
1052 }
1053
1054 fn assistant_turn(id: &str, text: &str) -> Turn {
1055 Turn {
1056 id: id.to_string(),
1057 parent_id: None,
1058 group_id: None,
1059 role: Role::Assistant,
1060 timestamp: "2024-01-01T00:00:01Z".to_string(),
1061 text: text.to_string(),
1062 thinking: None,
1063 tool_uses: vec![],
1064 model: None,
1065 stop_reason: None,
1066 token_usage: None,
1067 attributed_token_usage: None,
1068 environment: None,
1069 delegations: vec![],
1070 file_mutations: Vec::new(),
1071 }
1072 }
1073
1074 fn content_entries(convo: &Conversation) -> &[ConversationEntry] {
1076 &convo.entries
1077 }
1078
1079 #[test]
1082 fn test_projector_reexpands_group_usage_onto_every_line() {
1083 let usage = toolpath_convo::TokenUsage {
1087 input_tokens: Some(6),
1088 output_tokens: Some(997),
1089 cache_read_tokens: Some(14_842),
1090 cache_write_tokens: Some(429_831),
1091 ..Default::default()
1092 };
1093 let mut a1 = assistant_turn("a1", "Working on it.");
1094 a1.group_id = Some("msg_A".into());
1095 let mut a2 = assistant_turn("a2", "");
1096 a2.group_id = Some("msg_A".into());
1097 a2.token_usage = Some(usage);
1098
1099 let view = make_view("sess-1", vec![user_turn("u1", "Go"), a1, a2]);
1100 let convo = ClaudeProjector.project(&view).unwrap();
1101
1102 let assistants: Vec<&ConversationEntry> = content_entries(&convo)
1103 .iter()
1104 .filter(|e| e.entry_type == "assistant")
1105 .collect();
1106 assert_eq!(assistants.len(), 2);
1107 for entry in &assistants {
1108 let msg = entry.message.as_ref().unwrap();
1109 assert_eq!(msg.id.as_deref(), Some("msg_A"));
1110 let u = msg.usage.as_ref().expect("every line carries usage");
1111 assert_eq!(u.output_tokens, Some(997));
1112 assert_eq!(u.cache_creation_input_tokens, Some(429_831));
1113 }
1114 }
1115
1116 #[test]
1117 fn test_group_total_survives_projector_roundtrip() {
1118 let mut a1 = assistant_turn("a1", "first");
1124 a1.group_id = Some("msg_A".into());
1125 let mut a2 = assistant_turn("a2", "second");
1126 a2.group_id = Some("msg_A".into());
1127 a2.token_usage = Some(toolpath_convo::TokenUsage {
1128 input_tokens: Some(6),
1129 output_tokens: Some(164),
1130 cache_read_tokens: Some(100),
1131 cache_write_tokens: Some(200),
1132 ..Default::default()
1133 });
1134
1135 let view = make_view("sess-1", vec![user_turn("u1", "Go"), a1, a2]);
1136 let convo = ClaudeProjector.project(&view).unwrap();
1137
1138 for entry in content_entries(&convo).iter().filter(|e| e.entry_type == "assistant") {
1141 let msg = entry.message.as_ref().unwrap();
1142 assert_eq!(msg.id.as_deref(), Some("msg_A"));
1143 assert_eq!(msg.usage.as_ref().unwrap().output_tokens, Some(164));
1144 }
1145
1146 let back = crate::provider::to_view(&convo);
1148 let a: Vec<&Turn> = back.turns.iter().filter(|t| t.role == Role::Assistant).collect();
1149 assert!(a[0].token_usage.is_none());
1150 assert_eq!(a[1].token_usage.as_ref().unwrap().output_tokens, Some(164));
1151 assert!(a.iter().all(|t| t.attributed_token_usage.is_none()));
1152 }
1153
1154 #[test]
1157 fn test_permission_mode_in_preamble() {
1158 let view = make_view("sess-1", vec![user_turn("u1", "Hello")]);
1159 let convo = ClaudeProjector.project(&view).unwrap();
1160
1161 assert_eq!(convo.preamble.len(), 1);
1162 let perm = &convo.preamble[0];
1163 assert_eq!(perm["type"], "permission-mode");
1164 assert_eq!(perm["permissionMode"], "default");
1165 assert_eq!(perm["sessionId"], "sess-1");
1166 assert!(perm.get("uuid").is_none());
1168 assert!(perm.get("timestamp").is_none());
1169 }
1170
1171 #[test]
1174 fn test_basic_conversation_entry_count_and_content() {
1175 let view = make_view(
1176 "sess-1",
1177 vec![user_turn("u1", "Hello"), assistant_turn("a1", "Hi there!")],
1178 );
1179 let projector = ClaudeProjector;
1180 let convo = projector.project(&view).unwrap();
1181
1182 assert_eq!(convo.session_id, "sess-1");
1183 let entries = content_entries(&convo);
1184 assert_eq!(entries.len(), 2);
1185
1186 let user_entry = &entries[0];
1187 assert_eq!(user_entry.entry_type, "user");
1188 assert_eq!(user_entry.uuid, "u1");
1189 let msg = user_entry.message.as_ref().unwrap();
1190 assert_eq!(msg.role, MessageRole::User);
1191 assert_eq!(msg.text(), "Hello");
1192
1193 let asst_entry = &entries[1];
1194 assert_eq!(asst_entry.entry_type, "assistant");
1195 assert_eq!(asst_entry.uuid, "a1");
1196 let msg = asst_entry.message.as_ref().unwrap();
1197 assert_eq!(msg.role, MessageRole::Assistant);
1198 assert_eq!(msg.text(), "Hi there!");
1199 assert!(matches!(msg.content, Some(MessageContent::Parts(_))));
1201 }
1202
1203 #[test]
1206 fn test_user_turn_with_environment() {
1207 let mut turn = user_turn("u1", "Hello");
1208 turn.environment = Some(EnvironmentSnapshot {
1209 working_dir: Some("/my/project".to_string()),
1210 vcs_branch: Some("feat/auth".to_string()),
1211 vcs_revision: None,
1212 });
1213
1214 let view = make_view("sess-1", vec![turn]);
1215 let convo = ClaudeProjector.project(&view).unwrap();
1216
1217 let entry = &content_entries(&convo)[0];
1218 assert_eq!(entry.cwd.as_deref(), Some("/my/project"));
1219 assert_eq!(entry.git_branch.as_deref(), Some("feat/auth"));
1220 }
1221
1222 #[test]
1225 fn test_assistant_thinking_text_tool_use_produces_parts() {
1226 let mut turn = assistant_turn("a1", "I'll read the file.");
1227 turn.thinking = Some("Hmm, need to read the file first.".to_string());
1228 turn.tool_uses = vec![ToolInvocation {
1229 id: "t1".to_string(),
1230 name: "Read".to_string(),
1231 input: serde_json::json!({"file_path": "src/main.rs"}),
1232 result: None,
1233 category: None,
1234 }];
1235
1236 let view = make_view("sess-1", vec![turn]);
1237 let convo = ClaudeProjector.project(&view).unwrap();
1238
1239 let entries = content_entries(&convo);
1240 assert_eq!(entries.len(), 1);
1242 let entry = &entries[0];
1243 let msg = entry.message.as_ref().unwrap();
1244
1245 match msg.content.as_ref().unwrap() {
1246 MessageContent::Parts(parts) => {
1247 assert_eq!(parts.len(), 3);
1248 assert!(matches!(parts[0], ContentPart::Thinking { .. }));
1250 assert!(matches!(parts[1], ContentPart::Text { .. }));
1251 assert!(matches!(parts[2], ContentPart::ToolUse { .. }));
1252
1253 if let ContentPart::Thinking { thinking, .. } = &parts[0] {
1254 assert_eq!(thinking, "Hmm, need to read the file first.");
1255 }
1256 if let ContentPart::Text { text } = &parts[1] {
1257 assert_eq!(text, "I'll read the file.");
1258 }
1259 if let ContentPart::ToolUse { id, name, .. } = &parts[2] {
1260 assert_eq!(id, "t1");
1261 assert_eq!(name, "Read");
1262 }
1263 }
1264 other => panic!("Expected Parts, got {:?}", other),
1265 }
1266 }
1267
1268 #[test]
1271 fn test_simple_text_only_assistant_produces_parts_array() {
1272 let turn = assistant_turn("a1", "Just a plain answer.");
1273
1274 let view = make_view("sess-1", vec![turn]);
1275 let convo = ClaudeProjector.project(&view).unwrap();
1276
1277 let entry = &content_entries(&convo)[0];
1278 let msg = entry.message.as_ref().unwrap();
1279 match &msg.content {
1281 Some(MessageContent::Parts(parts)) => {
1282 assert_eq!(parts.len(), 1);
1283 assert!(
1284 matches!(&parts[0], ContentPart::Text { text } if text == "Just a plain answer.")
1285 );
1286 }
1287 other => panic!("Expected Parts([Text]), got {:?}", other),
1288 }
1289 }
1290
1291 #[test]
1294 fn test_tool_results_emitted_as_separate_user_entries() {
1295 let mut turn = assistant_turn("a1", "Reading file.");
1296 turn.tool_uses = vec![ToolInvocation {
1297 id: "t1".to_string(),
1298 name: "Read".to_string(),
1299 input: serde_json::json!({"file_path": "src/main.rs"}),
1300 result: Some(ToolResult {
1301 content: "fn main() {}".to_string(),
1302 is_error: false,
1303 }),
1304 category: None,
1305 }];
1306
1307 let view = make_view("sess-1", vec![user_turn("u1", "Go"), turn]);
1308 let convo = ClaudeProjector.project(&view).unwrap();
1309
1310 let entries = content_entries(&convo);
1311 assert_eq!(entries.len(), 3);
1313
1314 let result_entry = &entries[2];
1315 assert_eq!(result_entry.entry_type, "user");
1316 assert_eq!(result_entry.uuid, "a1-result-t1");
1317 assert_eq!(result_entry.parent_uuid.as_deref(), Some("a1"));
1318
1319 let msg = result_entry.message.as_ref().unwrap();
1320 assert_eq!(msg.role, MessageRole::User);
1321
1322 match msg.content.as_ref().unwrap() {
1323 MessageContent::Parts(parts) => {
1324 assert_eq!(parts.len(), 1);
1325 match &parts[0] {
1326 ContentPart::ToolResult {
1327 tool_use_id,
1328 content,
1329 is_error,
1330 } => {
1331 assert_eq!(tool_use_id, "t1");
1332 assert_eq!(content.text(), "fn main() {}");
1333 assert!(!is_error);
1334 }
1335 other => panic!("Expected ToolResult, got {:?}", other),
1336 }
1337 }
1338 other => panic!("Expected Parts, got {:?}", other),
1339 }
1340 }
1341
1342 #[test]
1345 fn test_no_tool_result_entry_when_no_results() {
1346 let mut turn = assistant_turn("a1", "Reading...");
1347 turn.tool_uses = vec![ToolInvocation {
1348 id: "t1".to_string(),
1349 name: "Read".to_string(),
1350 input: serde_json::json!({}),
1351 result: None, category: None,
1353 }];
1354
1355 let view = make_view("sess-1", vec![turn]);
1356 let convo = ClaudeProjector.project(&view).unwrap();
1357
1358 let entries = content_entries(&convo);
1359 assert_eq!(entries.len(), 1);
1361 assert_eq!(entries[0].entry_type, "assistant");
1362 }
1363
1364 #[test]
1367 fn test_token_usage_mapped_correctly_with_cache_swap() {
1368 let mut turn = assistant_turn("a1", "Done.");
1369 turn.token_usage = Some(TokenUsage {
1370 input_tokens: Some(100),
1371 output_tokens: Some(50),
1372 cache_read_tokens: Some(500), cache_write_tokens: Some(200), ..Default::default()
1375 });
1376
1377 let view = make_view("sess-1", vec![turn]);
1378 let convo = ClaudeProjector.project(&view).unwrap();
1379
1380 let msg = content_entries(&convo)[0].message.as_ref().unwrap();
1381 let usage = msg.usage.as_ref().unwrap();
1382
1383 assert_eq!(usage.input_tokens, Some(100));
1384 assert_eq!(usage.output_tokens, Some(50));
1385 assert_eq!(usage.cache_read_input_tokens, Some(500));
1386 assert_eq!(usage.cache_creation_input_tokens, Some(200));
1387 }
1388
1389 #[test]
1392 fn test_session_id_and_parent_chain_preserved() {
1393 let mut t2 = assistant_turn("a1", "Reply");
1394 t2.parent_id = Some("u1".to_string());
1395 let mut t3 = user_turn("u2", "Second");
1396 t3.parent_id = Some("a1".to_string());
1397
1398 let view = make_view("my-session", vec![user_turn("u1", "First"), t2, t3]);
1399 let convo = ClaudeProjector.project(&view).unwrap();
1400
1401 assert_eq!(convo.session_id, "my-session");
1402 for entry in &convo.entries {
1403 assert_eq!(entry.session_id.as_deref(), Some("my-session"));
1404 }
1405
1406 let entries = content_entries(&convo);
1407 assert_eq!(entries[0].parent_uuid, None);
1408 assert_eq!(entries[1].parent_uuid.as_deref(), Some("u1"));
1409 assert_eq!(entries[2].parent_uuid.as_deref(), Some("a1"));
1410 }
1411
1412 #[test]
1415 fn test_stop_reason_and_model_preserved() {
1416 let mut turn = assistant_turn("a1", "Done.");
1417 turn.model = Some("claude-opus-4-6".to_string());
1418 turn.stop_reason = Some("end_turn".to_string());
1419
1420 let view = make_view("sess-1", vec![turn]);
1421 let convo = ClaudeProjector.project(&view).unwrap();
1422
1423 let msg = content_entries(&convo)[0].message.as_ref().unwrap();
1424 assert_eq!(msg.model.as_deref(), Some("claude-opus-4-6"));
1425 assert_eq!(msg.stop_reason.as_deref(), Some("end_turn"));
1426 }
1427
1428 #[test]
1431 fn test_is_sidechain_always_false() {
1432 let view = make_view(
1433 "sess-1",
1434 vec![user_turn("u1", "Hi"), assistant_turn("a1", "Hello")],
1435 );
1436 let convo = ClaudeProjector.project(&view).unwrap();
1437
1438 for entry in &convo.entries {
1439 assert!(!entry.is_sidechain);
1440 }
1441 }
1442
1443 #[test]
1446 fn test_assistant_no_text_only_tool_use_produces_parts() {
1447 let mut turn = assistant_turn("a1", "");
1448 turn.tool_uses = vec![ToolInvocation {
1449 id: "t1".to_string(),
1450 name: "Bash".to_string(),
1451 input: serde_json::json!({"command": "ls"}),
1452 result: None,
1453 category: None,
1454 }];
1455
1456 let view = make_view("sess-1", vec![turn]);
1457 let convo = ClaudeProjector.project(&view).unwrap();
1458
1459 let msg = content_entries(&convo)[0].message.as_ref().unwrap();
1460 match msg.content.as_ref().unwrap() {
1461 MessageContent::Parts(parts) => {
1462 assert_eq!(parts.len(), 1);
1464 assert!(matches!(parts[0], ContentPart::ToolUse { .. }));
1465 }
1466 other => panic!("Expected Parts, got {:?}", other),
1467 }
1468 }
1469
1470 #[test]
1473 fn test_multiple_tool_uses_all_with_results() {
1474 let mut turn = assistant_turn("a1", "Reading two files.");
1475 turn.tool_uses = vec![
1476 ToolInvocation {
1477 id: "t1".to_string(),
1478 name: "Read".to_string(),
1479 input: serde_json::json!({}),
1480 result: Some(ToolResult {
1481 content: "file a".to_string(),
1482 is_error: false,
1483 }),
1484 category: None,
1485 },
1486 ToolInvocation {
1487 id: "t2".to_string(),
1488 name: "Read".to_string(),
1489 input: serde_json::json!({}),
1490 result: Some(ToolResult {
1491 content: "file b".to_string(),
1492 is_error: true,
1493 }),
1494 category: None,
1495 },
1496 ];
1497
1498 let view = make_view("sess-1", vec![turn]);
1499 let convo = ClaudeProjector.project(&view).unwrap();
1500
1501 let entries = content_entries(&convo);
1502 assert_eq!(entries.len(), 3);
1504
1505 let r1 = &entries[1];
1506 match r1.message.as_ref().unwrap().content.as_ref().unwrap() {
1507 MessageContent::Parts(parts) => {
1508 assert_eq!(parts.len(), 1);
1509 match &parts[0] {
1510 ContentPart::ToolResult {
1511 tool_use_id,
1512 content,
1513 is_error,
1514 } => {
1515 assert_eq!(tool_use_id, "t1");
1516 assert_eq!(content.text(), "file a");
1517 assert!(!is_error);
1518 }
1519 _ => panic!("Expected ToolResult at index 0"),
1520 }
1521 }
1522 other => panic!("Expected Parts, got {:?}", other),
1523 }
1524
1525 let r2 = &entries[2];
1526 match r2.message.as_ref().unwrap().content.as_ref().unwrap() {
1527 MessageContent::Parts(parts) => {
1528 assert_eq!(parts.len(), 1);
1529 match &parts[0] {
1530 ContentPart::ToolResult {
1531 tool_use_id,
1532 content,
1533 is_error,
1534 } => {
1535 assert_eq!(tool_use_id, "t2");
1536 assert_eq!(content.text(), "file b");
1537 assert!(is_error);
1538 }
1539 _ => panic!("Expected ToolResult at index 0"),
1540 }
1541 }
1542 other => panic!("Expected Parts, got {:?}", other),
1543 }
1544 }
1545
1546 #[test]
1549 fn test_partial_tool_results_only_emits_those_with_results() {
1550 let mut turn = assistant_turn("a1", "Using tools.");
1551 turn.tool_uses = vec![
1552 ToolInvocation {
1553 id: "t1".to_string(),
1554 name: "Read".to_string(),
1555 input: serde_json::json!({}),
1556 result: Some(ToolResult {
1557 content: "file content".to_string(),
1558 is_error: false,
1559 }),
1560 category: None,
1561 },
1562 ToolInvocation {
1563 id: "t2".to_string(),
1564 name: "Write".to_string(),
1565 input: serde_json::json!({}),
1566 result: None, category: None,
1568 },
1569 ];
1570
1571 let view = make_view("sess-1", vec![turn]);
1572 let convo = ClaudeProjector.project(&view).unwrap();
1573
1574 let entries = content_entries(&convo);
1575 assert_eq!(entries.len(), 2);
1577 let result_entry = &entries[1];
1578 let msg = result_entry.message.as_ref().unwrap();
1579 match msg.content.as_ref().unwrap() {
1580 MessageContent::Parts(parts) => {
1581 assert_eq!(parts.len(), 1);
1583 if let ContentPart::ToolResult { tool_use_id, .. } = &parts[0] {
1584 assert_eq!(tool_use_id, "t1");
1585 } else {
1586 panic!("Expected ToolResult");
1587 }
1588 }
1589 other => panic!("Expected Parts, got {:?}", other),
1590 }
1591 }
1592
1593 #[test]
1596 fn test_tool_result_entry_inherits_metadata() {
1597 let mut turn = assistant_turn("a1", "Reading.");
1598 turn.environment = Some(EnvironmentSnapshot {
1599 working_dir: Some("/project".to_string()),
1600 vcs_branch: Some("dev".to_string()),
1601 vcs_revision: None,
1602 });
1603 turn.tool_uses = vec![ToolInvocation {
1604 id: "t1".to_string(),
1605 name: "Read".to_string(),
1606 input: serde_json::json!({}),
1607 result: Some(ToolResult {
1608 content: "contents".to_string(),
1609 is_error: false,
1610 }),
1611 category: None,
1612 }];
1613
1614 let view = make_view("sess-1", vec![turn]);
1615 let convo = ClaudeProjector.project(&view).unwrap();
1616
1617 let entries = content_entries(&convo);
1618 assert_eq!(entries.len(), 2);
1619
1620 let result_entry = &entries[1];
1621 assert_eq!(result_entry.cwd.as_deref(), Some("/project"));
1622 assert_eq!(result_entry.git_branch.as_deref(), Some("dev"));
1623 assert_eq!(
1625 result_entry.extra.get("sourceToolAssistantUUID"),
1626 Some(&json!("a1"))
1627 );
1628 }
1629
1630 #[test]
1633 fn test_missing_metadata_no_nulls_in_json() {
1634 let turn = user_turn("u1", "Hello");
1635 let view = make_view("sess-1", vec![turn]);
1638 let convo = ClaudeProjector.project(&view).unwrap();
1639
1640 let entry = &content_entries(&convo)[0];
1641 let json_str = serde_json::to_string(entry).unwrap();
1642 assert!(!json_str.contains("\"version\""));
1644 assert!(!json_str.contains("\"userType\""));
1645 assert!(!json_str.contains("\"requestId\""));
1646 assert!(!json_str.contains("\"gitBranch\""));
1647 }
1648}