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()))
}
}
fn project_view(view: &ConversationView) -> std::result::Result<Conversation, String> {
let mut convo = Conversation::new(view.id.clone());
convo.preamble.push(json!({
"type": "permission-mode",
"permissionMode": "default",
"sessionId": view.id,
}));
for turn in &view.turns {
match &turn.role {
Role::User => {
let mut entry = user_turn_to_entry(turn, &view.id);
apply_turn_metadata(&mut entry, turn);
convo.add_entry(entry);
}
Role::Assistant => {
let mut assistant_entry = assistant_turn_to_entry(turn, &view.id);
apply_turn_metadata(&mut assistant_entry, turn);
convo.add_entry(assistant_entry);
if let Some(mut result_entry) = tool_result_entry(turn, &view.id) {
apply_turn_metadata(&mut result_entry, turn);
convo.add_entry(result_entry);
}
}
Role::System => {
let mut entry = system_turn_to_entry(turn, &view.id);
apply_turn_metadata(&mut entry, turn);
convo.add_entry(entry);
}
Role::Other(_) => {
let mut entry = other_turn_to_entry(turn, &view.id);
apply_turn_metadata(&mut entry, turn);
convo.add_entry(entry);
}
}
}
for event in &view.events {
let entry = project_event(event, &view.id);
convo.add_entry(entry);
}
Ok(convo)
}
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();
}
}
if let Some(claude) = turn.extra.get("claude").and_then(|v| v.as_object()) {
if let Some(v) = claude.get("version").and_then(|v| v.as_str()) {
entry.version = entry.version.take().or_else(|| Some(v.to_string()));
}
if let Some(v) = claude.get("user_type").and_then(|v| v.as_str()) {
entry.user_type = entry.user_type.take().or_else(|| Some(v.to_string()));
}
if let Some(v) = claude.get("request_id").and_then(|v| v.as_str()) {
entry.request_id = entry.request_id.take().or_else(|| Some(v.to_string()));
}
for (k, v) in claude {
match k.as_str() {
"version" | "user_type" | "request_id" => {} _ => {
entry.extra.entry(k.clone()).or_insert_with(|| v.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 {
parts.push(ContentPart::ToolUse {
id: tu.id.clone(),
name: tu.name.clone(),
input: tu.input.clone(),
});
}
MessageContent::Parts(parts)
}
fn tool_result_entry(turn: &Turn, session_id: &str) -> Option<ConversationEntry> {
let result_parts: Vec<ContentPart> = turn
.tool_uses
.iter()
.filter_map(build_tool_result_part)
.collect();
if result_parts.is_empty() {
return None;
}
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),
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(result_parts)),
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,
})
}
fn build_tool_result_part(tu: &ToolInvocation) -> Option<ContentPart> {
tu.result.as_ref().map(|r| ContentPart::ToolResult {
tool_use_id: tu.id.clone(),
content: ToolResultContent::Text(r.content.clone()),
is_error: r.is_error,
})
}
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![],
}
}
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![],
extra: Default::default(),
}
}
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![],
extra: Default::default(),
}
}
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");
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(), 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(), 2);
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"),
}
match &parts[1] {
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 1"),
}
}
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_user_entry_metadata_from_turn() {
let mut turn = user_turn("u1", "Hello");
turn.environment = Some(EnvironmentSnapshot {
working_dir: Some("/home/user/project".to_string()),
vcs_branch: Some("main".to_string()),
vcs_revision: None,
});
turn.extra.insert(
"claude".to_string(),
json!({
"version": "2.1.37",
"user_type": "external",
"entrypoint": "cli",
}),
);
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("/home/user/project"));
assert_eq!(entry.git_branch.as_deref(), Some("main"));
assert_eq!(entry.version.as_deref(), Some("2.1.37"));
assert_eq!(entry.user_type.as_deref(), Some("external"));
assert_eq!(entry.extra.get("entrypoint"), Some(&json!("cli")));
}
#[test]
fn test_assistant_entry_metadata_request_id() {
let mut turn = assistant_turn("a1", "Done.");
turn.extra.insert(
"claude".to_string(),
json!({
"request_id": "req_abc123",
"version": "2.1.37",
}),
);
let view = make_view("sess-1", vec![turn]);
let convo = ClaudeProjector.project(&view).unwrap();
let entry = &content_entries(&convo)[0];
assert_eq!(entry.request_id.as_deref(), Some("req_abc123"));
assert_eq!(entry.version.as_deref(), Some("2.1.37"));
}
#[test]
fn test_entry_extras_appear_in_projected_entries() {
let mut turn = user_turn("u1", "Hello");
turn.extra.insert(
"claude".to_string(),
json!({
"entrypoint": "cli",
"isMeta": true,
"slug": "my-slug",
}),
);
let view = make_view("sess-1", vec![turn]);
let convo = ClaudeProjector.project(&view).unwrap();
let entry = &content_entries(&convo)[0];
assert_eq!(entry.extra.get("entrypoint"), Some(&json!("cli")));
assert_eq!(entry.extra.get("isMeta"), Some(&json!(true)));
assert_eq!(entry.extra.get("slug"), Some(&json!("my-slug")));
}
#[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.extra.insert(
"claude".to_string(),
json!({
"version": "2.1.37",
"user_type": "external",
"entrypoint": "cli",
}),
);
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.version.as_deref(), Some("2.1.37"));
assert_eq!(result_entry.user_type.as_deref(), Some("external"));
assert_eq!(result_entry.extra.get("entrypoint"), Some(&json!("cli")));
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\""));
}
}