use std::collections::{HashMap, HashSet};
use chrono::DateTime;
use toolpath::v1::{Path, Step};
use crate::{
ConversationEvent, ConversationView, DelegatedWork, EnvironmentSnapshot, Role, TokenUsage,
ToolCategory, ToolInvocation, ToolResult, Turn,
};
pub fn extract_conversation(path: &Path) -> ConversationView {
let mut view = ConversationView {
id: String::new(),
started_at: None,
last_activity: None,
turns: Vec::new(),
total_usage: None,
provider_id: None,
files_changed: Vec::new(),
session_ids: Vec::new(),
events: Vec::new(),
};
let mut step_to_turn: HashMap<&str, usize> = HashMap::new();
let mut files_seen: HashSet<String> = HashSet::new();
for step in &path.steps {
for (artifact_key, artifact_change) in &step.change {
let structural = match &artifact_change.structural {
Some(s) => s,
None => continue,
};
match structural.change_type.as_str() {
"conversation.init" => {
handle_init(&mut view, artifact_key, &structural.extra);
}
"conversation.append" => {
if view.id.is_empty()
&& let Some((provider, session)) = artifact_key.split_once("://")
&& !provider.is_empty()
&& !session.is_empty()
{
view.provider_id = Some(provider.to_string());
view.id = session.to_string();
}
let turn = build_turn(step, &structural.extra);
let idx = view.turns.len();
step_to_turn.insert(&step.step.id, idx);
view.turns.push(turn);
}
"conversation.event" => {
let event_type = structural
.extra
.get("entry_type")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let event = ConversationEvent {
id: step.step.id.clone(),
timestamp: step.step.timestamp.clone(),
parent_id: step.step.parents.first().cloned(),
event_type,
data: structural.extra.clone(),
};
view.events.push(event);
}
"tool.invoke" => {
let invocation = build_tool_invocation(&structural.extra);
let category = parse_category(structural.extra.get("category"));
if category == Some(ToolCategory::FileWrite)
&& !artifact_key.starts_with("agent://")
&& files_seen.insert(artifact_key.clone())
{
view.files_changed.push(artifact_key.clone());
}
if let Some(parent_id) = step.step.parents.first()
&& let Some(&turn_idx) = step_to_turn.get(parent_id.as_str())
{
view.turns[turn_idx].tool_uses.push(invocation);
}
}
_ => {
}
}
}
}
let mut has_any_usage = false;
let mut total = TokenUsage::default();
for turn in &view.turns {
if let Some(usage) = &turn.token_usage {
has_any_usage = true;
total.input_tokens = add_opt(total.input_tokens, usage.input_tokens);
total.output_tokens = add_opt(total.output_tokens, usage.output_tokens);
total.cache_read_tokens = add_opt(total.cache_read_tokens, usage.cache_read_tokens);
total.cache_write_tokens = add_opt(total.cache_write_tokens, usage.cache_write_tokens);
}
}
if has_any_usage {
view.total_usage = Some(total);
}
if let Some(first) = view.turns.first() {
view.started_at = DateTime::parse_from_rfc3339(&first.timestamp)
.ok()
.map(|dt| dt.with_timezone(&chrono::Utc));
}
if let Some(last) = view.turns.last() {
view.last_activity = DateTime::parse_from_rfc3339(&last.timestamp)
.ok()
.map(|dt| dt.with_timezone(&chrono::Utc));
}
view
}
fn handle_init(
view: &mut ConversationView,
artifact_key: &str,
extra: &HashMap<String, serde_json::Value>,
) {
if let Some(rest) = artifact_key.strip_prefix("agent://") {
let parts: Vec<&str> = rest.splitn(2, '/').collect();
if parts.len() == 2 {
view.provider_id = Some(parts[0].to_string());
view.id = parts[1].to_string();
}
}
if let Some(serde_json::Value::String(v)) = extra.get("version") {
let _ = v;
}
}
fn build_turn(step: &Step, extra: &HashMap<String, serde_json::Value>) -> Turn {
let role = if let Some(serde_json::Value::String(r)) = extra.get("role") {
parse_role(r)
} else {
role_from_actor(&step.step.actor)
};
let text = extra
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let thinking = extra
.get("thinking")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let model = model_from_actor(&step.step.actor);
let stop_reason = extra
.get("stop_reason")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let token_usage = build_token_usage(extra);
let environment = build_environment(extra);
let tool_uses = build_inline_tool_uses(extra);
let delegations = build_delegations(extra);
let turn_extra = build_turn_extra(extra);
let parent_id = step.step.parents.first().cloned();
Turn {
id: step.step.id.clone(),
parent_id,
role,
timestamp: step.step.timestamp.clone(),
text,
thinking,
tool_uses,
model,
stop_reason,
token_usage,
environment,
delegations,
extra: turn_extra,
}
}
fn build_environment(extra: &HashMap<String, serde_json::Value>) -> Option<EnvironmentSnapshot> {
if let Some(v) = extra.get("environment")
&& let Ok(env) = serde_json::from_value::<EnvironmentSnapshot>(v.clone())
{
return Some(env);
}
let cwd = extra
.get("cwd")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let branch = extra
.get("git_branch")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if cwd.is_some() || branch.is_some() {
Some(EnvironmentSnapshot {
working_dir: cwd,
vcs_branch: branch,
vcs_revision: None,
})
} else {
None
}
}
fn build_inline_tool_uses(extra: &HashMap<String, serde_json::Value>) -> Vec<ToolInvocation> {
let Some(arr) = extra.get("tool_uses").and_then(|v| v.as_array()) else {
return Vec::new();
};
arr.iter()
.filter_map(|entry| {
let obj = entry.as_object()?;
let id = obj.get("id")?.as_str()?.to_string();
let name = obj.get("name")?.as_str()?.to_string();
let input = obj.get("input").cloned().unwrap_or(serde_json::Value::Null);
let category = parse_category(obj.get("category"));
let result = obj
.get("result")
.and_then(|v| serde_json::from_value::<ToolResult>(v.clone()).ok());
Some(ToolInvocation {
id,
name,
input,
result,
category,
})
})
.collect()
}
fn build_delegations(extra: &HashMap<String, serde_json::Value>) -> Vec<DelegatedWork> {
extra
.get("delegations")
.and_then(|v| serde_json::from_value::<Vec<DelegatedWork>>(v.clone()).ok())
.unwrap_or_default()
}
fn build_turn_extra(
extra: &HashMap<String, serde_json::Value>,
) -> HashMap<String, serde_json::Value> {
let mut out: HashMap<String, serde_json::Value> = HashMap::new();
if let Some(obj) = extra.get("turn_extra").and_then(|v| v.as_object()) {
for (k, v) in obj {
out.insert(k.clone(), v.clone());
}
}
let mut claude_data = serde_json::Map::new();
if let Some(v) = extra.get("version") {
claude_data.insert("version".to_string(), v.clone());
}
if let Some(v) = extra.get("user_type") {
claude_data.insert("user_type".to_string(), v.clone());
}
if let Some(v) = extra.get("request_id") {
claude_data.insert("request_id".to_string(), v.clone());
}
if let Some(entry_extra) = extra.get("entry_extra").and_then(|v| v.as_object()) {
for (k, v) in entry_extra {
claude_data.insert(k.clone(), v.clone());
}
}
if !claude_data.is_empty() {
let merged = match out.remove("claude") {
Some(serde_json::Value::Object(existing)) => {
let mut m = existing;
for (k, v) in claude_data {
m.entry(k).or_insert(v);
}
serde_json::Value::Object(m)
}
_ => serde_json::Value::Object(claude_data),
};
out.insert("claude".to_string(), merged);
}
out
}
fn build_token_usage(extra: &HashMap<String, serde_json::Value>) -> Option<TokenUsage> {
if let Some(v) = extra.get("token_usage")
&& let Ok(usage) = serde_json::from_value::<TokenUsage>(v.clone())
{
return Some(usage);
}
let input = extra
.get("input_tokens")
.and_then(|v| v.as_u64())
.map(|n| n as u32);
let output = extra
.get("output_tokens")
.and_then(|v| v.as_u64())
.map(|n| n as u32);
let cache_read = extra
.get("cache_read_tokens")
.and_then(|v| v.as_u64())
.map(|n| n as u32);
let cache_write = extra
.get("cache_write_tokens")
.and_then(|v| v.as_u64())
.map(|n| n as u32);
if input.is_some() || output.is_some() || cache_read.is_some() || cache_write.is_some() {
Some(TokenUsage {
input_tokens: input,
output_tokens: output,
cache_read_tokens: cache_read,
cache_write_tokens: cache_write,
})
} else {
None
}
}
fn build_tool_invocation(extra: &HashMap<String, serde_json::Value>) -> ToolInvocation {
let id = extra
.get("tool_use_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let name = extra
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let input = extra
.get("input")
.cloned()
.unwrap_or(serde_json::Value::Null);
let is_error = extra
.get("is_error")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let result_content = extra.get("result").and_then(|v| v.as_str());
let result = result_content.map(|content| ToolResult {
content: content.to_string(),
is_error,
});
let category = parse_category(extra.get("category"));
ToolInvocation {
id,
name,
input,
result,
category,
}
}
fn parse_category(value: Option<&serde_json::Value>) -> Option<ToolCategory> {
value
.and_then(|v| v.as_str())
.and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_string())).ok())
}
fn parse_role(s: &str) -> Role {
match s {
"user" => Role::User,
"assistant" => Role::Assistant,
"system" => Role::System,
other => Role::Other(other.to_string()),
}
}
fn model_from_actor(actor: &str) -> Option<String> {
let rest = actor.strip_prefix("agent:")?;
let model = match rest.split_once('/') {
Some((m, _)) => m,
None => rest,
};
if model.is_empty() || model == "unknown" {
None
} else {
Some(model.to_string())
}
}
fn role_from_actor(actor: &str) -> Role {
if actor.contains("/tool:") {
Role::Other("tool".to_string())
} else if actor.starts_with("human:") {
Role::User
} else if actor.starts_with("agent:") {
Role::Assistant
} else if actor.starts_with("tool:") {
Role::System
} else {
Role::Other(actor.to_string())
}
}
fn add_opt(a: Option<u32>, b: Option<u32>) -> Option<u32> {
match (a, b) {
(Some(x), Some(y)) => Some(x + y),
(Some(x), None) => Some(x),
(None, Some(y)) => Some(y),
(None, None) => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use toolpath::v1::{ArtifactChange, PathIdentity, StructuralChange};
#[test]
fn test_model_from_actor_variants() {
assert_eq!(
model_from_actor("agent:claude-opus-4-7"),
Some("claude-opus-4-7".to_string())
);
assert_eq!(
model_from_actor("agent:gemini-3-flash-preview"),
Some("gemini-3-flash-preview".to_string())
);
assert_eq!(
model_from_actor("agent:claude-code/tool:Write"),
Some("claude-code".to_string())
);
assert_eq!(model_from_actor("agent:unknown"), None);
assert_eq!(model_from_actor("human:user"), None);
assert_eq!(model_from_actor("system:gemini-cli"), None);
assert_eq!(model_from_actor("tool:rustfmt"), None);
assert_eq!(model_from_actor(""), None);
assert_eq!(model_from_actor("agent:"), None);
}
fn make_path(steps: Vec<Step>) -> Path {
let head = steps.last().map(|s| s.step.id.clone()).unwrap_or_default();
Path {
path: PathIdentity {
id: "test-path".into(),
base: None,
head,
graph_ref: None,
},
steps,
meta: None,
}
}
fn make_step(
id: &str,
actor: &str,
timestamp: &str,
parents: Vec<&str>,
changes: Vec<(&str, &str, HashMap<String, serde_json::Value>)>,
) -> Step {
let mut change = HashMap::new();
for (key, change_type, extra) in changes {
change.insert(
key.to_string(),
ArtifactChange {
raw: None,
structural: Some(StructuralChange {
change_type: change_type.to_string(),
extra,
}),
},
);
}
Step {
step: toolpath::v1::StepIdentity {
id: id.to_string(),
parents: parents.into_iter().map(String::from).collect(),
actor: actor.to_string(),
timestamp: timestamp.to_string(),
},
change,
meta: None,
}
}
fn extras(pairs: &[(&str, serde_json::Value)]) -> HashMap<String, serde_json::Value> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
}
#[test]
fn test_empty_path() {
let path = make_path(vec![]);
let view = extract_conversation(&path);
assert!(view.id.is_empty());
assert!(view.turns.is_empty());
assert!(view.total_usage.is_none());
assert!(view.started_at.is_none());
assert!(view.last_activity.is_none());
assert!(view.files_changed.is_empty());
}
#[test]
fn test_init_sets_metadata() {
let path = make_path(vec![make_step(
"step-001",
"tool:claude-code",
"2026-01-01T00:00:00Z",
vec![],
vec![(
"agent://claude-code/sess-abc",
"conversation.init",
extras(&[("version", serde_json::json!("1.0"))]),
)],
)]);
let view = extract_conversation(&path);
assert_eq!(view.id, "sess-abc");
assert_eq!(view.provider_id.as_deref(), Some("claude-code"));
}
#[test]
fn test_simple_conversation() {
let path = make_path(vec![
make_step(
"step-001",
"tool:claude-code",
"2026-01-01T00:00:00Z",
vec![],
vec![(
"agent://claude-code/sess-1",
"conversation.init",
HashMap::new(),
)],
),
make_step(
"step-002",
"human:alex",
"2026-01-01T00:00:01Z",
vec!["step-001"],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[
("role", serde_json::json!("user")),
("text", serde_json::json!("Fix the bug")),
]),
)],
),
make_step(
"step-003",
"agent:claude-opus-4-6",
"2026-01-01T00:00:02Z",
vec!["step-002"],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[
("role", serde_json::json!("assistant")),
("text", serde_json::json!("I'll fix that.")),
]),
)],
),
]);
let view = extract_conversation(&path);
assert_eq!(view.turns.len(), 2);
assert_eq!(view.turns[0].role, Role::User);
assert_eq!(view.turns[0].text, "Fix the bug");
assert_eq!(view.turns[0].id, "step-002");
assert_eq!(view.turns[1].role, Role::Assistant);
assert_eq!(view.turns[1].text, "I'll fix that.");
assert_eq!(view.turns[1].model.as_deref(), Some("claude-opus-4-6"));
}
#[test]
fn test_tool_invocations_attached_to_parent() {
let path = make_path(vec![
make_step(
"step-001",
"agent:claude-opus-4-6",
"2026-01-01T00:00:00Z",
vec![],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[
("role", serde_json::json!("assistant")),
("text", serde_json::json!("Let me read the file.")),
]),
)],
),
make_step(
"step-002",
"agent:claude-opus-4-6/tool:Read",
"2026-01-01T00:00:01Z",
vec!["step-001"],
vec![(
"src/main.rs",
"tool.invoke",
extras(&[
("tool_use_id", serde_json::json!("tu-001")),
("name", serde_json::json!("Read")),
("input", serde_json::json!({"file_path": "src/main.rs"})),
("result", serde_json::json!("fn main() {}")),
("is_error", serde_json::json!(false)),
("category", serde_json::json!("file_read")),
]),
)],
),
]);
let view = extract_conversation(&path);
assert_eq!(view.turns.len(), 1);
assert_eq!(view.turns[0].tool_uses.len(), 1);
assert_eq!(view.turns[0].tool_uses[0].id, "tu-001");
assert_eq!(view.turns[0].tool_uses[0].name, "Read");
assert_eq!(
view.turns[0].tool_uses[0].category,
Some(ToolCategory::FileRead)
);
assert!(view.turns[0].tool_uses[0].result.is_some());
assert!(!view.turns[0].tool_uses[0].result.as_ref().unwrap().is_error);
}
#[test]
fn test_token_usage_extracted_and_totaled() {
let path = make_path(vec![
make_step(
"step-001",
"human:alex",
"2026-01-01T00:00:00Z",
vec![],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[
("role", serde_json::json!("user")),
("text", serde_json::json!("hello")),
]),
)],
),
make_step(
"step-002",
"agent:claude-opus-4-6",
"2026-01-01T00:00:01Z",
vec!["step-001"],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[
("role", serde_json::json!("assistant")),
("text", serde_json::json!("hi")),
("input_tokens", serde_json::json!(100)),
("output_tokens", serde_json::json!(50)),
("cache_read_tokens", serde_json::json!(80)),
]),
)],
),
make_step(
"step-003",
"agent:claude-opus-4-6",
"2026-01-01T00:00:02Z",
vec!["step-002"],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[
("role", serde_json::json!("assistant")),
("text", serde_json::json!("more")),
("input_tokens", serde_json::json!(200)),
("output_tokens", serde_json::json!(100)),
]),
)],
),
]);
let view = extract_conversation(&path);
let total = view.total_usage.as_ref().unwrap();
assert_eq!(total.input_tokens, Some(300));
assert_eq!(total.output_tokens, Some(150));
assert_eq!(total.cache_read_tokens, Some(80));
assert!(total.cache_write_tokens.is_none());
}
#[test]
fn test_thinking_blocks_extracted() {
let path = make_path(vec![make_step(
"step-001",
"agent:claude-opus-4-6",
"2026-01-01T00:00:00Z",
vec![],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[
("role", serde_json::json!("assistant")),
("text", serde_json::json!("The answer is 42.")),
(
"thinking",
serde_json::json!("Let me think about this carefully..."),
),
]),
)],
)]);
let view = extract_conversation(&path);
assert_eq!(view.turns.len(), 1);
assert_eq!(
view.turns[0].thinking.as_deref(),
Some("Let me think about this carefully...")
);
}
#[test]
fn test_parent_chain_preserved() {
let path = make_path(vec![
make_step(
"step-001",
"human:alex",
"2026-01-01T00:00:00Z",
vec![],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[
("role", serde_json::json!("user")),
("text", serde_json::json!("first")),
]),
)],
),
make_step(
"step-002",
"agent:claude-opus-4-6",
"2026-01-01T00:00:01Z",
vec!["step-001"],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[
("role", serde_json::json!("assistant")),
("text", serde_json::json!("second")),
]),
)],
),
]);
let view = extract_conversation(&path);
assert!(view.turns[0].parent_id.is_none());
assert_eq!(view.turns[1].parent_id.as_deref(), Some("step-001"));
}
#[test]
fn test_unknown_structural_change_skipped() {
let path = make_path(vec![
make_step(
"step-001",
"human:alex",
"2026-01-01T00:00:00Z",
vec![],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[
("role", serde_json::json!("user")),
("text", serde_json::json!("hello")),
]),
)],
),
make_step(
"step-002",
"agent:claude-opus-4-6",
"2026-01-01T00:00:01Z",
vec!["step-001"],
vec![(
"agent://claude-code/sess-1",
"some.future.type",
extras(&[("data", serde_json::json!("whatever"))]),
)],
),
]);
let view = extract_conversation(&path);
assert_eq!(view.turns.len(), 1);
assert_eq!(view.turns[0].text, "hello");
}
#[test]
fn test_role_fallback_from_actor() {
let path = make_path(vec![
make_step(
"step-001",
"human:alex",
"2026-01-01T00:00:00Z",
vec![],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[("text", serde_json::json!("hello"))]),
)],
),
make_step(
"step-002",
"agent:claude-opus-4-6",
"2026-01-01T00:00:01Z",
vec!["step-001"],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[("text", serde_json::json!("hi back"))]),
)],
),
make_step(
"step-003",
"tool:system-prompt",
"2026-01-01T00:00:02Z",
vec!["step-002"],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[("text", serde_json::json!("system message"))]),
)],
),
]);
let view = extract_conversation(&path);
assert_eq!(view.turns[0].role, Role::User);
assert_eq!(view.turns[1].role, Role::Assistant);
assert_eq!(view.turns[2].role, Role::System);
}
#[test]
fn test_multiple_tool_invocations_same_turn() {
let path = make_path(vec![
make_step(
"step-001",
"agent:claude-opus-4-6",
"2026-01-01T00:00:00Z",
vec![],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[
("role", serde_json::json!("assistant")),
("text", serde_json::json!("Let me check two files.")),
]),
)],
),
make_step(
"step-002",
"agent:claude-opus-4-6/tool:Read",
"2026-01-01T00:00:01Z",
vec!["step-001"],
vec![(
"src/main.rs",
"tool.invoke",
extras(&[
("tool_use_id", serde_json::json!("tu-001")),
("name", serde_json::json!("Read")),
("input", serde_json::json!({"file_path": "src/main.rs"})),
("result", serde_json::json!("fn main() {}")),
("category", serde_json::json!("file_read")),
]),
)],
),
make_step(
"step-003",
"agent:claude-opus-4-6/tool:Read",
"2026-01-01T00:00:02Z",
vec!["step-001"],
vec![(
"src/lib.rs",
"tool.invoke",
extras(&[
("tool_use_id", serde_json::json!("tu-002")),
("name", serde_json::json!("Read")),
("input", serde_json::json!({"file_path": "src/lib.rs"})),
("result", serde_json::json!("pub mod foo;")),
("category", serde_json::json!("file_read")),
]),
)],
),
]);
let view = extract_conversation(&path);
assert_eq!(view.turns.len(), 1);
assert_eq!(view.turns[0].tool_uses.len(), 2);
assert_eq!(view.turns[0].tool_uses[0].id, "tu-001");
assert_eq!(view.turns[0].tool_uses[1].id, "tu-002");
}
#[test]
fn test_files_changed_from_file_write_tools() {
let path = make_path(vec![
make_step(
"step-001",
"agent:claude-opus-4-6",
"2026-01-01T00:00:00Z",
vec![],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[
("role", serde_json::json!("assistant")),
("text", serde_json::json!("Writing files.")),
]),
)],
),
make_step(
"step-002",
"agent:claude-opus-4-6/tool:Edit",
"2026-01-01T00:00:01Z",
vec!["step-001"],
vec![(
"src/main.rs",
"tool.invoke",
extras(&[
("tool_use_id", serde_json::json!("tu-001")),
("name", serde_json::json!("Edit")),
("input", serde_json::json!({})),
("category", serde_json::json!("file_write")),
]),
)],
),
make_step(
"step-003",
"agent:claude-opus-4-6/tool:Edit",
"2026-01-01T00:00:02Z",
vec!["step-001"],
vec![(
"src/main.rs",
"tool.invoke",
extras(&[
("tool_use_id", serde_json::json!("tu-002")),
("name", serde_json::json!("Edit")),
("input", serde_json::json!({})),
("category", serde_json::json!("file_write")),
]),
)],
),
]);
let view = extract_conversation(&path);
assert_eq!(view.files_changed, vec!["src/main.rs"]);
}
#[test]
fn test_timestamps_parsed() {
let path = make_path(vec![
make_step(
"step-001",
"human:alex",
"2026-01-01T10:00:00Z",
vec![],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[
("role", serde_json::json!("user")),
("text", serde_json::json!("hello")),
]),
)],
),
make_step(
"step-002",
"agent:claude-opus-4-6",
"2026-01-01T10:05:00Z",
vec!["step-001"],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[
("role", serde_json::json!("assistant")),
("text", serde_json::json!("hi")),
]),
)],
),
]);
let view = extract_conversation(&path);
assert!(view.started_at.is_some());
assert!(view.last_activity.is_some());
assert!(view.last_activity.unwrap() > view.started_at.unwrap());
}
#[test]
fn test_steps_without_structural_changes_skipped() {
let path = make_path(vec![make_step(
"step-001",
"human:alex",
"2026-01-01T00:00:00Z",
vec![],
vec![], )]);
let view = extract_conversation(&path);
assert!(view.turns.is_empty());
}
#[test]
fn test_environment_from_cwd_and_git_branch() {
let path = make_path(vec![make_step(
"step-001",
"human:alex",
"2026-01-01T00:00:00Z",
vec![],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[
("role", serde_json::json!("user")),
("text", serde_json::json!("hello")),
("cwd", serde_json::json!("/home/alex/project")),
("git_branch", serde_json::json!("feature/cool")),
]),
)],
)]);
let view = extract_conversation(&path);
let env = view.turns[0].environment.as_ref().unwrap();
assert_eq!(env.working_dir.as_deref(), Some("/home/alex/project"));
assert_eq!(env.vcs_branch.as_deref(), Some("feature/cool"));
assert!(env.vcs_revision.is_none());
}
#[test]
fn test_environment_none_when_absent() {
let path = make_path(vec![make_step(
"step-001",
"human:alex",
"2026-01-01T00:00:00Z",
vec![],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[
("role", serde_json::json!("user")),
("text", serde_json::json!("hello")),
]),
)],
)]);
let view = extract_conversation(&path);
assert!(view.turns[0].environment.is_none());
}
#[test]
fn test_extra_claude_metadata() {
let path = make_path(vec![make_step(
"step-001",
"agent:claude-opus-4-6",
"2026-01-01T00:00:00Z",
vec![],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[
("role", serde_json::json!("assistant")),
("text", serde_json::json!("hi")),
("version", serde_json::json!("1.0.30")),
("user_type", serde_json::json!("pro")),
("request_id", serde_json::json!("req-abc-123")),
]),
)],
)]);
let view = extract_conversation(&path);
let claude = view.turns[0].extra.get("claude").unwrap();
assert_eq!(claude["version"], serde_json::json!("1.0.30"));
assert_eq!(claude["user_type"], serde_json::json!("pro"));
assert_eq!(claude["request_id"], serde_json::json!("req-abc-123"));
}
#[test]
fn test_entry_extra_merged_into_claude() {
let path = make_path(vec![make_step(
"step-001",
"agent:claude-opus-4-6",
"2026-01-01T00:00:00Z",
vec![],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[
("role", serde_json::json!("assistant")),
("text", serde_json::json!("hi")),
(
"entry_extra",
serde_json::json!({
"entrypoint": "cli",
"isMeta": true,
"slug": "my-project"
}),
),
]),
)],
)]);
let view = extract_conversation(&path);
let claude = view.turns[0].extra.get("claude").unwrap();
assert_eq!(claude["entrypoint"], serde_json::json!("cli"));
assert_eq!(claude["isMeta"], serde_json::json!(true));
assert_eq!(claude["slug"], serde_json::json!("my-project"));
}
#[test]
fn test_extra_empty_when_no_metadata() {
let path = make_path(vec![make_step(
"step-001",
"human:alex",
"2026-01-01T00:00:00Z",
vec![],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[
("role", serde_json::json!("user")),
("text", serde_json::json!("hello")),
]),
)],
)]);
let view = extract_conversation(&path);
assert!(view.turns[0].extra.is_empty());
}
#[test]
fn test_agent_url_tool_not_in_files_changed() {
let path = make_path(vec![
make_step(
"step-001",
"agent:claude-opus-4-6",
"2026-01-01T00:00:00Z",
vec![],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[
("role", serde_json::json!("assistant")),
("text", serde_json::json!("Searching...")),
]),
)],
),
make_step(
"step-002",
"agent:claude-opus-4-6/tool:WebSearch",
"2026-01-01T00:00:01Z",
vec!["step-001"],
vec![(
"agent://claude-code/sess-1/tool/network/tu-001",
"tool.invoke",
extras(&[
("tool_use_id", serde_json::json!("tu-001")),
("name", serde_json::json!("WebSearch")),
("input", serde_json::json!({"query": "rust async"})),
("category", serde_json::json!("file_write")),
]),
)],
),
]);
let view = extract_conversation(&path);
assert!(view.files_changed.is_empty());
}
#[test]
fn test_conversation_event_extracted() {
let path = make_path(vec![
make_step(
"step-001",
"tool:claude-code",
"2026-01-01T00:00:00Z",
vec![],
vec![(
"agent://claude-code/sess-1",
"conversation.event",
extras(&[
("entry_type", serde_json::json!("attachment")),
("cwd", serde_json::json!("/home/alex/project")),
("version", serde_json::json!("1.0.30")),
(
"entry_extra",
serde_json::json!({"attachment": {"fileName": "test.png"}}),
),
]),
)],
),
make_step(
"step-002",
"tool:claude-code",
"2026-01-01T00:00:01Z",
vec!["step-001"],
vec![(
"agent://claude-code/sess-1",
"conversation.event",
extras(&[
("entry_type", serde_json::json!("file-history-snapshot")),
("snapshot", serde_json::json!({"files": []})),
]),
)],
),
]);
let view = extract_conversation(&path);
assert!(view.turns.is_empty());
assert_eq!(view.events.len(), 2);
assert_eq!(view.events[0].id, "step-001");
assert_eq!(view.events[0].event_type, "attachment");
assert_eq!(
view.events[0].data["cwd"],
serde_json::json!("/home/alex/project")
);
assert_eq!(view.events[0].data["version"], serde_json::json!("1.0.30"));
assert!(view.events[0].parent_id.is_none());
assert_eq!(view.events[1].id, "step-002");
assert_eq!(view.events[1].event_type, "file-history-snapshot");
assert_eq!(view.events[1].parent_id.as_deref(), Some("step-001"));
assert!(view.events[1].data.contains_key("snapshot"));
}
#[test]
fn test_conversation_event_with_unknown_type() {
let path = make_path(vec![make_step(
"step-001",
"tool:claude-code",
"2026-01-01T00:00:00Z",
vec![],
vec![(
"agent://claude-code/sess-1",
"conversation.event",
extras(&[("cwd", serde_json::json!("/tmp"))]),
)],
)]);
let view = extract_conversation(&path);
assert_eq!(view.events.len(), 1);
assert_eq!(view.events[0].event_type, "unknown");
}
#[test]
fn test_conversation_event_mixed_with_turns() {
let path = make_path(vec![
make_step(
"step-001",
"tool:claude-code",
"2026-01-01T00:00:00Z",
vec![],
vec![(
"agent://claude-code/sess-1",
"conversation.event",
extras(&[("entry_type", serde_json::json!("system"))]),
)],
),
make_step(
"step-002",
"human:alex",
"2026-01-01T00:00:01Z",
vec!["step-001"],
vec![(
"agent://claude-code/sess-1",
"conversation.append",
extras(&[
("role", serde_json::json!("user")),
("text", serde_json::json!("hello")),
]),
)],
),
]);
let view = extract_conversation(&path);
assert_eq!(view.turns.len(), 1);
assert_eq!(view.events.len(), 1);
assert_eq!(view.turns[0].text, "hello");
assert_eq!(view.events[0].event_type, "system");
}
}