use std::collections::HashMap;
use toolpath::v1::{
ActorDefinition, ArtifactChange, Base, PATH_KIND_AGENT_CODING_SESSION, Path, PathIdentity,
PathMeta, Step, StepIdentity, StructuralChange,
};
use crate::{ConversationView, Role, ToolCategory, ToolInvocation, Turn};
#[derive(Debug, Clone)]
pub struct DeriveConfig {
pub base_uri: Option<String>,
pub path_id: Option<String>,
pub title: Option<String>,
pub include_thinking: bool,
pub include_tool_uses: bool,
}
impl Default for DeriveConfig {
fn default() -> Self {
Self {
base_uri: None,
path_id: None,
title: None,
include_thinking: true,
include_tool_uses: true,
}
}
}
pub fn derive_path(view: &ConversationView, config: &DeriveConfig) -> Path {
let provider = view.provider_id.as_deref().unwrap_or("unknown");
let id_prefix: String = view.id.chars().take(8).collect();
let path_id = config
.path_id
.clone()
.unwrap_or_else(|| format!("path-{}-{}", provider, id_prefix));
let base = config
.base_uri
.clone()
.map(|uri| Base {
uri,
ref_str: view.base.as_ref().and_then(|b| b.vcs_revision.clone()),
branch: view.base.as_ref().and_then(|b| b.vcs_branch.clone()),
})
.or_else(|| {
view.base.as_ref().and_then(|b| {
let wd = b.working_dir.as_ref()?;
let uri = if wd.starts_with('/') {
format!("file://{}", wd)
} else {
wd.clone()
};
Some(Base {
uri,
ref_str: b.vcs_revision.clone(),
branch: b.vcs_branch.clone(),
})
})
})
.or_else(|| {
view.turns
.iter()
.find_map(|t| t.environment.as_ref()?.working_dir.clone())
.map(|wd| {
let uri = if wd.starts_with('/') {
format!("file://{}", wd)
} else {
wd
};
Base {
uri,
ref_str: None,
branch: None,
}
})
});
let conv_artifact_key = format!("{}://{}", provider, view.id);
let mut steps: Vec<Step> = Vec::with_capacity(view.turns.len());
let mut turn_to_step: HashMap<String, String> = HashMap::new();
let mut actors: HashMap<String, ActorDefinition> = HashMap::new();
for (idx, turn) in view.turns.iter().enumerate() {
let step_id = if turn.id.is_empty() {
format!("step-{:04}", idx + 1)
} else {
turn.id.clone()
};
turn_to_step.insert(turn.id.clone(), step_id.clone());
let actor = actor_for_turn(turn, provider);
record_actor(&mut actors, &actor, turn, provider, view);
let mut step = Step {
step: StepIdentity {
id: step_id,
parents: Vec::new(),
actor,
timestamp: turn.timestamp.clone(),
},
change: HashMap::new(),
meta: None,
};
if let Some(parent_id) = &turn.parent_id
&& let Some(parent_step_id) = turn_to_step.get(parent_id)
{
step.step.parents.push(parent_step_id.clone());
}
let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
extra.insert(
"role".to_string(),
serde_json::Value::String(turn.role.to_string()),
);
extra.insert(
"text".to_string(),
serde_json::Value::String(turn.text.clone()),
);
if config.include_thinking
&& let Some(thinking) = &turn.thinking
{
extra.insert(
"thinking".to_string(),
serde_json::Value::String(thinking.clone()),
);
}
if config.include_tool_uses && !turn.tool_uses.is_empty() {
let arr: Vec<serde_json::Value> = turn
.tool_uses
.iter()
.map(|t| {
let mut obj = serde_json::json!({
"id": t.id,
"name": t.name,
"input": t.input,
"category": t.category,
});
if let Some(result) = &t.result
&& let Ok(v) = serde_json::to_value(result)
{
obj.as_object_mut().unwrap().insert("result".to_string(), v);
}
obj
})
.collect();
extra.insert("tool_uses".to_string(), serde_json::Value::Array(arr));
}
if let Some(usage) = &turn.token_usage
&& let Ok(v) = serde_json::to_value(usage)
{
extra.insert("token_usage".to_string(), v);
}
if !turn.delegations.is_empty()
&& let Ok(v) = serde_json::to_value(&turn.delegations)
{
extra.insert("delegations".to_string(), v);
}
if let Some(stop_reason) = &turn.stop_reason {
extra.insert(
"stop_reason".to_string(),
serde_json::Value::String(stop_reason.clone()),
);
}
if let Some(env) = &turn.environment
&& let Ok(v) = serde_json::to_value(env)
{
extra.insert("environment".to_string(), v);
}
step.change.insert(
conv_artifact_key.clone(),
ArtifactChange {
raw: None,
structural: Some(StructuralChange {
change_type: "conversation.append".to_string(),
extra,
}),
},
);
let attributed: std::collections::HashSet<String> = turn
.file_mutations
.iter()
.filter_map(|fm| fm.tool_id.clone())
.collect();
for fm in &turn.file_mutations {
let mut t_extra: HashMap<String, serde_json::Value> = HashMap::new();
if let Some(tid) = &fm.tool_id {
t_extra.insert(
"tool_id".to_string(),
serde_json::Value::String(tid.clone()),
);
if let Some(tool) = turn.tool_uses.iter().find(|t| &t.id == tid) {
t_extra.insert(
"tool".to_string(),
serde_json::Value::String(tool.name.clone()),
);
}
}
if let Some(op) = &fm.operation {
t_extra.insert(
"operation".to_string(),
serde_json::Value::String(op.clone()),
);
}
if let Some(b) = &fm.before {
t_extra.insert("before".to_string(), serde_json::Value::String(b.clone()));
}
if let Some(a) = &fm.after {
t_extra.insert("after".to_string(), serde_json::Value::String(a.clone()));
}
if let Some(rt) = &fm.rename_to {
t_extra.insert(
"rename_to".to_string(),
serde_json::Value::String(rt.clone()),
);
}
step.change.insert(
fm.path.clone(),
ArtifactChange {
raw: fm.raw_diff.clone(),
structural: Some(StructuralChange {
change_type: "file.write".to_string(),
extra: t_extra,
}),
},
);
}
for tool in &turn.tool_uses {
if tool.category != Some(ToolCategory::FileWrite) || attributed.contains(&tool.id) {
continue;
}
let Some(path) = extract_file_path(tool) else {
continue;
};
let (raw, mut t_extra) = file_write_change(tool, &path, None);
t_extra.insert(
"tool".to_string(),
serde_json::Value::String(tool.name.clone()),
);
t_extra.insert(
"tool_id".to_string(),
serde_json::Value::String(tool.id.clone()),
);
step.change.insert(
path,
ArtifactChange {
raw,
structural: Some(StructuralChange {
change_type: "file.write".to_string(),
extra: t_extra,
}),
},
);
}
steps.push(step);
}
let mut last_step_id: Option<String> = steps.last().map(|s| s.step.id.clone());
for (idx, event) in view.events.iter().enumerate() {
let step_id = if event.id.is_empty() {
format!("event-{:04}", idx + 1)
} else {
event.id.clone()
};
let actor = format!("tool:{}", provider);
actors
.entry(actor.clone())
.or_insert_with(|| ActorDefinition {
name: Some(provider.to_string()),
provider: Some(provider.to_string()),
..Default::default()
});
let mut extra: HashMap<String, serde_json::Value> = event
.data
.iter()
.filter(|(k, _)| k.as_str() != "type")
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
if let Some(t) = event.data.get("type") {
extra.insert("event_data_type".to_string(), t.clone());
}
extra.insert(
"entry_type".to_string(),
serde_json::Value::String(event.event_type.clone()),
);
if !event.id.is_empty() {
extra.insert(
"event_source_id".to_string(),
serde_json::Value::String(event.id.clone()),
);
}
let parents: Vec<String> = event
.parent_id
.as_ref()
.and_then(|pid| turn_to_step.get(pid).cloned())
.or_else(|| last_step_id.clone())
.into_iter()
.collect();
let mut step = Step {
step: StepIdentity {
id: step_id.clone(),
parents,
actor,
timestamp: event.timestamp.clone(),
},
change: HashMap::new(),
meta: None,
};
step.change.insert(
conv_artifact_key.clone(),
ArtifactChange {
raw: None,
structural: Some(StructuralChange {
change_type: "conversation.event".to_string(),
extra,
}),
},
);
steps.push(step);
last_step_id = Some(step_id);
}
let head = steps.last().map(|s| s.step.id.clone()).unwrap_or_default();
let title = config
.title
.clone()
.unwrap_or_else(|| format!("{} session: {}", provider, id_prefix));
let mut meta = PathMeta {
title: Some(title),
kind: Some(PATH_KIND_AGENT_CODING_SESSION.to_string()),
source: view.provider_id.clone(),
..Default::default()
};
if !actors.is_empty() {
meta.actors = Some(actors);
}
if !view.files_changed.is_empty()
&& let Ok(v) = serde_json::to_value(&view.files_changed)
{
meta.extra.insert("files_changed".to_string(), v);
}
if let Some(remote) = view.base.as_ref().and_then(|b| b.vcs_remote.as_ref())
&& !meta.extra.contains_key("vcs_remote")
{
meta.extra.insert(
"vcs_remote".to_string(),
serde_json::Value::String(remote.clone()),
);
}
if let Some(producer) = &view.producer
&& let Ok(v) = serde_json::to_value(producer)
{
meta.extra.insert("producer".to_string(), v);
}
Path {
path: PathIdentity {
id: path_id,
base,
head,
graph_ref: None,
},
steps,
meta: Some(meta),
}
}
fn actor_for_turn(turn: &Turn, provider: &str) -> String {
match &turn.role {
Role::User => "human:user".to_string(),
Role::Assistant => {
let model = turn.model.as_deref().unwrap_or("unknown");
format!("agent:{}", model)
}
Role::System => format!("tool:{}", provider),
Role::Other(_) => format!("tool:{}", provider),
}
}
fn record_actor(
actors: &mut HashMap<String, ActorDefinition>,
actor: &str,
turn: &Turn,
provider: &str,
_view: &ConversationView,
) {
if actors.contains_key(actor) {
return;
}
let def = if let Some(rest) = actor.strip_prefix("agent:") {
ActorDefinition {
name: Some(rest.to_string()),
provider: Some(provider.to_string()),
model: turn.model.clone(),
identities: vec![],
keys: vec![],
}
} else if let Some(rest) = actor.strip_prefix("human:") {
ActorDefinition {
name: Some(rest.to_string()),
..Default::default()
}
} else {
let name = actor.split_once(':').map(|x| x.1).unwrap_or("").to_string();
ActorDefinition {
name: Some(name),
provider: Some(provider.to_string()),
..Default::default()
}
};
actors.insert(actor.to_string(), def);
}
fn extract_file_path(tool: &ToolInvocation) -> Option<String> {
for field in &["file_path", "path", "filename", "file"] {
if let Some(v) = tool.input.get(*field)
&& let Some(s) = v.as_str()
{
return Some(s.to_string());
}
}
None
}
fn file_write_change(
tool: &ToolInvocation,
path: &str,
before_state: Option<&str>,
) -> (Option<String>, HashMap<String, serde_json::Value>) {
let input = &tool.input;
let str_field = |k: &str| input.get(k).and_then(|v| v.as_str()).map(str::to_string);
let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
if let (Some(old), Some(new)) = (str_field("old_string"), str_field("new_string")) {
extra.insert("before".to_string(), serde_json::Value::String(old.clone()));
extra.insert("after".to_string(), serde_json::Value::String(new.clone()));
} else if let Some(content) = str_field("content") {
if let Some(before) = before_state {
extra.insert(
"before".to_string(),
serde_json::Value::String(before.to_string()),
);
}
extra.insert("after".to_string(), serde_json::Value::String(content));
} else if let Some(edits) = input.get("edits").and_then(|v| v.as_array()) {
extra.insert("edits".to_string(), serde_json::Value::Array(edits.clone()));
}
(
file_write_diff(&tool.name, input, path, before_state),
extra,
)
}
pub fn file_write_diff(
tool_name: &str,
input: &serde_json::Value,
path: &str,
before_state: Option<&str>,
) -> Option<String> {
let str_field = |k: &str| input.get(k).and_then(|v| v.as_str());
if let (Some(old), Some(new)) = (str_field("old_string"), str_field("new_string")) {
return Some(unified_diff(path, old, new));
}
if let Some(content) = str_field("content") {
let before = before_state.unwrap_or("");
return Some(unified_diff(path, before, content));
}
if let Some(edits) = input.get("edits").and_then(|v| v.as_array()) {
if edits.is_empty() {
return None;
}
let mut parts: Vec<String> = Vec::new();
for (idx, edit) in edits.iter().enumerate() {
let old = edit
.get("old_string")
.and_then(|v| v.as_str())
.unwrap_or("");
let new = edit
.get("new_string")
.and_then(|v| v.as_str())
.unwrap_or("");
let header = format!("# edit {}/{}", idx + 1, edits.len());
parts.push(format!("{header}\n{}", unified_diff(path, old, new)));
}
return Some(parts.join("\n"));
}
let _ = tool_name;
None
}
pub fn unified_diff(path: &str, before: &str, after: &str) -> String {
use similar::TextDiff;
let diff = TextDiff::from_lines(before, after);
let display = path.trim_start_matches('/');
let mut out = String::new();
out.push_str(&format!("--- a/{display}\n+++ b/{display}\n"));
out.push_str(
&diff
.unified_diff()
.context_radius(3)
.header("", "")
.to_string(),
);
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{DelegatedWork, EnvironmentSnapshot, TokenUsage, ToolInvocation, ToolResult};
fn base_turn(id: &str, role: Role) -> Turn {
Turn {
id: id.to_string(),
parent_id: None,
role,
timestamp: "2026-01-01T00:00:00Z".to_string(),
text: String::new(),
thinking: None,
tool_uses: vec![],
model: None,
stop_reason: None,
token_usage: None,
environment: None,
delegations: vec![],
file_mutations: Vec::new(),
}
}
fn view_with(turns: Vec<Turn>) -> ConversationView {
ConversationView {
id: "abcdef012345".to_string(),
turns,
provider_id: Some("pi".to_string()),
..Default::default()
}
}
fn conv_change(step: &Step) -> &StructuralChange {
let key = step
.change
.keys()
.find(|k| k.contains("://"))
.expect("conversation artifact key present");
step.change[key].structural.as_ref().unwrap()
}
#[test]
fn test_empty_view() {
let view = view_with(vec![]);
let path = derive_path(&view, &DeriveConfig::default());
assert!(path.steps.is_empty());
assert_eq!(path.path.head, "");
}
#[test]
fn test_meta_kind_is_convo() {
let view = view_with(vec![base_turn("t1", Role::User)]);
let path = derive_path(&view, &DeriveConfig::default());
assert_eq!(
path.meta.as_ref().unwrap().kind.as_deref(),
Some(PATH_KIND_AGENT_CODING_SESSION)
);
let json = serde_json::to_string(&path).unwrap();
assert!(
json.contains(r#""kind":"https://toolpath.net/kinds/agent-coding-session/v1.0.0""#)
);
}
#[test]
fn test_single_user_turn() {
let mut turn = base_turn("t1", Role::User);
turn.text = "hello".into();
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());
assert_eq!(path.steps.len(), 1);
assert_eq!(path.steps[0].step.actor, "human:user");
assert_eq!(path.steps[0].step.id, "t1");
}
#[test]
fn test_single_assistant_turn() {
let mut turn = base_turn("t1", Role::Assistant);
turn.model = Some("claude-opus-4-7".into());
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());
assert_eq!(path.steps[0].step.actor, "agent:claude-opus-4-7");
}
#[test]
fn test_assistant_without_model() {
let turn = base_turn("t1", Role::Assistant);
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());
assert_eq!(path.steps[0].step.actor, "agent:unknown");
}
#[test]
fn test_system_role() {
let turn = base_turn("t1", Role::System);
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());
assert_eq!(path.steps[0].step.actor, "tool:pi");
}
#[test]
fn test_other_role() {
let turn = base_turn("t1", Role::Other("tool".into()));
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());
assert_eq!(path.steps[0].step.actor, "tool:pi");
}
#[test]
fn test_parent_id_preserved() {
let t1 = base_turn("t1", Role::User);
let mut t2 = base_turn("t2", Role::Assistant);
t2.parent_id = Some("t1".into());
t2.model = Some("m".into());
let view = view_with(vec![t1, t2]);
let path = derive_path(&view, &DeriveConfig::default());
assert_eq!(path.steps[1].step.parents, vec!["t1".to_string()]);
}
#[test]
fn derived_path_validates_against_base_schema() {
let user = base_turn("t1", Role::User);
let mut assistant = base_turn("t2", Role::Assistant);
assistant.parent_id = Some("t1".into());
assistant.model = Some("gpt-5.5".into());
let system = base_turn("t3", Role::System);
let other = base_turn("t4", Role::Other("bash".into()));
let mut view = view_with(vec![user, assistant, system, other]);
view.events.push(crate::ConversationEvent {
id: "e1".into(),
timestamp: "2026-01-01T00:00:00Z".into(),
parent_id: None,
event_type: "attachment".into(),
data: HashMap::new(),
});
let path = derive_path(&view, &DeriveConfig::default());
let graph = serde_json::json!({
"graph": { "id": "g1" },
"paths": [serde_json::to_value(&path).unwrap()],
});
let schema: serde_json::Value = serde_json::from_str(toolpath::SCHEMA_JSON).unwrap();
let validator = jsonschema::validator_for(&schema).unwrap();
let errors: Vec<String> = validator
.iter_errors(&graph)
.map(|e| format!("at {}: {e}", e.instance_path()))
.collect();
assert!(
errors.is_empty(),
"base-schema violations:\n{}",
errors.join("\n")
);
}
#[test]
fn derived_path_conforms_to_agent_coding_session_kind() {
let mut user = base_turn("t1", Role::User);
user.text = "implement the feature".into();
let mut assistant = base_turn("t2", Role::Assistant);
assistant.parent_id = Some("t1".into());
assistant.model = Some("gpt-5.5".into());
assistant.text = "on it".into();
assistant.thinking = Some("plan the edit".into());
assistant.stop_reason = Some("tool_use".into());
assistant.token_usage = Some(TokenUsage {
input_tokens: Some(100),
output_tokens: Some(20),
cache_read_tokens: Some(50),
cache_write_tokens: None,
});
assistant.environment = Some(EnvironmentSnapshot {
working_dir: Some("/repo".into()),
vcs_branch: Some("main".into()),
vcs_revision: None,
});
assistant.tool_uses = vec![ToolInvocation {
id: "call-1".into(),
name: "write_file".into(),
input: serde_json::json!({ "file_path": "a.rs", "content": "fn main() {}" }),
result: Some(ToolResult {
content: "ok".into(),
is_error: false,
}),
category: Some(crate::ToolCategory::FileWrite),
}];
assistant.file_mutations = vec![crate::FileMutation {
path: "a.rs".into(),
tool_id: Some("call-1".into()),
operation: Some("add".into()),
raw_diff: Some("@@ -0,0 +1 @@\n+fn main() {}".into()),
before: None,
after: Some("fn main() {}".into()),
rename_to: None,
}];
assistant.delegations = vec![DelegatedWork {
agent_id: "sub-1".into(),
prompt: "do the subtask".into(),
turns: vec![],
result: Some("done".into()),
}];
let mut system = base_turn("t3", Role::System);
system.parent_id = Some("t2".into());
system.text = "system note".into();
let mut other = base_turn("t4", Role::Other("tool".into()));
other.parent_id = Some("t3".into());
other.text = "tool output".into();
let mut view = view_with(vec![user, assistant, system, other]);
view.events.push(crate::ConversationEvent {
id: "e1".into(),
timestamp: "2026-01-01T00:00:00Z".into(),
parent_id: None,
event_type: "attachment".into(),
data: HashMap::new(),
});
let path = derive_path(&view, &DeriveConfig::default());
assert_eq!(
path.meta.as_ref().and_then(|m| m.kind.as_deref()),
Some(toolpath::v1::PATH_KIND_AGENT_CODING_SESSION),
"derive_path must stamp the agent-coding-session kind"
);
let schema_src = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../path-cli/kinds/agent-coding-session/v1.0.0/schema.json"
))
.expect("read kind schema");
let schema: serde_json::Value = serde_json::from_str(&schema_src).unwrap();
let validator = jsonschema::validator_for(&schema).unwrap();
let value = serde_json::to_value(&path).unwrap();
let errors: Vec<String> = validator
.iter_errors(&value)
.map(|e| format!("at {}: {e}", e.instance_path()))
.collect();
assert!(
errors.is_empty(),
"kind-schema violations:\n{}",
errors.join("\n")
);
}
fn fw_tool(name: &str, id: &str, input: serde_json::Value) -> ToolInvocation {
ToolInvocation {
id: id.to_string(),
name: name.to_string(),
input,
result: None,
category: Some(ToolCategory::FileWrite),
}
}
#[test]
fn test_tool_use_filewrite_with_file_path_field() {
let mut turn = base_turn("t1", Role::Assistant);
turn.tool_uses = vec![fw_tool(
"Write",
"tu1",
serde_json::json!({"file_path": "src/main.rs"}),
)];
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());
assert!(path.steps[0].change.contains_key("src/main.rs"));
let sc = path.steps[0].change["src/main.rs"]
.structural
.as_ref()
.unwrap();
assert_eq!(sc.change_type, "file.write");
assert_eq!(sc.extra["tool"], serde_json::json!("Write"));
assert_eq!(sc.extra["tool_id"], serde_json::json!("tu1"));
}
#[test]
fn test_tool_use_filewrite_with_path_field() {
let mut turn = base_turn("t1", Role::Assistant);
turn.tool_uses = vec![fw_tool("Edit", "tu1", serde_json::json!({"path": "a.rs"}))];
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());
assert!(path.steps[0].change.contains_key("a.rs"));
}
#[test]
fn test_tool_use_filewrite_with_filename_field() {
let mut turn = base_turn("t1", Role::Assistant);
turn.tool_uses = vec![fw_tool("W", "tu1", serde_json::json!({"filename": "b.rs"}))];
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());
assert!(path.steps[0].change.contains_key("b.rs"));
}
#[test]
fn test_tool_use_filewrite_with_file_field() {
let mut turn = base_turn("t1", Role::Assistant);
turn.tool_uses = vec![fw_tool("W", "tu1", serde_json::json!({"file": "c.rs"}))];
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());
assert!(path.steps[0].change.contains_key("c.rs"));
}
#[test]
fn test_tool_use_filewrite_no_recognized_field() {
let mut turn = base_turn("t1", Role::Assistant);
turn.tool_uses = vec![fw_tool("W", "tu1", serde_json::json!({"other": "foo"}))];
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());
assert_eq!(path.steps[0].change.len(), 1);
let sc = conv_change(&path.steps[0]);
assert!(sc.extra.contains_key("tool_uses"));
}
#[test]
fn test_tool_use_non_filewrite_ignored() {
let mut turn = base_turn("t1", Role::Assistant);
turn.tool_uses = vec![ToolInvocation {
id: "tu1".into(),
name: "Read".into(),
input: serde_json::json!({"file_path": "x.rs"}),
result: None,
category: Some(ToolCategory::FileRead),
}];
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());
assert!(!path.steps[0].change.contains_key("x.rs"));
assert_eq!(path.steps[0].change.len(), 1);
}
#[test]
fn test_tool_use_edit_emits_unified_diff() {
let mut turn = base_turn("t1", Role::Assistant);
turn.tool_uses = vec![fw_tool(
"Edit",
"tu1",
serde_json::json!({
"file_path": "src/login.rs",
"old_string": "validate_token()",
"new_string": "validate_token_v2()",
}),
)];
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());
let ch = &path.steps[0].change["src/login.rs"];
let raw = ch.raw.as_deref().expect("edit should emit unified diff");
assert!(raw.contains("--- a/src/login.rs"));
assert!(raw.contains("+++ b/src/login.rs"));
assert!(raw.contains("-validate_token()"));
assert!(raw.contains("+validate_token_v2()"));
let sc = ch.structural.as_ref().unwrap();
assert_eq!(sc.extra["before"], serde_json::json!("validate_token()"));
assert_eq!(sc.extra["after"], serde_json::json!("validate_token_v2()"));
}
#[test]
fn test_tool_use_write_emits_full_content_diff() {
let mut turn = base_turn("t1", Role::Assistant);
turn.tool_uses = vec![fw_tool(
"Write",
"tu1",
serde_json::json!({
"file_path": "hello.txt",
"content": "hi\nthere\n",
}),
)];
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());
let ch = &path.steps[0].change["hello.txt"];
let raw = ch.raw.as_deref().expect("write should emit diff");
assert!(raw.contains("+hi"));
assert!(raw.contains("+there"));
let sc = ch.structural.as_ref().unwrap();
assert_eq!(sc.extra["after"], serde_json::json!("hi\nthere\n"));
assert!(!sc.extra.contains_key("before"));
}
#[test]
fn test_file_write_diff_write_without_before_state_is_addition_only() {
let input = serde_json::json!({
"file_path": "hello.txt",
"content": "hi\nthere\n",
});
let raw =
file_write_diff("Write", &input, "hello.txt", None).expect("write should emit diff");
assert!(raw.contains("+hi"));
assert!(raw.contains("+there"));
assert!(
!raw.lines()
.any(|l| l.starts_with('-') && !l.starts_with("---"))
);
}
#[test]
fn test_file_write_diff_write_with_before_state_shows_replacement() {
let input = serde_json::json!({
"file_path": "hello.txt",
"content": "hi\nthere\n",
});
let raw = file_write_diff("Write", &input, "hello.txt", Some("bye\nfriend\n"))
.expect("write should emit diff");
assert!(raw.contains("-bye"));
assert!(raw.contains("-friend"));
assert!(raw.contains("+hi"));
assert!(raw.contains("+there"));
}
#[test]
fn test_file_write_diff_before_state_ignored_for_edit_shape() {
let input = serde_json::json!({
"file_path": "a.rs",
"old_string": "foo",
"new_string": "bar",
});
let raw = file_write_diff("Edit", &input, "a.rs", Some("something else entirely"))
.expect("edit should emit diff");
assert!(raw.contains("-foo"));
assert!(raw.contains("+bar"));
assert!(!raw.contains("something else entirely"));
}
#[test]
fn test_unified_diff_strips_leading_slash_on_absolute_path() {
let raw = unified_diff("/abs/path.rs", "a\n", "b\n");
assert!(
raw.contains("--- a/abs/path.rs\n"),
"missing stripped --- header: {raw}"
);
assert!(
raw.contains("+++ b/abs/path.rs\n"),
"missing stripped +++ header: {raw}"
);
assert!(
!raw.contains("a//"),
"header should not contain doubled slash: {raw}"
);
assert!(
!raw.contains("b//"),
"header should not contain doubled slash: {raw}"
);
}
#[test]
fn test_unified_diff_preserves_relative_path() {
let raw = unified_diff("src/login.rs", "a\n", "b\n");
assert!(raw.contains("--- a/src/login.rs\n"), "{raw}");
assert!(raw.contains("+++ b/src/login.rs\n"), "{raw}");
}
#[test]
fn test_tool_use_multiedit_emits_per_hunk_diff() {
let mut turn = base_turn("t1", Role::Assistant);
turn.tool_uses = vec![fw_tool(
"MultiEdit",
"tu1",
serde_json::json!({
"file_path": "m.rs",
"edits": [
{"old_string": "foo", "new_string": "bar"},
{"old_string": "baz", "new_string": "qux"},
],
}),
)];
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());
let ch = &path.steps[0].change["m.rs"];
let raw = ch.raw.as_deref().expect("multiedit should emit diff");
assert!(raw.contains("# edit 1/2"));
assert!(raw.contains("# edit 2/2"));
assert!(raw.contains("-foo"));
assert!(raw.contains("+bar"));
assert!(raw.contains("-baz"));
assert!(raw.contains("+qux"));
}
#[test]
fn test_thinking_included_when_enabled() {
let mut turn = base_turn("t1", Role::Assistant);
turn.thinking = Some("hmm".into());
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());
let sc = conv_change(&path.steps[0]);
assert_eq!(sc.extra["thinking"], serde_json::json!("hmm"));
}
#[test]
fn test_thinking_omitted_when_disabled() {
let mut turn = base_turn("t1", Role::Assistant);
turn.thinking = Some("hmm".into());
let view = view_with(vec![turn]);
let cfg = DeriveConfig {
include_thinking: false,
..Default::default()
};
let path = derive_path(&view, &cfg);
let sc = conv_change(&path.steps[0]);
assert!(!sc.extra.contains_key("thinking"));
}
#[test]
fn test_tool_uses_included_when_enabled() {
let mut turn = base_turn("t1", Role::Assistant);
turn.tool_uses = vec![ToolInvocation {
id: "tu1".into(),
name: "Read".into(),
input: serde_json::json!({}),
result: Some(ToolResult {
content: "x".into(),
is_error: false,
}),
category: Some(ToolCategory::FileRead),
}];
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());
let sc = conv_change(&path.steps[0]);
assert!(sc.extra.contains_key("tool_uses"));
}
#[test]
fn test_tool_uses_omitted_when_disabled() {
let mut turn = base_turn("t1", Role::Assistant);
turn.tool_uses = vec![ToolInvocation {
id: "tu1".into(),
name: "Read".into(),
input: serde_json::json!({}),
result: None,
category: Some(ToolCategory::FileRead),
}];
let view = view_with(vec![turn]);
let cfg = DeriveConfig {
include_tool_uses: false,
..Default::default()
};
let path = derive_path(&view, &cfg);
let sc = conv_change(&path.steps[0]);
assert!(!sc.extra.contains_key("tool_uses"));
}
#[test]
fn test_base_uri_from_working_dir() {
let mut turn = base_turn("t1", Role::User);
turn.environment = Some(EnvironmentSnapshot {
working_dir: Some("/Users/alex/proj".into()),
..Default::default()
});
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());
assert_eq!(path.path.base.unwrap().uri, "file:///Users/alex/proj");
}
#[test]
fn test_base_uri_from_config_override() {
let mut turn = base_turn("t1", Role::User);
turn.environment = Some(EnvironmentSnapshot {
working_dir: Some("/Users/alex/proj".into()),
..Default::default()
});
let view = view_with(vec![turn]);
let cfg = DeriveConfig {
base_uri: Some("github:org/repo".into()),
..Default::default()
};
let path = derive_path(&view, &cfg);
assert_eq!(path.path.base.unwrap().uri, "github:org/repo");
}
#[test]
fn test_base_uri_absent_when_no_source() {
let turn = base_turn("t1", Role::User);
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());
assert!(path.path.base.is_none());
}
#[test]
fn test_path_id_from_config_override() {
let view = view_with(vec![]);
let cfg = DeriveConfig {
path_id: Some("my-custom-id".into()),
..Default::default()
};
let path = derive_path(&view, &cfg);
assert_eq!(path.path.id, "my-custom-id");
}
#[test]
fn test_path_id_default_format() {
let view = view_with(vec![]);
let path = derive_path(&view, &DeriveConfig::default());
assert_eq!(path.path.id, "path-pi-abcdef01");
}
#[test]
fn test_files_changed_in_meta() {
let mut view = view_with(vec![]);
view.files_changed = vec!["a.rs".into(), "b.rs".into()];
let path = derive_path(&view, &DeriveConfig::default());
let meta = path.meta.unwrap();
assert_eq!(
meta.extra["files_changed"],
serde_json::json!(["a.rs", "b.rs"])
);
}
#[test]
fn test_actors_in_meta() {
let u = base_turn("t1", Role::User);
let mut a = base_turn("t2", Role::Assistant);
a.model = Some("claude-opus-4-7".into());
let view = view_with(vec![u, a]);
let path = derive_path(&view, &DeriveConfig::default());
let actors = path.meta.unwrap().actors.unwrap();
assert!(actors.contains_key("human:user"));
assert!(actors.contains_key("agent:claude-opus-4-7"));
let agent = &actors["agent:claude-opus-4-7"];
assert_eq!(agent.provider.as_deref(), Some("pi"));
assert_eq!(agent.model.as_deref(), Some("claude-opus-4-7"));
let human = &actors["human:user"];
assert_eq!(human.name.as_deref(), Some("user"));
}
#[test]
fn test_head_is_last_step_id() {
let turns = vec![
base_turn("t1", Role::User),
base_turn("t2", Role::User),
base_turn("t3", Role::User),
];
let view = view_with(turns);
let path = derive_path(&view, &DeriveConfig::default());
assert_eq!(path.path.head, "t3");
}
#[test]
fn test_token_usage_in_extras() {
let mut turn = base_turn("t1", Role::Assistant);
turn.token_usage = Some(TokenUsage {
input_tokens: Some(100),
output_tokens: Some(50),
cache_read_tokens: None,
cache_write_tokens: None,
});
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());
let sc = conv_change(&path.steps[0]);
assert!(sc.extra.contains_key("token_usage"));
assert_eq!(
sc.extra["token_usage"]["input_tokens"],
serde_json::json!(100)
);
}
#[test]
fn test_delegations_in_extras() {
let mut turn = base_turn("t1", Role::Assistant);
turn.delegations = vec![DelegatedWork {
agent_id: "sub-1".into(),
prompt: "do a thing".into(),
turns: vec![],
result: None,
}];
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());
let sc = conv_change(&path.steps[0]);
assert!(sc.extra.contains_key("delegations"));
assert_eq!(
sc.extra["delegations"][0]["agent_id"],
serde_json::json!("sub-1")
);
}
#[test]
fn test_title_from_config() {
let view = view_with(vec![]);
let cfg = DeriveConfig {
title: Some("My Session".into()),
..Default::default()
};
let path = derive_path(&view, &cfg);
assert_eq!(path.meta.unwrap().title.as_deref(), Some("My Session"));
}
#[test]
fn test_title_default_when_unset() {
let view = view_with(vec![]);
let path = derive_path(&view, &DeriveConfig::default());
assert_eq!(
path.meta.unwrap().title.as_deref(),
Some("pi session: abcdef01")
);
}
#[test]
fn test_serde_roundtrip() {
let mut t1 = base_turn("t1", Role::User);
t1.text = "hello".into();
t1.environment = Some(EnvironmentSnapshot {
working_dir: Some("/proj".into()),
..Default::default()
});
let mut t2 = base_turn("t2", Role::Assistant);
t2.parent_id = Some("t1".into());
t2.model = Some("m".into());
t2.tool_uses = vec![fw_tool(
"Write",
"tu1",
serde_json::json!({"file_path": "x.rs"}),
)];
let mut view = view_with(vec![t1, t2]);
view.files_changed = vec!["x.rs".into()];
let path = derive_path(&view, &DeriveConfig::default());
let json = serde_json::to_string(&path).unwrap();
let back: Path = serde_json::from_str(&json).unwrap();
assert_eq!(back.path.id, path.path.id);
assert_eq!(back.path.head, path.path.head);
assert_eq!(back.steps.len(), 2);
assert_eq!(back.steps[1].step.parents, vec!["t1".to_string()]);
assert!(back.steps[1].change.contains_key("x.rs"));
}
}