use crate::types::{
ContentPart, Conversation, ConversationEntry, Message, MessageContent, MessageRole,
ToolResultContent, Usage,
};
use serde_json::json;
use std::collections::HashMap;
use toolpath_convo::{
ConversationProjector, ConversationView, ConvoError, Result, Role, ToolInvocation, Turn,
};
pub struct ClaudeProjector;
impl ConversationProjector for ClaudeProjector {
type Output = Conversation;
fn project(&self, view: &ConversationView) -> Result<Conversation> {
project_view(view).map_err(|e| ConvoError::Provider(e.to_string()))
}
}
const TOOL_RESULT_USER_EVENT: &str = "tool_result_user";
fn project_view(view: &ConversationView) -> std::result::Result<Conversation, String> {
let mut convo = Conversation::new(view.id.clone());
let mut emitted_preamble = false;
for event in &view.events {
if let Some(raw) = event.data.get("raw") {
convo.preamble.push(raw.clone());
emitted_preamble = true;
}
}
if !emitted_preamble {
convo.preamble.push(json!({
"type": "permission-mode",
"permissionMode": "default",
"sessionId": view.id,
}));
}
let mut tool_result_events_by_parent: HashMap<String, Vec<&toolpath_convo::ConversationEvent>> =
HashMap::new();
for event in &view.events {
if event.event_type != TOOL_RESULT_USER_EVENT {
continue;
}
if let Some(pid) = &event.parent_id {
tool_result_events_by_parent
.entry(pid.clone())
.or_default()
.push(event);
}
}
let mut consumed_event_ids: std::collections::HashSet<String> =
std::collections::HashSet::new();
let mut parent_rewrites: HashMap<String, String> = HashMap::new();
for turn in &view.turns {
let effective_parent = turn
.parent_id
.as_ref()
.and_then(|pid| parent_rewrites.get(pid).cloned())
.or_else(|| turn.parent_id.clone());
match &turn.role {
Role::User => {
let mut entry = user_turn_to_entry(turn, &view.id);
apply_turn_metadata(&mut entry, turn);
entry.parent_uuid = effective_parent;
convo.add_entry(entry);
}
Role::Assistant => {
let mut assistant_entry = assistant_turn_to_entry(turn, &view.id);
apply_turn_metadata(&mut assistant_entry, turn);
assistant_entry.parent_uuid = effective_parent;
convo.add_entry(assistant_entry);
let real = tool_result_events_by_parent.remove(&turn.id);
if let Some(events) = real {
let mut last_uuid = turn.id.clone();
for event in events {
let entry = tool_result_event_to_entry(event, &view.id);
last_uuid = entry.uuid.clone();
convo.add_entry(entry);
consumed_event_ids.insert(event.id.clone());
}
if last_uuid != turn.id {
parent_rewrites.insert(turn.id.clone(), last_uuid);
}
} else {
let mut last_uuid = turn.id.clone();
for mut result_entry in tool_result_entries(turn, &view.id) {
apply_turn_metadata(&mut result_entry, turn);
last_uuid = result_entry.uuid.clone();
convo.add_entry(result_entry);
}
if last_uuid != turn.id {
parent_rewrites.insert(turn.id.clone(), last_uuid);
}
}
}
Role::System => {
let mut entry = system_turn_to_entry(turn, &view.id);
apply_turn_metadata(&mut entry, turn);
entry.parent_uuid = effective_parent;
convo.add_entry(entry);
}
Role::Other(_) => {
let mut entry = other_turn_to_entry(turn, &view.id);
apply_turn_metadata(&mut entry, turn);
entry.parent_uuid = effective_parent;
convo.add_entry(entry);
}
}
}
for event in &view.events {
if event.data.contains_key("raw") {
continue; }
if consumed_event_ids.contains(&event.id) {
continue;
}
if event.event_type == TOOL_RESULT_USER_EVENT {
let entry = tool_result_event_to_entry(event, &view.id);
convo.add_entry(entry);
continue;
}
let entry = project_event(event, &view.id);
convo.add_entry(entry);
}
Ok(convo)
}
fn tool_result_event_to_entry(
event: &toolpath_convo::ConversationEvent,
session_id: &str,
) -> ConversationEntry {
let mut content_parts: Vec<ContentPart> = Vec::new();
if let Some(arr) = event.data.get("tool_results").and_then(|v| v.as_array()) {
for v in arr {
let tool_use_id = v
.get("tool_use_id")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_string();
let content_text = v
.get("content")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_string();
let is_error = v.get("is_error").and_then(|x| x.as_bool()).unwrap_or(false);
content_parts.push(ContentPart::ToolResult {
tool_use_id,
content: ToolResultContent::Text(content_text),
is_error,
});
}
}
let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
if let Some(map) = event.data.get("entry_extra").and_then(|v| v.as_object()) {
for (k, v) in map {
extra.insert(k.clone(), v.clone());
}
}
ConversationEntry {
uuid: event.id.clone(),
parent_uuid: event.parent_id.clone(),
is_sidechain: false,
entry_type: "user".to_string(),
timestamp: event.timestamp.clone(),
session_id: Some(session_id.to_string()),
cwd: event
.data
.get("cwd")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
git_branch: event
.data
.get("git_branch")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
message: Some(Message {
role: MessageRole::User,
content: Some(MessageContent::Parts(content_parts)),
model: None,
id: None,
message_type: None,
stop_reason: None,
stop_sequence: None,
usage: None,
}),
version: event
.data
.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
user_type: event
.data
.get("user_type")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
request_id: None,
tool_use_result: event.data.get("tool_use_result").cloned(),
snapshot: None,
message_id: None,
extra,
}
}
fn apply_turn_metadata(entry: &mut ConversationEntry, turn: &Turn) {
if let Some(env) = &turn.environment {
if entry.cwd.is_none() {
entry.cwd = env.working_dir.clone();
}
if entry.git_branch.is_none() {
entry.git_branch = env.vcs_branch.clone();
}
}
}
fn user_turn_to_entry(turn: &Turn, session_id: &str) -> ConversationEntry {
let content = MessageContent::Text(turn.text.clone());
ConversationEntry {
uuid: turn.id.clone(),
parent_uuid: turn.parent_id.clone(),
is_sidechain: false,
entry_type: "user".to_string(),
timestamp: turn.timestamp.clone(),
session_id: Some(session_id.to_string()),
cwd: turn
.environment
.as_ref()
.and_then(|e| e.working_dir.clone()),
git_branch: turn.environment.as_ref().and_then(|e| e.vcs_branch.clone()),
message: Some(Message {
role: MessageRole::User,
content: Some(content),
model: None,
id: None,
message_type: None,
stop_reason: None,
stop_sequence: None,
usage: None,
}),
version: None,
user_type: None,
request_id: None,
tool_use_result: None,
snapshot: None,
message_id: None,
extra: Default::default(),
}
}
fn assistant_turn_to_entry(turn: &Turn, session_id: &str) -> ConversationEntry {
let content = build_assistant_content(turn);
let usage = turn.token_usage.as_ref().map(|u| Usage {
input_tokens: u.input_tokens,
output_tokens: u.output_tokens,
cache_creation_input_tokens: u.cache_write_tokens,
cache_read_input_tokens: u.cache_read_tokens,
cache_creation: None,
service_tier: None,
});
ConversationEntry {
uuid: turn.id.clone(),
parent_uuid: turn.parent_id.clone(),
is_sidechain: false,
entry_type: "assistant".to_string(),
timestamp: turn.timestamp.clone(),
session_id: Some(session_id.to_string()),
cwd: None,
git_branch: None,
message: Some(Message {
role: MessageRole::Assistant,
content: Some(content),
model: turn.model.clone(),
id: None,
message_type: None,
stop_reason: turn.stop_reason.clone(),
stop_sequence: None,
usage,
}),
version: None,
user_type: None,
request_id: None,
tool_use_result: None,
snapshot: None,
message_id: None,
extra: Default::default(),
}
}
fn build_assistant_content(turn: &Turn) -> MessageContent {
let has_thinking = turn.thinking.is_some();
let has_tool_uses = !turn.tool_uses.is_empty();
if !has_thinking && !has_tool_uses {
return MessageContent::Parts(vec![ContentPart::Text {
text: turn.text.clone(),
}]);
}
let mut parts: Vec<ContentPart> = Vec::new();
if let Some(thinking) = &turn.thinking {
parts.push(ContentPart::Thinking {
thinking: thinking.clone(),
signature: None,
});
}
if !turn.text.is_empty() {
parts.push(ContentPart::Text {
text: turn.text.clone(),
});
}
for tu in &turn.tool_uses {
let name = canonical_claude_tool_name(tu);
let input = canonical_claude_tool_input(tu, &name);
parts.push(ContentPart::ToolUse {
id: tu.id.clone(),
name,
input,
});
}
MessageContent::Parts(parts)
}
fn canonical_claude_tool_name(tu: &ToolInvocation) -> String {
if crate::provider::tool_category(&tu.name).is_some() {
return tu.name.clone();
}
if let Some(cat) = tu.category
&& let Some(remap) = crate::provider::native_name(cat, &tu.input)
{
return remap.to_string();
}
tu.name.clone()
}
fn canonical_claude_tool_input(tu: &ToolInvocation, claude_name: &str) -> serde_json::Value {
let get_str = |keys: &[&str]| -> Option<String> {
for k in keys {
if let Some(v) = tu.input.get(*k).and_then(|v| v.as_str()) {
return Some(v.to_string());
}
}
None
};
let path_alts = ["file_path", "filePath", "path", "absolute_path", "filename"];
match claude_name {
"Bash" => {
let mut obj = serde_json::Map::new();
if let Some(cmd) = get_str(&["command", "cmd"]) {
obj.insert("command".into(), serde_json::Value::String(cmd));
}
if let Some(desc) = get_str(&["description", "summary"]) {
obj.insert("description".into(), serde_json::Value::String(desc));
}
if !obj.is_empty() {
serde_json::Value::Object(obj)
} else {
tu.input.clone()
}
}
"Read" => {
let mut obj = serde_json::Map::new();
if let Some(p) = get_str(&path_alts) {
obj.insert("file_path".into(), serde_json::Value::String(p));
}
if let Some(off) = tu.input.get("offset").or_else(|| tu.input.get("startLine")) {
obj.insert("offset".into(), off.clone());
}
if let Some(lim) = tu.input.get("limit").or_else(|| tu.input.get("numLines")) {
obj.insert("limit".into(), lim.clone());
}
if !obj.is_empty() {
serde_json::Value::Object(obj)
} else {
tu.input.clone()
}
}
"Write" => {
let mut obj = serde_json::Map::new();
if let Some(p) = get_str(&path_alts) {
obj.insert("file_path".into(), serde_json::Value::String(p));
}
if let Some(c) = get_str(&["content", "text"]) {
obj.insert("content".into(), serde_json::Value::String(c));
}
if !obj.is_empty() {
serde_json::Value::Object(obj)
} else {
tu.input.clone()
}
}
"Edit" | "MultiEdit" => {
let mut obj = serde_json::Map::new();
if let Some(p) = get_str(&path_alts) {
obj.insert("file_path".into(), serde_json::Value::String(p));
}
if let Some(o) = get_str(&["old_string", "oldString"]) {
obj.insert("old_string".into(), serde_json::Value::String(o));
}
if let Some(n) = get_str(&["new_string", "newString"]) {
obj.insert("new_string".into(), serde_json::Value::String(n));
}
if let Some(r) = tu
.input
.get("replace_all")
.or_else(|| tu.input.get("replaceAll"))
{
obj.insert("replace_all".into(), r.clone());
}
if !obj.is_empty() {
serde_json::Value::Object(obj)
} else {
tu.input.clone()
}
}
"Glob" | "Grep" => {
let mut obj = serde_json::Map::new();
if let Some(p) = get_str(&["pattern", "query", "regex"]) {
obj.insert("pattern".into(), serde_json::Value::String(p));
}
if let Some(p) = get_str(&path_alts) {
obj.insert("path".into(), serde_json::Value::String(p));
}
if !obj.is_empty() {
serde_json::Value::Object(obj)
} else {
tu.input.clone()
}
}
_ => tu.input.clone(),
}
}
fn tool_result_entries(turn: &Turn, session_id: &str) -> Vec<ConversationEntry> {
turn.tool_uses
.iter()
.filter_map(|tu| {
let result = tu.result.as_ref()?;
let part = ContentPart::ToolResult {
tool_use_id: tu.id.clone(),
content: ToolResultContent::Text(result.content.clone()),
is_error: result.is_error,
};
let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
extra.insert("sourceToolAssistantUUID".to_string(), json!(turn.id));
Some(ConversationEntry {
uuid: format!("{}-result-{}", turn.id, tu.id),
parent_uuid: Some(turn.id.clone()),
is_sidechain: false,
entry_type: "user".to_string(),
timestamp: turn.timestamp.clone(),
session_id: Some(session_id.to_string()),
cwd: None,
git_branch: None,
message: Some(Message {
role: MessageRole::User,
content: Some(MessageContent::Parts(vec![part])),
model: None,
id: None,
message_type: None,
stop_reason: None,
stop_sequence: None,
usage: None,
}),
version: None,
user_type: None,
request_id: None,
tool_use_result: tool_use_result_from_invocation(tu),
snapshot: None,
message_id: None,
extra,
})
})
.collect()
}
fn tool_use_result_from_invocation(tu: &ToolInvocation) -> Option<serde_json::Value> {
use toolpath_convo::ToolCategory;
let str_field = |k: &str| -> Option<String> {
tu.input
.get(k)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
};
let path_field = || -> Option<String> {
["file_path", "filePath", "path", "absolute_path", "filename"]
.iter()
.find_map(|k| str_field(k))
};
let result_text = || {
tu.result
.as_ref()
.map(|r| r.content.clone())
.unwrap_or_default()
};
enum Kind {
Shell,
Write,
Edit,
Read,
Search,
Other,
}
let kind = match tu.name.as_str() {
"Bash" => Kind::Shell,
"Write" => Kind::Write,
"Edit" | "MultiEdit" => Kind::Edit,
"Read" => Kind::Read,
"Glob" | "Grep" => Kind::Search,
_ => match tu.category {
Some(ToolCategory::Shell) => Kind::Shell,
Some(ToolCategory::FileWrite) => {
if tu.input.get("old_string").is_some() || tu.input.get("oldString").is_some() {
Kind::Edit
} else {
Kind::Write
}
}
Some(ToolCategory::FileRead) => Kind::Read,
Some(ToolCategory::FileSearch) => Kind::Search,
_ => Kind::Other,
},
};
match kind {
Kind::Shell => Some(json!({
"stdout": result_text(),
"stderr": "",
"interrupted": false,
"isImage": false,
"noOutputExpected": false,
})),
Kind::Write => {
let path = path_field()?;
let content = str_field("content").unwrap_or_default();
Some(json!({
"type": "update",
"filePath": path,
"content": content,
}))
}
Kind::Edit => {
let path = path_field()?;
let old = str_field("old_string")
.or_else(|| str_field("oldString"))
.unwrap_or_default();
let new_ = str_field("new_string")
.or_else(|| str_field("newString"))
.unwrap_or_default();
let replace_all = tu
.input
.get("replace_all")
.or_else(|| tu.input.get("replaceAll"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
Some(json!({
"filePath": path,
"oldString": old,
"newString": new_,
"originalFile": "",
"replaceAll": replace_all,
"userModified": false,
"structuredPatch": structured_patch_hunks(&old, &new_),
}))
}
Kind::Read => {
let Some(path) = path_field() else {
return Some(json!(result_text()));
};
let content = result_text();
let stripped = strip_line_numbers(&content);
let total_lines = stripped.lines().count();
Some(json!({
"type": "text",
"file": {
"filePath": path,
"content": stripped,
"numLines": total_lines,
"startLine": tu.input.get("offset").and_then(|v| v.as_u64()).unwrap_or(1),
"totalLines": total_lines,
}
}))
}
Kind::Search => {
let pattern = str_field("pattern")
.or_else(|| str_field("query"))
.unwrap_or_default();
let filenames: Vec<String> = tu
.result
.as_ref()
.map(|r| {
r.content
.lines()
.filter(|l| !l.is_empty())
.map(|s| s.to_string())
.collect()
})
.unwrap_or_default();
let num_files = filenames.len();
Some(json!({
"filenames": filenames,
"numFiles": num_files,
"pattern": pattern,
}))
}
Kind::Other => tu.result.as_ref().map(|r| json!(r.content)),
}
}
fn structured_patch_hunks(old: &str, new_: &str) -> serde_json::Value {
use similar::{ChangeTag, TextDiff};
let diff = TextDiff::from_lines(old, new_);
let mut hunks = Vec::new();
for group in diff.grouped_ops(3) {
if group.is_empty() {
continue;
}
let first = group.first().unwrap();
let last = group.last().unwrap();
let old_start = first.old_range().start + 1;
let old_lines = last.old_range().end - first.old_range().start;
let new_start = first.new_range().start + 1;
let new_lines = last.new_range().end - first.new_range().start;
let mut lines: Vec<String> = Vec::new();
for op in &group {
for change in diff.iter_changes(op) {
let prefix = match change.tag() {
ChangeTag::Delete => "-",
ChangeTag::Insert => "+",
ChangeTag::Equal => " ",
};
let text: &str = change.value();
let trimmed = text.trim_end_matches('\n');
lines.push(format!("{prefix}{trimmed}"));
}
}
hunks.push(json!({
"oldStart": old_start,
"oldLines": old_lines,
"newStart": new_start,
"newLines": new_lines,
"lines": lines,
}));
}
serde_json::Value::Array(hunks)
}
fn strip_line_numbers(s: &str) -> String {
let lines: Vec<&str> = s.lines().collect();
let mut all_match = !lines.is_empty();
for line in &lines {
let trimmed = line.trim_start();
let mut chars = trimmed.chars();
let has_digit = chars.next().map(|c| c.is_ascii_digit()).unwrap_or(false);
let has_tab = trimmed.contains('\t');
if !has_digit || !has_tab {
all_match = false;
break;
}
}
if !all_match {
return s.to_string();
}
lines
.iter()
.map(|l| {
let trimmed = l.trim_start();
match trimmed.find('\t') {
Some(idx) => trimmed[idx + 1..].to_string(),
None => l.to_string(),
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn system_turn_to_entry(turn: &Turn, session_id: &str) -> ConversationEntry {
ConversationEntry {
uuid: turn.id.clone(),
parent_uuid: turn.parent_id.clone(),
is_sidechain: false,
entry_type: "user".to_string(),
timestamp: turn.timestamp.clone(),
session_id: Some(session_id.to_string()),
cwd: None,
git_branch: None,
message: Some(Message {
role: MessageRole::System,
content: Some(MessageContent::Text(turn.text.clone())),
model: None,
id: None,
message_type: None,
stop_reason: None,
stop_sequence: None,
usage: None,
}),
version: None,
user_type: None,
request_id: None,
tool_use_result: None,
snapshot: None,
message_id: None,
extra: Default::default(),
}
}
fn other_turn_to_entry(turn: &Turn, session_id: &str) -> ConversationEntry {
ConversationEntry {
uuid: turn.id.clone(),
parent_uuid: turn.parent_id.clone(),
is_sidechain: false,
entry_type: "user".to_string(),
timestamp: turn.timestamp.clone(),
session_id: Some(session_id.to_string()),
cwd: None,
git_branch: None,
message: Some(Message {
role: MessageRole::User,
content: Some(MessageContent::Text(turn.text.clone())),
model: None,
id: None,
message_type: None,
stop_reason: None,
stop_sequence: None,
usage: None,
}),
version: None,
user_type: None,
request_id: None,
tool_use_result: None,
snapshot: None,
message_id: None,
extra: Default::default(),
}
}
fn project_event(event: &toolpath_convo::ConversationEvent, session_id: &str) -> ConversationEntry {
let mut extra = HashMap::new();
if let Some(entry_extra) = event.data.get("entry_extra").and_then(|v| v.as_object()) {
for (k, v) in entry_extra {
extra.insert(k.clone(), v.clone());
}
}
let message = event
.data
.get("text")
.and_then(|v| v.as_str())
.map(|text| Message {
role: if event.event_type == "system" {
MessageRole::System
} else {
MessageRole::User
},
content: Some(MessageContent::Text(text.to_string())),
model: None,
id: None,
message_type: None,
stop_reason: None,
stop_sequence: None,
usage: None,
});
ConversationEntry {
uuid: event.id.clone(),
entry_type: event.event_type.clone(),
timestamp: event.timestamp.clone(),
session_id: Some(session_id.into()),
parent_uuid: event.parent_id.clone(),
is_sidechain: false,
message,
cwd: event
.data
.get("cwd")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
git_branch: event
.data
.get("git_branch")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
version: event
.data
.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
user_type: event
.data
.get("user_type")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
request_id: None,
tool_use_result: event.data.get("tool_use_result").cloned(),
snapshot: event.data.get("snapshot").cloned(),
message_id: event
.data
.get("message_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
extra,
}
}
#[cfg(test)]
mod tests {
use super::*;
use toolpath_convo::{EnvironmentSnapshot, TokenUsage, ToolResult};
fn make_view(id: &str, turns: Vec<Turn>) -> ConversationView {
ConversationView {
id: id.to_string(),
started_at: None,
last_activity: None,
turns,
total_usage: None,
provider_id: None,
files_changed: vec![],
session_ids: vec![],
events: vec![],
..Default::default()
}
}
fn user_turn(id: &str, text: &str) -> Turn {
Turn {
id: id.to_string(),
parent_id: None,
role: Role::User,
timestamp: "2024-01-01T00:00:00Z".to_string(),
text: text.to_string(),
thinking: None,
tool_uses: vec![],
model: None,
stop_reason: None,
token_usage: None,
environment: None,
delegations: vec![],
file_mutations: Vec::new(),
}
}
fn assistant_turn(id: &str, text: &str) -> Turn {
Turn {
id: id.to_string(),
parent_id: None,
role: Role::Assistant,
timestamp: "2024-01-01T00:00:01Z".to_string(),
text: text.to_string(),
thinking: None,
tool_uses: vec![],
model: None,
stop_reason: None,
token_usage: None,
environment: None,
delegations: vec![],
file_mutations: Vec::new(),
}
}
fn content_entries(convo: &Conversation) -> &[ConversationEntry] {
&convo.entries
}
#[test]
fn test_permission_mode_in_preamble() {
let view = make_view("sess-1", vec![user_turn("u1", "Hello")]);
let convo = ClaudeProjector.project(&view).unwrap();
assert_eq!(convo.preamble.len(), 1);
let perm = &convo.preamble[0];
assert_eq!(perm["type"], "permission-mode");
assert_eq!(perm["permissionMode"], "default");
assert_eq!(perm["sessionId"], "sess-1");
assert!(perm.get("uuid").is_none());
assert!(perm.get("timestamp").is_none());
}
#[test]
fn test_basic_conversation_entry_count_and_content() {
let view = make_view(
"sess-1",
vec![user_turn("u1", "Hello"), assistant_turn("a1", "Hi there!")],
);
let projector = ClaudeProjector;
let convo = projector.project(&view).unwrap();
assert_eq!(convo.session_id, "sess-1");
let entries = content_entries(&convo);
assert_eq!(entries.len(), 2);
let user_entry = &entries[0];
assert_eq!(user_entry.entry_type, "user");
assert_eq!(user_entry.uuid, "u1");
let msg = user_entry.message.as_ref().unwrap();
assert_eq!(msg.role, MessageRole::User);
assert_eq!(msg.text(), "Hello");
let asst_entry = &entries[1];
assert_eq!(asst_entry.entry_type, "assistant");
assert_eq!(asst_entry.uuid, "a1");
let msg = asst_entry.message.as_ref().unwrap();
assert_eq!(msg.role, MessageRole::Assistant);
assert_eq!(msg.text(), "Hi there!");
assert!(matches!(msg.content, Some(MessageContent::Parts(_))));
}
#[test]
fn test_user_turn_with_environment() {
let mut turn = user_turn("u1", "Hello");
turn.environment = Some(EnvironmentSnapshot {
working_dir: Some("/my/project".to_string()),
vcs_branch: Some("feat/auth".to_string()),
vcs_revision: None,
});
let view = make_view("sess-1", vec![turn]);
let convo = ClaudeProjector.project(&view).unwrap();
let entry = &content_entries(&convo)[0];
assert_eq!(entry.cwd.as_deref(), Some("/my/project"));
assert_eq!(entry.git_branch.as_deref(), Some("feat/auth"));
}
#[test]
fn test_assistant_thinking_text_tool_use_produces_parts() {
let mut turn = assistant_turn("a1", "I'll read the file.");
turn.thinking = Some("Hmm, need to read the file first.".to_string());
turn.tool_uses = vec![ToolInvocation {
id: "t1".to_string(),
name: "Read".to_string(),
input: serde_json::json!({"file_path": "src/main.rs"}),
result: None,
category: None,
}];
let view = make_view("sess-1", vec![turn]);
let convo = ClaudeProjector.project(&view).unwrap();
let entries = content_entries(&convo);
assert_eq!(entries.len(), 1);
let entry = &entries[0];
let msg = entry.message.as_ref().unwrap();
match msg.content.as_ref().unwrap() {
MessageContent::Parts(parts) => {
assert_eq!(parts.len(), 3);
assert!(matches!(parts[0], ContentPart::Thinking { .. }));
assert!(matches!(parts[1], ContentPart::Text { .. }));
assert!(matches!(parts[2], ContentPart::ToolUse { .. }));
if let ContentPart::Thinking { thinking, .. } = &parts[0] {
assert_eq!(thinking, "Hmm, need to read the file first.");
}
if let ContentPart::Text { text } = &parts[1] {
assert_eq!(text, "I'll read the file.");
}
if let ContentPart::ToolUse { id, name, .. } = &parts[2] {
assert_eq!(id, "t1");
assert_eq!(name, "Read");
}
}
other => panic!("Expected Parts, got {:?}", other),
}
}
#[test]
fn test_simple_text_only_assistant_produces_parts_array() {
let turn = assistant_turn("a1", "Just a plain answer.");
let view = make_view("sess-1", vec![turn]);
let convo = ClaudeProjector.project(&view).unwrap();
let entry = &content_entries(&convo)[0];
let msg = entry.message.as_ref().unwrap();
match &msg.content {
Some(MessageContent::Parts(parts)) => {
assert_eq!(parts.len(), 1);
assert!(
matches!(&parts[0], ContentPart::Text { text } if text == "Just a plain answer.")
);
}
other => panic!("Expected Parts([Text]), got {:?}", other),
}
}
#[test]
fn test_tool_results_emitted_as_separate_user_entries() {
let mut turn = assistant_turn("a1", "Reading file.");
turn.tool_uses = vec![ToolInvocation {
id: "t1".to_string(),
name: "Read".to_string(),
input: serde_json::json!({"file_path": "src/main.rs"}),
result: Some(ToolResult {
content: "fn main() {}".to_string(),
is_error: false,
}),
category: None,
}];
let view = make_view("sess-1", vec![user_turn("u1", "Go"), turn]);
let convo = ClaudeProjector.project(&view).unwrap();
let entries = content_entries(&convo);
assert_eq!(entries.len(), 3);
let result_entry = &entries[2];
assert_eq!(result_entry.entry_type, "user");
assert_eq!(result_entry.uuid, "a1-result-t1");
assert_eq!(result_entry.parent_uuid.as_deref(), Some("a1"));
let msg = result_entry.message.as_ref().unwrap();
assert_eq!(msg.role, MessageRole::User);
match msg.content.as_ref().unwrap() {
MessageContent::Parts(parts) => {
assert_eq!(parts.len(), 1);
match &parts[0] {
ContentPart::ToolResult {
tool_use_id,
content,
is_error,
} => {
assert_eq!(tool_use_id, "t1");
assert_eq!(content.text(), "fn main() {}");
assert!(!is_error);
}
other => panic!("Expected ToolResult, got {:?}", other),
}
}
other => panic!("Expected Parts, got {:?}", other),
}
}
#[test]
fn test_no_tool_result_entry_when_no_results() {
let mut turn = assistant_turn("a1", "Reading...");
turn.tool_uses = vec![ToolInvocation {
id: "t1".to_string(),
name: "Read".to_string(),
input: serde_json::json!({}),
result: None, category: None,
}];
let view = make_view("sess-1", vec![turn]);
let convo = ClaudeProjector.project(&view).unwrap();
let entries = content_entries(&convo);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].entry_type, "assistant");
}
#[test]
fn test_token_usage_mapped_correctly_with_cache_swap() {
let mut turn = assistant_turn("a1", "Done.");
turn.token_usage = Some(TokenUsage {
input_tokens: Some(100),
output_tokens: Some(50),
cache_read_tokens: Some(500), cache_write_tokens: Some(200), });
let view = make_view("sess-1", vec![turn]);
let convo = ClaudeProjector.project(&view).unwrap();
let msg = content_entries(&convo)[0].message.as_ref().unwrap();
let usage = msg.usage.as_ref().unwrap();
assert_eq!(usage.input_tokens, Some(100));
assert_eq!(usage.output_tokens, Some(50));
assert_eq!(usage.cache_read_input_tokens, Some(500));
assert_eq!(usage.cache_creation_input_tokens, Some(200));
}
#[test]
fn test_session_id_and_parent_chain_preserved() {
let mut t2 = assistant_turn("a1", "Reply");
t2.parent_id = Some("u1".to_string());
let mut t3 = user_turn("u2", "Second");
t3.parent_id = Some("a1".to_string());
let view = make_view("my-session", vec![user_turn("u1", "First"), t2, t3]);
let convo = ClaudeProjector.project(&view).unwrap();
assert_eq!(convo.session_id, "my-session");
for entry in &convo.entries {
assert_eq!(entry.session_id.as_deref(), Some("my-session"));
}
let entries = content_entries(&convo);
assert_eq!(entries[0].parent_uuid, None);
assert_eq!(entries[1].parent_uuid.as_deref(), Some("u1"));
assert_eq!(entries[2].parent_uuid.as_deref(), Some("a1"));
}
#[test]
fn test_stop_reason_and_model_preserved() {
let mut turn = assistant_turn("a1", "Done.");
turn.model = Some("claude-opus-4-6".to_string());
turn.stop_reason = Some("end_turn".to_string());
let view = make_view("sess-1", vec![turn]);
let convo = ClaudeProjector.project(&view).unwrap();
let msg = content_entries(&convo)[0].message.as_ref().unwrap();
assert_eq!(msg.model.as_deref(), Some("claude-opus-4-6"));
assert_eq!(msg.stop_reason.as_deref(), Some("end_turn"));
}
#[test]
fn test_is_sidechain_always_false() {
let view = make_view(
"sess-1",
vec![user_turn("u1", "Hi"), assistant_turn("a1", "Hello")],
);
let convo = ClaudeProjector.project(&view).unwrap();
for entry in &convo.entries {
assert!(!entry.is_sidechain);
}
}
#[test]
fn test_assistant_no_text_only_tool_use_produces_parts() {
let mut turn = assistant_turn("a1", "");
turn.tool_uses = vec![ToolInvocation {
id: "t1".to_string(),
name: "Bash".to_string(),
input: serde_json::json!({"command": "ls"}),
result: None,
category: None,
}];
let view = make_view("sess-1", vec![turn]);
let convo = ClaudeProjector.project(&view).unwrap();
let msg = content_entries(&convo)[0].message.as_ref().unwrap();
match msg.content.as_ref().unwrap() {
MessageContent::Parts(parts) => {
assert_eq!(parts.len(), 1);
assert!(matches!(parts[0], ContentPart::ToolUse { .. }));
}
other => panic!("Expected Parts, got {:?}", other),
}
}
#[test]
fn test_multiple_tool_uses_all_with_results() {
let mut turn = assistant_turn("a1", "Reading two files.");
turn.tool_uses = vec![
ToolInvocation {
id: "t1".to_string(),
name: "Read".to_string(),
input: serde_json::json!({}),
result: Some(ToolResult {
content: "file a".to_string(),
is_error: false,
}),
category: None,
},
ToolInvocation {
id: "t2".to_string(),
name: "Read".to_string(),
input: serde_json::json!({}),
result: Some(ToolResult {
content: "file b".to_string(),
is_error: true,
}),
category: None,
},
];
let view = make_view("sess-1", vec![turn]);
let convo = ClaudeProjector.project(&view).unwrap();
let entries = content_entries(&convo);
assert_eq!(entries.len(), 3);
let r1 = &entries[1];
match r1.message.as_ref().unwrap().content.as_ref().unwrap() {
MessageContent::Parts(parts) => {
assert_eq!(parts.len(), 1);
match &parts[0] {
ContentPart::ToolResult {
tool_use_id,
content,
is_error,
} => {
assert_eq!(tool_use_id, "t1");
assert_eq!(content.text(), "file a");
assert!(!is_error);
}
_ => panic!("Expected ToolResult at index 0"),
}
}
other => panic!("Expected Parts, got {:?}", other),
}
let r2 = &entries[2];
match r2.message.as_ref().unwrap().content.as_ref().unwrap() {
MessageContent::Parts(parts) => {
assert_eq!(parts.len(), 1);
match &parts[0] {
ContentPart::ToolResult {
tool_use_id,
content,
is_error,
} => {
assert_eq!(tool_use_id, "t2");
assert_eq!(content.text(), "file b");
assert!(is_error);
}
_ => panic!("Expected ToolResult at index 0"),
}
}
other => panic!("Expected Parts, got {:?}", other),
}
}
#[test]
fn test_partial_tool_results_only_emits_those_with_results() {
let mut turn = assistant_turn("a1", "Using tools.");
turn.tool_uses = vec![
ToolInvocation {
id: "t1".to_string(),
name: "Read".to_string(),
input: serde_json::json!({}),
result: Some(ToolResult {
content: "file content".to_string(),
is_error: false,
}),
category: None,
},
ToolInvocation {
id: "t2".to_string(),
name: "Write".to_string(),
input: serde_json::json!({}),
result: None, category: None,
},
];
let view = make_view("sess-1", vec![turn]);
let convo = ClaudeProjector.project(&view).unwrap();
let entries = content_entries(&convo);
assert_eq!(entries.len(), 2);
let result_entry = &entries[1];
let msg = result_entry.message.as_ref().unwrap();
match msg.content.as_ref().unwrap() {
MessageContent::Parts(parts) => {
assert_eq!(parts.len(), 1);
if let ContentPart::ToolResult { tool_use_id, .. } = &parts[0] {
assert_eq!(tool_use_id, "t1");
} else {
panic!("Expected ToolResult");
}
}
other => panic!("Expected Parts, got {:?}", other),
}
}
#[test]
fn test_tool_result_entry_inherits_metadata() {
let mut turn = assistant_turn("a1", "Reading.");
turn.environment = Some(EnvironmentSnapshot {
working_dir: Some("/project".to_string()),
vcs_branch: Some("dev".to_string()),
vcs_revision: None,
});
turn.tool_uses = vec![ToolInvocation {
id: "t1".to_string(),
name: "Read".to_string(),
input: serde_json::json!({}),
result: Some(ToolResult {
content: "contents".to_string(),
is_error: false,
}),
category: None,
}];
let view = make_view("sess-1", vec![turn]);
let convo = ClaudeProjector.project(&view).unwrap();
let entries = content_entries(&convo);
assert_eq!(entries.len(), 2);
let result_entry = &entries[1];
assert_eq!(result_entry.cwd.as_deref(), Some("/project"));
assert_eq!(result_entry.git_branch.as_deref(), Some("dev"));
assert_eq!(
result_entry.extra.get("sourceToolAssistantUUID"),
Some(&json!("a1"))
);
}
#[test]
fn test_missing_metadata_no_nulls_in_json() {
let turn = user_turn("u1", "Hello");
let view = make_view("sess-1", vec![turn]);
let convo = ClaudeProjector.project(&view).unwrap();
let entry = &content_entries(&convo)[0];
let json_str = serde_json::to_string(entry).unwrap();
assert!(!json_str.contains("\"version\""));
assert!(!json_str.contains("\"userType\""));
assert!(!json_str.contains("\"requestId\""));
assert!(!json_str.contains("\"gitBranch\""));
}
}