1use std::collections::HashMap;
10
11use toolpath::v1::{
12 ActorDefinition, ArtifactChange, Base, PATH_KIND_AGENT_CODING_SESSION, Path, PathIdentity,
13 PathMeta, Step, StepIdentity, StructuralChange,
14};
15
16use crate::{ConversationView, Role, ToolCategory, ToolInvocation, Turn};
17
18#[derive(Debug, Clone)]
20pub struct DeriveConfig {
21 pub base_uri: Option<String>,
24 pub path_id: Option<String>,
26 pub title: Option<String>,
28 pub include_thinking: bool,
30 pub include_tool_uses: bool,
32}
33
34impl Default for DeriveConfig {
35 fn default() -> Self {
36 Self {
37 base_uri: None,
38 path_id: None,
39 title: None,
40 include_thinking: true,
41 include_tool_uses: true,
42 }
43 }
44}
45
46pub fn derive_path(view: &ConversationView, config: &DeriveConfig) -> Path {
48 let provider = view.provider_id.as_deref().unwrap_or("unknown");
49 let id_prefix: String = view.id.chars().take(8).collect();
50
51 let path_id = config
52 .path_id
53 .clone()
54 .unwrap_or_else(|| format!("path-{}-{}", provider, id_prefix));
55
56 let base = config
62 .base_uri
63 .clone()
64 .map(|uri| Base {
65 uri,
66 ref_str: view.base.as_ref().and_then(|b| b.vcs_revision.clone()),
67 branch: view.base.as_ref().and_then(|b| b.vcs_branch.clone()),
68 })
69 .or_else(|| {
70 view.base.as_ref().and_then(|b| {
71 let wd = b.working_dir.as_ref()?;
72 let uri = if wd.starts_with('/') {
73 format!("file://{}", wd)
74 } else {
75 wd.clone()
76 };
77 Some(Base {
78 uri,
79 ref_str: b.vcs_revision.clone(),
80 branch: b.vcs_branch.clone(),
81 })
82 })
83 })
84 .or_else(|| {
85 view.turns
86 .iter()
87 .find_map(|t| t.environment.as_ref()?.working_dir.clone())
88 .map(|wd| {
89 let uri = if wd.starts_with('/') {
90 format!("file://{}", wd)
91 } else {
92 wd
93 };
94 Base {
95 uri,
96 ref_str: None,
97 branch: None,
98 }
99 })
100 });
101
102 let conv_artifact_key = format!("{}://{}", provider, view.id);
103
104 let mut steps: Vec<Step> = Vec::with_capacity(view.turns.len());
105 let mut turn_to_step: HashMap<String, String> = HashMap::new();
106 let mut actors: HashMap<String, ActorDefinition> = HashMap::new();
107
108 for (idx, turn) in view.turns.iter().enumerate() {
109 let step_id = if turn.id.is_empty() {
112 format!("step-{:04}", idx + 1)
113 } else {
114 turn.id.clone()
115 };
116 turn_to_step.insert(turn.id.clone(), step_id.clone());
117
118 let actor = actor_for_turn(turn, provider);
119 record_actor(&mut actors, &actor, turn, provider, view);
120
121 let mut step = Step {
122 step: StepIdentity {
123 id: step_id,
124 parents: Vec::new(),
125 actor,
126 timestamp: turn.timestamp.clone(),
127 },
128 change: HashMap::new(),
129 meta: None,
130 };
131
132 if let Some(parent_id) = &turn.parent_id
134 && let Some(parent_step_id) = turn_to_step.get(parent_id)
135 {
136 step.step.parents.push(parent_step_id.clone());
137 }
138
139 let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
141 extra.insert(
142 "role".to_string(),
143 serde_json::Value::String(turn.role.to_string()),
144 );
145 extra.insert(
146 "text".to_string(),
147 serde_json::Value::String(turn.text.clone()),
148 );
149
150 if config.include_thinking
151 && let Some(thinking) = &turn.thinking
152 {
153 extra.insert(
154 "thinking".to_string(),
155 serde_json::Value::String(thinking.clone()),
156 );
157 }
158
159 if config.include_tool_uses && !turn.tool_uses.is_empty() {
160 let arr: Vec<serde_json::Value> = turn
161 .tool_uses
162 .iter()
163 .map(|t| {
164 let mut obj = serde_json::json!({
165 "id": t.id,
166 "name": t.name,
167 "input": t.input,
168 "category": t.category,
169 });
170 if let Some(result) = &t.result
171 && let Ok(v) = serde_json::to_value(result)
172 {
173 obj.as_object_mut().unwrap().insert("result".to_string(), v);
174 }
175 obj
176 })
177 .collect();
178 extra.insert("tool_uses".to_string(), serde_json::Value::Array(arr));
179 }
180
181 let last_of_message = match &turn.group_id {
187 None => true,
188 Some(mid) => view
189 .turns
190 .get(idx + 1)
191 .is_none_or(|next| next.group_id.as_ref() != Some(mid)),
192 };
193 if last_of_message
194 && let Some(usage) = &turn.token_usage
195 && let Ok(v) = serde_json::to_value(usage)
196 {
197 extra.insert("token_usage".to_string(), v);
198 }
199
200 if let Some(attr) = &turn.attributed_token_usage
205 && let Ok(v) = serde_json::to_value(attr)
206 {
207 extra.insert("attributed_token_usage".to_string(), v);
208 }
209
210 if let Some(mid) = &turn.group_id {
211 extra.insert(
212 "group_id".to_string(),
213 serde_json::Value::String(mid.clone()),
214 );
215 }
216
217 if !turn.delegations.is_empty()
218 && let Ok(v) = serde_json::to_value(&turn.delegations)
219 {
220 extra.insert("delegations".to_string(), v);
221 }
222
223 if let Some(stop_reason) = &turn.stop_reason {
224 extra.insert(
225 "stop_reason".to_string(),
226 serde_json::Value::String(stop_reason.clone()),
227 );
228 }
229
230 if let Some(env) = &turn.environment
231 && let Ok(v) = serde_json::to_value(env)
232 {
233 extra.insert("environment".to_string(), v);
234 }
235
236 step.change.insert(
237 conv_artifact_key.clone(),
238 ArtifactChange {
239 raw: None,
240 structural: Some(StructuralChange {
241 change_type: "conversation.append".to_string(),
242 extra,
243 }),
244 },
245 );
246
247 let attributed: std::collections::HashSet<String> = turn
259 .file_mutations
260 .iter()
261 .filter_map(|fm| fm.tool_id.clone())
262 .collect();
263 for fm in &turn.file_mutations {
264 let mut t_extra: HashMap<String, serde_json::Value> = HashMap::new();
265 if let Some(tid) = &fm.tool_id {
266 t_extra.insert(
267 "tool_id".to_string(),
268 serde_json::Value::String(tid.clone()),
269 );
270 if let Some(tool) = turn.tool_uses.iter().find(|t| &t.id == tid) {
271 t_extra.insert(
272 "tool".to_string(),
273 serde_json::Value::String(tool.name.clone()),
274 );
275 }
276 }
277 if let Some(op) = &fm.operation {
278 t_extra.insert(
279 "operation".to_string(),
280 serde_json::Value::String(op.clone()),
281 );
282 }
283 if let Some(b) = &fm.before {
284 t_extra.insert("before".to_string(), serde_json::Value::String(b.clone()));
285 }
286 if let Some(a) = &fm.after {
287 t_extra.insert("after".to_string(), serde_json::Value::String(a.clone()));
288 }
289 if let Some(rt) = &fm.rename_to {
290 t_extra.insert(
291 "rename_to".to_string(),
292 serde_json::Value::String(rt.clone()),
293 );
294 }
295 step.change.insert(
296 fm.path.clone(),
297 ArtifactChange {
298 raw: fm.raw_diff.clone(),
299 structural: Some(StructuralChange {
300 change_type: "file.write".to_string(),
301 extra: t_extra,
302 }),
303 },
304 );
305 }
306 for tool in &turn.tool_uses {
307 if tool.category != Some(ToolCategory::FileWrite) || attributed.contains(&tool.id) {
308 continue;
309 }
310 let Some(path) = extract_file_path(tool) else {
311 continue;
312 };
313 let (raw, mut t_extra) = file_write_change(tool, &path, None);
314 t_extra.insert(
315 "tool".to_string(),
316 serde_json::Value::String(tool.name.clone()),
317 );
318 t_extra.insert(
319 "tool_id".to_string(),
320 serde_json::Value::String(tool.id.clone()),
321 );
322 step.change.insert(
323 path,
324 ArtifactChange {
325 raw,
326 structural: Some(StructuralChange {
327 change_type: "file.write".to_string(),
328 extra: t_extra,
329 }),
330 },
331 );
332 }
333
334 steps.push(step);
335 }
336
337 let mut last_step_id: Option<String> = steps.last().map(|s| s.step.id.clone());
345 for (idx, event) in view.events.iter().enumerate() {
346 let step_id = if event.id.is_empty() {
348 format!("event-{:04}", idx + 1)
349 } else {
350 event.id.clone()
351 };
352 let actor = format!("tool:{}", provider);
353 actors
354 .entry(actor.clone())
355 .or_insert_with(|| ActorDefinition {
356 name: Some(provider.to_string()),
357 provider: Some(provider.to_string()),
358 ..Default::default()
359 });
360
361 let mut extra: HashMap<String, serde_json::Value> = event
368 .data
369 .iter()
370 .filter(|(k, _)| k.as_str() != "type")
371 .map(|(k, v)| (k.clone(), v.clone()))
372 .collect();
373 if let Some(t) = event.data.get("type") {
376 extra.insert("event_data_type".to_string(), t.clone());
377 }
378 extra.insert(
379 "entry_type".to_string(),
380 serde_json::Value::String(event.event_type.clone()),
381 );
382 if !event.id.is_empty() {
383 extra.insert(
384 "event_source_id".to_string(),
385 serde_json::Value::String(event.id.clone()),
386 );
387 }
388
389 let parents: Vec<String> = event
390 .parent_id
391 .as_ref()
392 .and_then(|pid| turn_to_step.get(pid).cloned())
393 .or_else(|| last_step_id.clone())
394 .into_iter()
395 .collect();
396
397 let mut step = Step {
398 step: StepIdentity {
399 id: step_id.clone(),
400 parents,
401 actor,
402 timestamp: event.timestamp.clone(),
403 },
404 change: HashMap::new(),
405 meta: None,
406 };
407
408 step.change.insert(
409 conv_artifact_key.clone(),
410 ArtifactChange {
411 raw: None,
412 structural: Some(StructuralChange {
413 change_type: "conversation.event".to_string(),
414 extra,
415 }),
416 },
417 );
418 steps.push(step);
419 last_step_id = Some(step_id);
420 }
421
422 let head = steps.last().map(|s| s.step.id.clone()).unwrap_or_default();
423
424 let title = config
426 .title
427 .clone()
428 .unwrap_or_else(|| format!("{} session: {}", provider, id_prefix));
429
430 let mut meta = PathMeta {
431 title: Some(title),
432 kind: Some(PATH_KIND_AGENT_CODING_SESSION.to_string()),
433 source: view.provider_id.clone(),
434 ..Default::default()
435 };
436
437 if !actors.is_empty() {
438 meta.actors = Some(actors);
439 }
440
441 if !view.files_changed.is_empty()
442 && let Ok(v) = serde_json::to_value(&view.files_changed)
443 {
444 meta.extra.insert("files_changed".to_string(), v);
445 }
446
447 if let Some(remote) = view.base.as_ref().and_then(|b| b.vcs_remote.as_ref())
449 && !meta.extra.contains_key("vcs_remote")
450 {
451 meta.extra.insert(
452 "vcs_remote".to_string(),
453 serde_json::Value::String(remote.clone()),
454 );
455 }
456
457 if let Some(producer) = &view.producer
459 && let Ok(v) = serde_json::to_value(producer)
460 {
461 meta.extra.insert("producer".to_string(), v);
462 }
463
464 Path {
465 path: PathIdentity {
466 id: path_id,
467 base,
468 head,
469 graph_ref: None,
470 },
471 steps,
472 meta: Some(meta),
473 }
474}
475
476fn actor_for_turn(turn: &Turn, provider: &str) -> String {
477 match &turn.role {
478 Role::User => "human:user".to_string(),
479 Role::Assistant => {
480 let model = turn.model.as_deref().unwrap_or("unknown");
481 format!("agent:{}", model)
482 }
483 Role::System => format!("tool:{}", provider),
484 Role::Other(_) => format!("tool:{}", provider),
485 }
486}
487
488fn record_actor(
489 actors: &mut HashMap<String, ActorDefinition>,
490 actor: &str,
491 turn: &Turn,
492 provider: &str,
493 _view: &ConversationView,
494) {
495 if actors.contains_key(actor) {
496 return;
497 }
498 let def = if let Some(rest) = actor.strip_prefix("agent:") {
499 ActorDefinition {
500 name: Some(rest.to_string()),
501 provider: Some(provider.to_string()),
502 model: turn.model.clone(),
503 identities: vec![],
504 keys: vec![],
505 }
506 } else if let Some(rest) = actor.strip_prefix("human:") {
507 ActorDefinition {
508 name: Some(rest.to_string()),
509 ..Default::default()
510 }
511 } else {
512 let name = actor.split_once(':').map(|x| x.1).unwrap_or("").to_string();
513 ActorDefinition {
514 name: Some(name),
515 provider: Some(provider.to_string()),
516 ..Default::default()
517 }
518 };
519 actors.insert(actor.to_string(), def);
520}
521
522fn extract_file_path(tool: &ToolInvocation) -> Option<String> {
523 for field in &["file_path", "path", "filename", "file"] {
524 if let Some(v) = tool.input.get(*field)
525 && let Some(s) = v.as_str()
526 {
527 return Some(s.to_string());
528 }
529 }
530 None
531}
532
533fn file_write_change(
543 tool: &ToolInvocation,
544 path: &str,
545 before_state: Option<&str>,
546) -> (Option<String>, HashMap<String, serde_json::Value>) {
547 let input = &tool.input;
548 let str_field = |k: &str| input.get(k).and_then(|v| v.as_str()).map(str::to_string);
549
550 let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
551
552 if let (Some(old), Some(new)) = (str_field("old_string"), str_field("new_string")) {
553 extra.insert("before".to_string(), serde_json::Value::String(old.clone()));
554 extra.insert("after".to_string(), serde_json::Value::String(new.clone()));
555 } else if let Some(content) = str_field("content") {
556 if let Some(before) = before_state {
557 extra.insert(
558 "before".to_string(),
559 serde_json::Value::String(before.to_string()),
560 );
561 }
562 extra.insert("after".to_string(), serde_json::Value::String(content));
563 } else if let Some(edits) = input.get("edits").and_then(|v| v.as_array()) {
564 extra.insert("edits".to_string(), serde_json::Value::Array(edits.clone()));
565 }
566
567 (
568 file_write_diff(&tool.name, input, path, before_state),
569 extra,
570 )
571}
572
573pub fn file_write_diff(
601 tool_name: &str,
602 input: &serde_json::Value,
603 path: &str,
604 before_state: Option<&str>,
605) -> Option<String> {
606 let str_field = |k: &str| input.get(k).and_then(|v| v.as_str());
607
608 if let (Some(old), Some(new)) = (str_field("old_string"), str_field("new_string")) {
610 return Some(unified_diff(path, old, new));
611 }
612
613 if let Some(content) = str_field("content") {
616 let before = before_state.unwrap_or("");
617 return Some(unified_diff(path, before, content));
618 }
619
620 if let Some(edits) = input.get("edits").and_then(|v| v.as_array()) {
622 if edits.is_empty() {
623 return None;
624 }
625 let mut parts: Vec<String> = Vec::new();
626 for (idx, edit) in edits.iter().enumerate() {
627 let old = edit
628 .get("old_string")
629 .and_then(|v| v.as_str())
630 .unwrap_or("");
631 let new = edit
632 .get("new_string")
633 .and_then(|v| v.as_str())
634 .unwrap_or("");
635 let header = format!("# edit {}/{}", idx + 1, edits.len());
636 parts.push(format!("{header}\n{}", unified_diff(path, old, new)));
637 }
638 return Some(parts.join("\n"));
639 }
640
641 let _ = tool_name;
644 None
645}
646
647pub fn unified_diff(path: &str, before: &str, after: &str) -> String {
657 use similar::TextDiff;
658 let diff = TextDiff::from_lines(before, after);
659 let display = path.trim_start_matches('/');
660 let mut out = String::new();
661 out.push_str(&format!("--- a/{display}\n+++ b/{display}\n"));
662 out.push_str(
663 &diff
664 .unified_diff()
665 .context_radius(3)
666 .header("", "")
667 .to_string(),
668 );
669 out
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675 use crate::{DelegatedWork, EnvironmentSnapshot, TokenUsage, ToolInvocation, ToolResult};
676
677 fn base_turn(id: &str, role: Role) -> Turn {
678 Turn {
679 id: id.to_string(),
680 parent_id: None,
681 group_id: None,
682 role,
683 timestamp: "2026-01-01T00:00:00Z".to_string(),
684 text: String::new(),
685 thinking: None,
686 tool_uses: vec![],
687 model: None,
688 stop_reason: None,
689 token_usage: None,
690 attributed_token_usage: None,
691 environment: None,
692 delegations: vec![],
693 file_mutations: Vec::new(),
694 }
695 }
696
697 fn view_with(turns: Vec<Turn>) -> ConversationView {
698 ConversationView {
699 id: "abcdef012345".to_string(),
700 turns,
701 provider_id: Some("pi".to_string()),
702 ..Default::default()
703 }
704 }
705
706 fn conv_change(step: &Step) -> &StructuralChange {
707 let key = step
708 .change
709 .keys()
710 .find(|k| k.contains("://"))
711 .expect("conversation artifact key present");
712 step.change[key].structural.as_ref().unwrap()
713 }
714
715 #[test]
716 fn test_empty_view() {
717 let view = view_with(vec![]);
718 let path = derive_path(&view, &DeriveConfig::default());
719 assert!(path.steps.is_empty());
720 assert_eq!(path.path.head, "");
721 }
722
723 #[test]
724 fn test_meta_kind_is_convo() {
725 let view = view_with(vec![base_turn("t1", Role::User)]);
726 let path = derive_path(&view, &DeriveConfig::default());
727 assert_eq!(
728 path.meta.as_ref().unwrap().kind.as_deref(),
729 Some(PATH_KIND_AGENT_CODING_SESSION)
730 );
731 let json = serde_json::to_string(&path).unwrap();
733 assert!(
734 json.contains(r#""kind":"https://toolpath.net/kinds/agent-coding-session/v1.1.0""#)
735 );
736 }
737
738 #[test]
739 fn test_token_usage_breakdowns_round_trip() {
740 use std::collections::BTreeMap;
741 let mut breakdowns = BTreeMap::new();
744 breakdowns.insert(
745 "output".to_string(),
746 BTreeMap::from([("reasoning".to_string(), 450u32)]),
747 );
748 let mut turn = base_turn("t1", Role::Assistant);
749 turn.model = Some("claude-opus-4-7".into());
750 turn.token_usage = Some(TokenUsage {
751 input_tokens: Some(100),
752 output_tokens: Some(900),
753 breakdowns: breakdowns.clone(),
754 ..Default::default()
755 });
756 let view = view_with(vec![turn]);
757
758 let path = derive_path(&view, &DeriveConfig::default());
759 let extracted = crate::extract::extract_conversation(&path);
760
761 let usage = extracted.turns[0]
762 .token_usage
763 .as_ref()
764 .expect("token_usage survives round-trip");
765 assert_eq!(usage.input_tokens, Some(100));
766 assert_eq!(usage.output_tokens, Some(900));
767 assert_eq!(usage.breakdowns, breakdowns);
768 assert_eq!(usage.breakdowns["output"]["reasoning"], 450);
769 }
770
771 #[test]
772 fn test_token_usage_empty_breakdowns_omitted_in_json() {
773 let usage = TokenUsage {
776 input_tokens: Some(10),
777 output_tokens: Some(20),
778 ..Default::default()
779 };
780 let json = serde_json::to_string(&usage).unwrap();
781 assert!(
782 !json.contains("breakdowns"),
783 "empty breakdowns must be omitted, got: {json}"
784 );
785 }
786
787 #[test]
788 fn test_token_usage_absent_breakdowns_defaults_empty() {
789 let usage: TokenUsage =
792 serde_json::from_str(r#"{"input_tokens":10,"output_tokens":20}"#).unwrap();
793 assert!(usage.breakdowns.is_empty());
794 }
795
796 #[test]
797 fn test_single_user_turn() {
798 let mut turn = base_turn("t1", Role::User);
799 turn.text = "hello".into();
800 let view = view_with(vec![turn]);
801 let path = derive_path(&view, &DeriveConfig::default());
802 assert_eq!(path.steps.len(), 1);
803 assert_eq!(path.steps[0].step.actor, "human:user");
804 assert_eq!(path.steps[0].step.id, "t1");
805 }
806
807 #[test]
808 fn test_single_assistant_turn() {
809 let mut turn = base_turn("t1", Role::Assistant);
810 turn.model = Some("claude-opus-4-7".into());
811 let view = view_with(vec![turn]);
812 let path = derive_path(&view, &DeriveConfig::default());
813 assert_eq!(path.steps[0].step.actor, "agent:claude-opus-4-7");
814 }
815
816 #[test]
817 fn test_assistant_without_model() {
818 let turn = base_turn("t1", Role::Assistant);
819 let view = view_with(vec![turn]);
820 let path = derive_path(&view, &DeriveConfig::default());
821 assert_eq!(path.steps[0].step.actor, "agent:unknown");
822 }
823
824 #[test]
825 fn test_system_role() {
826 let turn = base_turn("t1", Role::System);
827 let view = view_with(vec![turn]);
828 let path = derive_path(&view, &DeriveConfig::default());
829 assert_eq!(path.steps[0].step.actor, "tool:pi");
830 }
831
832 #[test]
833 fn test_other_role() {
834 let turn = base_turn("t1", Role::Other("tool".into()));
835 let view = view_with(vec![turn]);
836 let path = derive_path(&view, &DeriveConfig::default());
837 assert_eq!(path.steps[0].step.actor, "tool:pi");
838 }
839
840 #[test]
841 fn test_parent_id_preserved() {
842 let t1 = base_turn("t1", Role::User);
843 let mut t2 = base_turn("t2", Role::Assistant);
844 t2.parent_id = Some("t1".into());
845 t2.model = Some("m".into());
846 let view = view_with(vec![t1, t2]);
847 let path = derive_path(&view, &DeriveConfig::default());
848 assert_eq!(path.steps[1].step.parents, vec!["t1".to_string()]);
849 }
850
851 #[test]
852 fn derived_path_validates_against_base_schema() {
853 let user = base_turn("t1", Role::User);
854 let mut assistant = base_turn("t2", Role::Assistant);
855 assistant.parent_id = Some("t1".into());
856 assistant.model = Some("gpt-5.5".into());
857 let system = base_turn("t3", Role::System);
858 let other = base_turn("t4", Role::Other("bash".into()));
859
860 let mut view = view_with(vec![user, assistant, system, other]);
861 view.events.push(crate::ConversationEvent {
862 id: "e1".into(),
863 timestamp: "2026-01-01T00:00:00Z".into(),
864 parent_id: None,
865 event_type: "attachment".into(),
866 data: HashMap::new(),
867 });
868
869 let path = derive_path(&view, &DeriveConfig::default());
870 let graph = serde_json::json!({
871 "graph": { "id": "g1" },
872 "paths": [serde_json::to_value(&path).unwrap()],
873 });
874
875 let schema: serde_json::Value = serde_json::from_str(toolpath::SCHEMA_JSON).unwrap();
876 let validator = jsonschema::validator_for(&schema).unwrap();
877 let errors: Vec<String> = validator
878 .iter_errors(&graph)
879 .map(|e| format!("at {}: {e}", e.instance_path()))
880 .collect();
881 assert!(
882 errors.is_empty(),
883 "base-schema violations:\n{}",
884 errors.join("\n")
885 );
886 }
887
888 #[test]
889 fn derived_path_conforms_to_agent_coding_session_kind() {
890 let mut user = base_turn("t1", Role::User);
895 user.text = "implement the feature".into();
896
897 let mut assistant = base_turn("t2", Role::Assistant);
898 assistant.parent_id = Some("t1".into());
899 assistant.group_id = Some("msg_t2".into());
900 assistant.model = Some("gpt-5.5".into());
901 assistant.text = "on it".into();
902 assistant.thinking = Some("plan the edit".into());
903 assistant.stop_reason = Some("tool_use".into());
904 assistant.token_usage = Some(TokenUsage {
905 input_tokens: Some(100),
906 output_tokens: Some(20),
907 cache_read_tokens: Some(50),
908 cache_write_tokens: None,
909 ..Default::default()
910 });
911 assistant.attributed_token_usage = Some(TokenUsage {
912 output_tokens: Some(20),
913 ..Default::default()
914 });
915 assistant.environment = Some(EnvironmentSnapshot {
916 working_dir: Some("/repo".into()),
917 vcs_branch: Some("main".into()),
918 vcs_revision: None,
919 });
920 assistant.tool_uses = vec![ToolInvocation {
921 id: "call-1".into(),
922 name: "write_file".into(),
923 input: serde_json::json!({ "file_path": "a.rs", "content": "fn main() {}" }),
924 result: Some(ToolResult {
925 content: "ok".into(),
926 is_error: false,
927 }),
928 category: Some(crate::ToolCategory::FileWrite),
929 }];
930 assistant.file_mutations = vec![crate::FileMutation {
931 path: "a.rs".into(),
932 tool_id: Some("call-1".into()),
933 operation: Some("add".into()),
934 raw_diff: Some("@@ -0,0 +1 @@\n+fn main() {}".into()),
935 before: None,
936 after: Some("fn main() {}".into()),
937 rename_to: None,
938 }];
939 assistant.delegations = vec![DelegatedWork {
940 agent_id: "sub-1".into(),
941 prompt: "do the subtask".into(),
942 turns: vec![],
943 result: Some("done".into()),
944 }];
945
946 let mut system = base_turn("t3", Role::System);
947 system.parent_id = Some("t2".into());
948 system.text = "system note".into();
949
950 let mut other = base_turn("t4", Role::Other("tool".into()));
951 other.parent_id = Some("t3".into());
952 other.text = "tool output".into();
953
954 let mut view = view_with(vec![user, assistant, system, other]);
955 view.events.push(crate::ConversationEvent {
956 id: "e1".into(),
957 timestamp: "2026-01-01T00:00:00Z".into(),
958 parent_id: None,
959 event_type: "attachment".into(),
960 data: HashMap::new(),
961 });
962
963 let path = derive_path(&view, &DeriveConfig::default());
964 assert_eq!(
965 path.meta.as_ref().and_then(|m| m.kind.as_deref()),
966 Some(toolpath::v1::PATH_KIND_AGENT_CODING_SESSION),
967 "derive_path must stamp the agent-coding-session kind"
968 );
969
970 let schema_src = std::fs::read_to_string(concat!(
971 env!("CARGO_MANIFEST_DIR"),
972 "/../path-cli/kinds/agent-coding-session/v1.1.0/schema.json"
973 ))
974 .expect("read kind schema");
975 let schema: serde_json::Value = serde_json::from_str(&schema_src).unwrap();
976 let validator = jsonschema::validator_for(&schema).unwrap();
977 let value = serde_json::to_value(&path).unwrap();
978 let errors: Vec<String> = validator
979 .iter_errors(&value)
980 .map(|e| format!("at {}: {e}", e.instance_path()))
981 .collect();
982 assert!(
983 errors.is_empty(),
984 "kind-schema violations:\n{}",
985 errors.join("\n")
986 );
987 }
988
989 fn fw_tool(name: &str, id: &str, input: serde_json::Value) -> ToolInvocation {
990 ToolInvocation {
991 id: id.to_string(),
992 name: name.to_string(),
993 input,
994 result: None,
995 category: Some(ToolCategory::FileWrite),
996 }
997 }
998
999 #[test]
1000 fn test_tool_use_filewrite_with_file_path_field() {
1001 let mut turn = base_turn("t1", Role::Assistant);
1002 turn.tool_uses = vec![fw_tool(
1003 "Write",
1004 "tu1",
1005 serde_json::json!({"file_path": "src/main.rs"}),
1006 )];
1007 let view = view_with(vec![turn]);
1008 let path = derive_path(&view, &DeriveConfig::default());
1009 assert!(path.steps[0].change.contains_key("src/main.rs"));
1010 let sc = path.steps[0].change["src/main.rs"]
1011 .structural
1012 .as_ref()
1013 .unwrap();
1014 assert_eq!(sc.change_type, "file.write");
1015 assert_eq!(sc.extra["tool"], serde_json::json!("Write"));
1016 assert_eq!(sc.extra["tool_id"], serde_json::json!("tu1"));
1017 }
1018
1019 #[test]
1020 fn test_tool_use_filewrite_with_path_field() {
1021 let mut turn = base_turn("t1", Role::Assistant);
1022 turn.tool_uses = vec![fw_tool("Edit", "tu1", serde_json::json!({"path": "a.rs"}))];
1023 let view = view_with(vec![turn]);
1024 let path = derive_path(&view, &DeriveConfig::default());
1025 assert!(path.steps[0].change.contains_key("a.rs"));
1026 }
1027
1028 #[test]
1029 fn test_tool_use_filewrite_with_filename_field() {
1030 let mut turn = base_turn("t1", Role::Assistant);
1031 turn.tool_uses = vec![fw_tool("W", "tu1", serde_json::json!({"filename": "b.rs"}))];
1032 let view = view_with(vec![turn]);
1033 let path = derive_path(&view, &DeriveConfig::default());
1034 assert!(path.steps[0].change.contains_key("b.rs"));
1035 }
1036
1037 #[test]
1038 fn test_tool_use_filewrite_with_file_field() {
1039 let mut turn = base_turn("t1", Role::Assistant);
1040 turn.tool_uses = vec![fw_tool("W", "tu1", serde_json::json!({"file": "c.rs"}))];
1041 let view = view_with(vec![turn]);
1042 let path = derive_path(&view, &DeriveConfig::default());
1043 assert!(path.steps[0].change.contains_key("c.rs"));
1044 }
1045
1046 #[test]
1047 fn test_tool_use_filewrite_no_recognized_field() {
1048 let mut turn = base_turn("t1", Role::Assistant);
1049 turn.tool_uses = vec![fw_tool("W", "tu1", serde_json::json!({"other": "foo"}))];
1050 let view = view_with(vec![turn]);
1051 let path = derive_path(&view, &DeriveConfig::default());
1052 assert_eq!(path.steps[0].change.len(), 1);
1053 let sc = conv_change(&path.steps[0]);
1054 assert!(sc.extra.contains_key("tool_uses"));
1055 }
1056
1057 #[test]
1058 fn test_tool_use_non_filewrite_ignored() {
1059 let mut turn = base_turn("t1", Role::Assistant);
1060 turn.tool_uses = vec![ToolInvocation {
1061 id: "tu1".into(),
1062 name: "Read".into(),
1063 input: serde_json::json!({"file_path": "x.rs"}),
1064 result: None,
1065 category: Some(ToolCategory::FileRead),
1066 }];
1067 let view = view_with(vec![turn]);
1068 let path = derive_path(&view, &DeriveConfig::default());
1069 assert!(!path.steps[0].change.contains_key("x.rs"));
1070 assert_eq!(path.steps[0].change.len(), 1);
1071 }
1072
1073 #[test]
1074 fn test_tool_use_edit_emits_unified_diff() {
1075 let mut turn = base_turn("t1", Role::Assistant);
1076 turn.tool_uses = vec![fw_tool(
1077 "Edit",
1078 "tu1",
1079 serde_json::json!({
1080 "file_path": "src/login.rs",
1081 "old_string": "validate_token()",
1082 "new_string": "validate_token_v2()",
1083 }),
1084 )];
1085 let view = view_with(vec![turn]);
1086 let path = derive_path(&view, &DeriveConfig::default());
1087 let ch = &path.steps[0].change["src/login.rs"];
1088 let raw = ch.raw.as_deref().expect("edit should emit unified diff");
1089 assert!(raw.contains("--- a/src/login.rs"));
1090 assert!(raw.contains("+++ b/src/login.rs"));
1091 assert!(raw.contains("-validate_token()"));
1092 assert!(raw.contains("+validate_token_v2()"));
1093 let sc = ch.structural.as_ref().unwrap();
1094 assert_eq!(sc.extra["before"], serde_json::json!("validate_token()"));
1095 assert_eq!(sc.extra["after"], serde_json::json!("validate_token_v2()"));
1096 }
1097
1098 #[test]
1099 fn test_tool_use_write_emits_full_content_diff() {
1100 let mut turn = base_turn("t1", Role::Assistant);
1101 turn.tool_uses = vec![fw_tool(
1102 "Write",
1103 "tu1",
1104 serde_json::json!({
1105 "file_path": "hello.txt",
1106 "content": "hi\nthere\n",
1107 }),
1108 )];
1109 let view = view_with(vec![turn]);
1110 let path = derive_path(&view, &DeriveConfig::default());
1111 let ch = &path.steps[0].change["hello.txt"];
1112 let raw = ch.raw.as_deref().expect("write should emit diff");
1113 assert!(raw.contains("+hi"));
1114 assert!(raw.contains("+there"));
1115 let sc = ch.structural.as_ref().unwrap();
1116 assert_eq!(sc.extra["after"], serde_json::json!("hi\nthere\n"));
1117 assert!(!sc.extra.contains_key("before"));
1118 }
1119
1120 #[test]
1121 fn test_file_write_diff_write_without_before_state_is_addition_only() {
1122 let input = serde_json::json!({
1124 "file_path": "hello.txt",
1125 "content": "hi\nthere\n",
1126 });
1127 let raw =
1128 file_write_diff("Write", &input, "hello.txt", None).expect("write should emit diff");
1129 assert!(raw.contains("+hi"));
1130 assert!(raw.contains("+there"));
1131 assert!(
1133 !raw.lines()
1134 .any(|l| l.starts_with('-') && !l.starts_with("---"))
1135 );
1136 }
1137
1138 #[test]
1139 fn test_file_write_diff_write_with_before_state_shows_replacement() {
1140 let input = serde_json::json!({
1141 "file_path": "hello.txt",
1142 "content": "hi\nthere\n",
1143 });
1144 let raw = file_write_diff("Write", &input, "hello.txt", Some("bye\nfriend\n"))
1145 .expect("write should emit diff");
1146 assert!(raw.contains("-bye"));
1148 assert!(raw.contains("-friend"));
1149 assert!(raw.contains("+hi"));
1151 assert!(raw.contains("+there"));
1152 }
1153
1154 #[test]
1155 fn test_file_write_diff_before_state_ignored_for_edit_shape() {
1156 let input = serde_json::json!({
1159 "file_path": "a.rs",
1160 "old_string": "foo",
1161 "new_string": "bar",
1162 });
1163 let raw = file_write_diff("Edit", &input, "a.rs", Some("something else entirely"))
1164 .expect("edit should emit diff");
1165 assert!(raw.contains("-foo"));
1166 assert!(raw.contains("+bar"));
1167 assert!(!raw.contains("something else entirely"));
1168 }
1169
1170 #[test]
1171 fn test_unified_diff_strips_leading_slash_on_absolute_path() {
1172 let raw = unified_diff("/abs/path.rs", "a\n", "b\n");
1174 assert!(
1175 raw.contains("--- a/abs/path.rs\n"),
1176 "missing stripped --- header: {raw}"
1177 );
1178 assert!(
1179 raw.contains("+++ b/abs/path.rs\n"),
1180 "missing stripped +++ header: {raw}"
1181 );
1182 assert!(
1183 !raw.contains("a//"),
1184 "header should not contain doubled slash: {raw}"
1185 );
1186 assert!(
1187 !raw.contains("b//"),
1188 "header should not contain doubled slash: {raw}"
1189 );
1190 }
1191
1192 #[test]
1193 fn test_unified_diff_preserves_relative_path() {
1194 let raw = unified_diff("src/login.rs", "a\n", "b\n");
1197 assert!(raw.contains("--- a/src/login.rs\n"), "{raw}");
1198 assert!(raw.contains("+++ b/src/login.rs\n"), "{raw}");
1199 }
1200
1201 #[test]
1202 fn test_tool_use_multiedit_emits_per_hunk_diff() {
1203 let mut turn = base_turn("t1", Role::Assistant);
1204 turn.tool_uses = vec![fw_tool(
1205 "MultiEdit",
1206 "tu1",
1207 serde_json::json!({
1208 "file_path": "m.rs",
1209 "edits": [
1210 {"old_string": "foo", "new_string": "bar"},
1211 {"old_string": "baz", "new_string": "qux"},
1212 ],
1213 }),
1214 )];
1215 let view = view_with(vec![turn]);
1216 let path = derive_path(&view, &DeriveConfig::default());
1217 let ch = &path.steps[0].change["m.rs"];
1218 let raw = ch.raw.as_deref().expect("multiedit should emit diff");
1219 assert!(raw.contains("# edit 1/2"));
1220 assert!(raw.contains("# edit 2/2"));
1221 assert!(raw.contains("-foo"));
1222 assert!(raw.contains("+bar"));
1223 assert!(raw.contains("-baz"));
1224 assert!(raw.contains("+qux"));
1225 }
1226
1227 #[test]
1228 fn test_thinking_included_when_enabled() {
1229 let mut turn = base_turn("t1", Role::Assistant);
1230 turn.thinking = Some("hmm".into());
1231 let view = view_with(vec![turn]);
1232 let path = derive_path(&view, &DeriveConfig::default());
1233 let sc = conv_change(&path.steps[0]);
1234 assert_eq!(sc.extra["thinking"], serde_json::json!("hmm"));
1235 }
1236
1237 #[test]
1238 fn test_thinking_omitted_when_disabled() {
1239 let mut turn = base_turn("t1", Role::Assistant);
1240 turn.thinking = Some("hmm".into());
1241 let view = view_with(vec![turn]);
1242 let cfg = DeriveConfig {
1243 include_thinking: false,
1244 ..Default::default()
1245 };
1246 let path = derive_path(&view, &cfg);
1247 let sc = conv_change(&path.steps[0]);
1248 assert!(!sc.extra.contains_key("thinking"));
1249 }
1250
1251 #[test]
1252 fn test_tool_uses_included_when_enabled() {
1253 let mut turn = base_turn("t1", Role::Assistant);
1254 turn.tool_uses = vec![ToolInvocation {
1255 id: "tu1".into(),
1256 name: "Read".into(),
1257 input: serde_json::json!({}),
1258 result: Some(ToolResult {
1259 content: "x".into(),
1260 is_error: false,
1261 }),
1262 category: Some(ToolCategory::FileRead),
1263 }];
1264 let view = view_with(vec![turn]);
1265 let path = derive_path(&view, &DeriveConfig::default());
1266 let sc = conv_change(&path.steps[0]);
1267 assert!(sc.extra.contains_key("tool_uses"));
1268 }
1269
1270 #[test]
1271 fn test_tool_uses_omitted_when_disabled() {
1272 let mut turn = base_turn("t1", Role::Assistant);
1273 turn.tool_uses = vec![ToolInvocation {
1274 id: "tu1".into(),
1275 name: "Read".into(),
1276 input: serde_json::json!({}),
1277 result: None,
1278 category: Some(ToolCategory::FileRead),
1279 }];
1280 let view = view_with(vec![turn]);
1281 let cfg = DeriveConfig {
1282 include_tool_uses: false,
1283 ..Default::default()
1284 };
1285 let path = derive_path(&view, &cfg);
1286 let sc = conv_change(&path.steps[0]);
1287 assert!(!sc.extra.contains_key("tool_uses"));
1288 }
1289
1290 #[test]
1291 fn test_base_uri_from_working_dir() {
1292 let mut turn = base_turn("t1", Role::User);
1293 turn.environment = Some(EnvironmentSnapshot {
1294 working_dir: Some("/Users/alex/proj".into()),
1295 ..Default::default()
1296 });
1297 let view = view_with(vec![turn]);
1298 let path = derive_path(&view, &DeriveConfig::default());
1299 assert_eq!(path.path.base.unwrap().uri, "file:///Users/alex/proj");
1300 }
1301
1302 #[test]
1303 fn test_base_uri_from_config_override() {
1304 let mut turn = base_turn("t1", Role::User);
1305 turn.environment = Some(EnvironmentSnapshot {
1306 working_dir: Some("/Users/alex/proj".into()),
1307 ..Default::default()
1308 });
1309 let view = view_with(vec![turn]);
1310 let cfg = DeriveConfig {
1311 base_uri: Some("github:org/repo".into()),
1312 ..Default::default()
1313 };
1314 let path = derive_path(&view, &cfg);
1315 assert_eq!(path.path.base.unwrap().uri, "github:org/repo");
1316 }
1317
1318 #[test]
1319 fn test_base_uri_absent_when_no_source() {
1320 let turn = base_turn("t1", Role::User);
1321 let view = view_with(vec![turn]);
1322 let path = derive_path(&view, &DeriveConfig::default());
1323 assert!(path.path.base.is_none());
1324 }
1325
1326 #[test]
1327 fn test_path_id_from_config_override() {
1328 let view = view_with(vec![]);
1329 let cfg = DeriveConfig {
1330 path_id: Some("my-custom-id".into()),
1331 ..Default::default()
1332 };
1333 let path = derive_path(&view, &cfg);
1334 assert_eq!(path.path.id, "my-custom-id");
1335 }
1336
1337 #[test]
1338 fn test_path_id_default_format() {
1339 let view = view_with(vec![]);
1340 let path = derive_path(&view, &DeriveConfig::default());
1341 assert_eq!(path.path.id, "path-pi-abcdef01");
1342 }
1343
1344 #[test]
1345 fn test_files_changed_in_meta() {
1346 let mut view = view_with(vec![]);
1347 view.files_changed = vec!["a.rs".into(), "b.rs".into()];
1348 let path = derive_path(&view, &DeriveConfig::default());
1349 let meta = path.meta.unwrap();
1350 assert_eq!(
1351 meta.extra["files_changed"],
1352 serde_json::json!(["a.rs", "b.rs"])
1353 );
1354 }
1355
1356 #[test]
1357 fn test_actors_in_meta() {
1358 let u = base_turn("t1", Role::User);
1359 let mut a = base_turn("t2", Role::Assistant);
1360 a.model = Some("claude-opus-4-7".into());
1361 let view = view_with(vec![u, a]);
1362 let path = derive_path(&view, &DeriveConfig::default());
1363 let actors = path.meta.unwrap().actors.unwrap();
1364 assert!(actors.contains_key("human:user"));
1365 assert!(actors.contains_key("agent:claude-opus-4-7"));
1366 let agent = &actors["agent:claude-opus-4-7"];
1367 assert_eq!(agent.provider.as_deref(), Some("pi"));
1368 assert_eq!(agent.model.as_deref(), Some("claude-opus-4-7"));
1369 let human = &actors["human:user"];
1370 assert_eq!(human.name.as_deref(), Some("user"));
1371 }
1372
1373 #[test]
1374 fn test_head_is_last_step_id() {
1375 let turns = vec![
1376 base_turn("t1", Role::User),
1377 base_turn("t2", Role::User),
1378 base_turn("t3", Role::User),
1379 ];
1380 let view = view_with(turns);
1381 let path = derive_path(&view, &DeriveConfig::default());
1382 assert_eq!(path.path.head, "t3");
1383 }
1384
1385 #[test]
1386 fn test_token_usage_in_extras() {
1387 let mut turn = base_turn("t1", Role::Assistant);
1388 turn.token_usage = Some(TokenUsage {
1389 input_tokens: Some(100),
1390 output_tokens: Some(50),
1391 cache_read_tokens: None,
1392 cache_write_tokens: None,
1393 ..Default::default()
1394 });
1395 let view = view_with(vec![turn]);
1396 let path = derive_path(&view, &DeriveConfig::default());
1397 let sc = conv_change(&path.steps[0]);
1398 assert!(sc.extra.contains_key("token_usage"));
1399 assert_eq!(
1400 sc.extra["token_usage"]["input_tokens"],
1401 serde_json::json!(100)
1402 );
1403 }
1404
1405 fn usage(output: u32) -> TokenUsage {
1406 TokenUsage {
1407 input_tokens: Some(6),
1408 output_tokens: Some(output),
1409 cache_read_tokens: Some(14_842),
1410 cache_write_tokens: Some(429_831),
1411 ..Default::default()
1412 }
1413 }
1414
1415 #[test]
1416 fn test_message_group_carries_usage_once_on_last_step() {
1417 let mut turns: Vec<Turn> = (1..=3)
1422 .map(|i| {
1423 let mut t = base_turn(&format!("t{i}"), Role::Assistant);
1424 t.group_id = Some("msg_01".into());
1425 t.token_usage = Some(usage(997));
1426 t
1427 })
1428 .collect();
1429 let mut t4 = base_turn("t4", Role::Assistant);
1430 t4.group_id = Some("msg_02".into());
1431 t4.token_usage = Some(usage(11));
1432 turns.push(t4);
1433
1434 let view = view_with(turns);
1435 let path = derive_path(&view, &DeriveConfig::default());
1436 let changes: Vec<&StructuralChange> = path.steps.iter().map(conv_change).collect();
1437
1438 assert!(!changes[0].extra.contains_key("token_usage"));
1439 assert!(!changes[1].extra.contains_key("token_usage"));
1440 assert_eq!(
1441 changes[2].extra["token_usage"]["output_tokens"],
1442 serde_json::json!(997)
1443 );
1444 assert_eq!(
1445 changes[3].extra["token_usage"]["output_tokens"],
1446 serde_json::json!(11)
1447 );
1448 for c in &changes[..3] {
1449 assert_eq!(c.extra["group_id"], serde_json::json!("msg_01"));
1450 }
1451 assert_eq!(changes[3].extra["group_id"], serde_json::json!("msg_02"));
1452 }
1453
1454 #[test]
1455 fn test_turn_without_group_id_is_its_own_accounting_unit() {
1456 let mut turns = Vec::new();
1459 for i in 1..=2 {
1460 let mut t = base_turn(&format!("t{i}"), Role::Assistant);
1461 t.token_usage = Some(usage(50 + i));
1462 turns.push(t);
1463 }
1464 let view = view_with(turns);
1465 let path = derive_path(&view, &DeriveConfig::default());
1466 for (i, step) in path.steps.iter().enumerate() {
1467 let sc = conv_change(step);
1468 assert_eq!(
1469 sc.extra["token_usage"]["output_tokens"],
1470 serde_json::json!(51 + i as u64)
1471 );
1472 assert!(!sc.extra.contains_key("group_id"));
1473 }
1474 }
1475
1476 #[test]
1477 fn test_message_grouping_is_consecutive_only() {
1478 let mk = |id: &str, msg: &str, out: u32| {
1482 let mut t = base_turn(id, Role::Assistant);
1483 t.group_id = Some(msg.into());
1484 t.token_usage = Some(usage(out));
1485 t
1486 };
1487 let view = view_with(vec![
1488 mk("t1", "msg_01", 100),
1489 mk("t2", "msg_02", 200),
1490 mk("t3", "msg_01", 300),
1491 ]);
1492 let path = derive_path(&view, &DeriveConfig::default());
1493 let changes: Vec<&StructuralChange> = path.steps.iter().map(conv_change).collect();
1494 assert_eq!(
1495 changes[0].extra["token_usage"]["output_tokens"],
1496 serde_json::json!(100)
1497 );
1498 assert_eq!(
1499 changes[1].extra["token_usage"]["output_tokens"],
1500 serde_json::json!(200)
1501 );
1502 assert_eq!(
1503 changes[2].extra["token_usage"]["output_tokens"],
1504 serde_json::json!(300)
1505 );
1506 }
1507
1508 #[test]
1509 fn test_delegations_in_extras() {
1510 let mut turn = base_turn("t1", Role::Assistant);
1511 turn.delegations = vec![DelegatedWork {
1512 agent_id: "sub-1".into(),
1513 prompt: "do a thing".into(),
1514 turns: vec![],
1515 result: None,
1516 }];
1517 let view = view_with(vec![turn]);
1518 let path = derive_path(&view, &DeriveConfig::default());
1519 let sc = conv_change(&path.steps[0]);
1520 assert!(sc.extra.contains_key("delegations"));
1521 assert_eq!(
1522 sc.extra["delegations"][0]["agent_id"],
1523 serde_json::json!("sub-1")
1524 );
1525 }
1526
1527 #[test]
1528 fn test_title_from_config() {
1529 let view = view_with(vec![]);
1530 let cfg = DeriveConfig {
1531 title: Some("My Session".into()),
1532 ..Default::default()
1533 };
1534 let path = derive_path(&view, &cfg);
1535 assert_eq!(path.meta.unwrap().title.as_deref(), Some("My Session"));
1536 }
1537
1538 #[test]
1539 fn test_title_default_when_unset() {
1540 let view = view_with(vec![]);
1541 let path = derive_path(&view, &DeriveConfig::default());
1542 assert_eq!(
1543 path.meta.unwrap().title.as_deref(),
1544 Some("pi session: abcdef01")
1545 );
1546 }
1547
1548 #[test]
1549 fn test_serde_roundtrip() {
1550 let mut t1 = base_turn("t1", Role::User);
1551 t1.text = "hello".into();
1552 t1.environment = Some(EnvironmentSnapshot {
1553 working_dir: Some("/proj".into()),
1554 ..Default::default()
1555 });
1556 let mut t2 = base_turn("t2", Role::Assistant);
1557 t2.parent_id = Some("t1".into());
1558 t2.model = Some("m".into());
1559 t2.tool_uses = vec![fw_tool(
1560 "Write",
1561 "tu1",
1562 serde_json::json!({"file_path": "x.rs"}),
1563 )];
1564
1565 let mut view = view_with(vec![t1, t2]);
1566 view.files_changed = vec!["x.rs".into()];
1567
1568 let path = derive_path(&view, &DeriveConfig::default());
1569 let json = serde_json::to_string(&path).unwrap();
1570 let back: Path = serde_json::from_str(&json).unwrap();
1571 assert_eq!(back.path.id, path.path.id);
1572 assert_eq!(back.path.head, path.path.head);
1573 assert_eq!(back.steps.len(), 2);
1574 assert_eq!(back.steps[1].step.parents, vec!["t1".to_string()]);
1575 assert!(back.steps[1].change.contains_key("x.rs"));
1576 }
1577}