1use std::collections::HashMap;
9
10use toolpath::v1::{
11 ActorDefinition, ArtifactChange, Base, Path, PathIdentity, PathMeta, Step, StepIdentity,
12 StructuralChange,
13};
14
15use crate::{ConversationView, Role, ToolCategory, ToolInvocation, Turn};
16
17#[derive(Debug, Clone)]
19pub struct DeriveConfig {
20 pub base_uri: Option<String>,
23 pub path_id: Option<String>,
25 pub title: Option<String>,
27 pub include_thinking: bool,
29 pub include_tool_uses: bool,
31}
32
33impl Default for DeriveConfig {
34 fn default() -> Self {
35 Self {
36 base_uri: None,
37 path_id: None,
38 title: None,
39 include_thinking: true,
40 include_tool_uses: true,
41 }
42 }
43}
44
45pub fn derive_path(view: &ConversationView, config: &DeriveConfig) -> Path {
47 let provider = view.provider_id.as_deref().unwrap_or("unknown");
48 let id_prefix: String = view.id.chars().take(8).collect();
49
50 let path_id = config
51 .path_id
52 .clone()
53 .unwrap_or_else(|| format!("path-{}-{}", provider, id_prefix));
54
55 let base = config
57 .base_uri
58 .clone()
59 .map(|uri| Base { uri, ref_str: None })
60 .or_else(|| {
61 view.turns
62 .iter()
63 .find_map(|t| t.environment.as_ref()?.working_dir.clone())
64 .map(|wd| {
65 let uri = if wd.starts_with('/') {
66 format!("file://{}", wd)
67 } else {
68 wd
69 };
70 Base { uri, ref_str: None }
71 })
72 });
73
74 let conv_artifact_key = format!("{}://{}", provider, view.id);
75
76 let mut steps: Vec<Step> = Vec::with_capacity(view.turns.len());
77 let mut turn_to_step: HashMap<String, String> = HashMap::new();
78 let mut actors: HashMap<String, ActorDefinition> = HashMap::new();
79
80 for (idx, turn) in view.turns.iter().enumerate() {
81 let step_id = format!("step-{:04}", idx + 1);
82 turn_to_step.insert(turn.id.clone(), step_id.clone());
83
84 let actor = actor_for_turn(turn, provider);
85 record_actor(&mut actors, &actor, turn, provider, view);
86
87 let mut step = Step {
88 step: StepIdentity {
89 id: step_id,
90 parents: Vec::new(),
91 actor,
92 timestamp: turn.timestamp.clone(),
93 },
94 change: HashMap::new(),
95 meta: None,
96 };
97
98 if let Some(parent_id) = &turn.parent_id
100 && let Some(parent_step_id) = turn_to_step.get(parent_id)
101 {
102 step.step.parents.push(parent_step_id.clone());
103 }
104
105 let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
107 extra.insert(
108 "role".to_string(),
109 serde_json::Value::String(turn.role.to_string()),
110 );
111 extra.insert(
112 "text".to_string(),
113 serde_json::Value::String(turn.text.clone()),
114 );
115
116 if config.include_thinking
117 && let Some(thinking) = &turn.thinking
118 {
119 extra.insert(
120 "thinking".to_string(),
121 serde_json::Value::String(thinking.clone()),
122 );
123 }
124
125 if config.include_tool_uses && !turn.tool_uses.is_empty() {
126 let arr: Vec<serde_json::Value> = turn
127 .tool_uses
128 .iter()
129 .map(|t| {
130 let mut obj = serde_json::json!({
131 "id": t.id,
132 "name": t.name,
133 "input": t.input,
134 "category": t.category,
135 });
136 if let Some(result) = &t.result
137 && let Ok(v) = serde_json::to_value(result)
138 {
139 obj.as_object_mut().unwrap().insert("result".to_string(), v);
140 }
141 obj
142 })
143 .collect();
144 extra.insert("tool_uses".to_string(), serde_json::Value::Array(arr));
145 }
146
147 if let Some(usage) = &turn.token_usage
148 && let Ok(v) = serde_json::to_value(usage)
149 {
150 extra.insert("token_usage".to_string(), v);
151 }
152
153 if !turn.delegations.is_empty()
154 && let Ok(v) = serde_json::to_value(&turn.delegations)
155 {
156 extra.insert("delegations".to_string(), v);
157 }
158
159 if let Some(stop_reason) = &turn.stop_reason {
160 extra.insert(
161 "stop_reason".to_string(),
162 serde_json::Value::String(stop_reason.clone()),
163 );
164 }
165
166 if let Some(env) = &turn.environment
167 && let Ok(v) = serde_json::to_value(env)
168 {
169 extra.insert("environment".to_string(), v);
170 }
171
172 if !turn.extra.is_empty()
173 && let Ok(v) = serde_json::to_value(&turn.extra)
174 {
175 extra.insert("turn_extra".to_string(), v);
176 }
177
178 step.change.insert(
179 conv_artifact_key.clone(),
180 ArtifactChange {
181 raw: None,
182 structural: Some(StructuralChange {
183 change_type: "conversation.append".to_string(),
184 extra,
185 }),
186 },
187 );
188
189 for tool in &turn.tool_uses {
194 if tool.category != Some(ToolCategory::FileWrite) {
195 continue;
196 }
197 let Some(path) = extract_file_path(tool) else {
198 continue;
199 };
200 let (raw, mut t_extra) = file_write_change(tool, &path, None);
205 t_extra.insert(
206 "tool".to_string(),
207 serde_json::Value::String(tool.name.clone()),
208 );
209 t_extra.insert(
210 "tool_id".to_string(),
211 serde_json::Value::String(tool.id.clone()),
212 );
213 step.change.insert(
214 path,
215 ArtifactChange {
216 raw,
217 structural: Some(StructuralChange {
218 change_type: "file.write".to_string(),
219 extra: t_extra,
220 }),
221 },
222 );
223 }
224
225 steps.push(step);
226 }
227
228 let head = steps.last().map(|s| s.step.id.clone()).unwrap_or_default();
229
230 let title = config
232 .title
233 .clone()
234 .unwrap_or_else(|| format!("{} session: {}", provider, id_prefix));
235
236 let mut meta = PathMeta {
237 title: Some(title),
238 source: view.provider_id.clone(),
239 ..Default::default()
240 };
241
242 if !actors.is_empty() {
243 meta.actors = Some(actors);
244 }
245
246 if !view.files_changed.is_empty()
247 && let Ok(v) = serde_json::to_value(&view.files_changed)
248 {
249 meta.extra.insert("files_changed".to_string(), v);
250 }
251
252 Path {
253 path: PathIdentity {
254 id: path_id,
255 base,
256 head,
257 graph_ref: None,
258 },
259 steps,
260 meta: Some(meta),
261 }
262}
263
264fn actor_for_turn(turn: &Turn, provider: &str) -> String {
265 match &turn.role {
266 Role::User => "human:user".to_string(),
267 Role::Assistant => {
268 let model = turn.model.as_deref().unwrap_or("unknown");
269 format!("agent:{}", model)
270 }
271 Role::System => format!("system:{}", provider),
272 Role::Other(s) => format!("{}:unknown", s),
273 }
274}
275
276fn record_actor(
277 actors: &mut HashMap<String, ActorDefinition>,
278 actor: &str,
279 turn: &Turn,
280 provider: &str,
281 _view: &ConversationView,
282) {
283 if actors.contains_key(actor) {
284 return;
285 }
286 let def = if let Some(rest) = actor.strip_prefix("agent:") {
287 ActorDefinition {
288 name: Some(rest.to_string()),
289 provider: Some(provider.to_string()),
290 model: turn.model.clone(),
291 identities: vec![],
292 keys: vec![],
293 }
294 } else if let Some(rest) = actor.strip_prefix("human:") {
295 ActorDefinition {
296 name: Some(rest.to_string()),
297 ..Default::default()
298 }
299 } else {
300 let name = actor.split_once(':').map(|x| x.1).unwrap_or("").to_string();
302 ActorDefinition {
303 name: Some(name),
304 ..Default::default()
305 }
306 };
307 actors.insert(actor.to_string(), def);
308}
309
310fn extract_file_path(tool: &ToolInvocation) -> Option<String> {
311 for field in &["file_path", "path", "filename", "file"] {
312 if let Some(v) = tool.input.get(*field)
313 && let Some(s) = v.as_str()
314 {
315 return Some(s.to_string());
316 }
317 }
318 None
319}
320
321fn file_write_change(
331 tool: &ToolInvocation,
332 path: &str,
333 before_state: Option<&str>,
334) -> (Option<String>, HashMap<String, serde_json::Value>) {
335 let input = &tool.input;
336 let str_field = |k: &str| input.get(k).and_then(|v| v.as_str()).map(str::to_string);
337
338 let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
339
340 if let (Some(old), Some(new)) = (str_field("old_string"), str_field("new_string")) {
341 extra.insert("before".to_string(), serde_json::Value::String(old.clone()));
342 extra.insert("after".to_string(), serde_json::Value::String(new.clone()));
343 } else if let Some(content) = str_field("content") {
344 if let Some(before) = before_state {
345 extra.insert(
346 "before".to_string(),
347 serde_json::Value::String(before.to_string()),
348 );
349 }
350 extra.insert("after".to_string(), serde_json::Value::String(content));
351 } else if let Some(edits) = input.get("edits").and_then(|v| v.as_array()) {
352 extra.insert("edits".to_string(), serde_json::Value::Array(edits.clone()));
353 }
354
355 (
356 file_write_diff(&tool.name, input, path, before_state),
357 extra,
358 )
359}
360
361pub fn file_write_diff(
389 tool_name: &str,
390 input: &serde_json::Value,
391 path: &str,
392 before_state: Option<&str>,
393) -> Option<String> {
394 let str_field = |k: &str| input.get(k).and_then(|v| v.as_str());
395
396 if let (Some(old), Some(new)) = (str_field("old_string"), str_field("new_string")) {
398 return Some(unified_diff(path, old, new));
399 }
400
401 if let Some(content) = str_field("content") {
404 let before = before_state.unwrap_or("");
405 return Some(unified_diff(path, before, content));
406 }
407
408 if let Some(edits) = input.get("edits").and_then(|v| v.as_array()) {
410 if edits.is_empty() {
411 return None;
412 }
413 let mut parts: Vec<String> = Vec::new();
414 for (idx, edit) in edits.iter().enumerate() {
415 let old = edit
416 .get("old_string")
417 .and_then(|v| v.as_str())
418 .unwrap_or("");
419 let new = edit
420 .get("new_string")
421 .and_then(|v| v.as_str())
422 .unwrap_or("");
423 let header = format!("# edit {}/{}", idx + 1, edits.len());
424 parts.push(format!("{header}\n{}", unified_diff(path, old, new)));
425 }
426 return Some(parts.join("\n"));
427 }
428
429 let _ = tool_name;
432 None
433}
434
435pub fn unified_diff(path: &str, before: &str, after: &str) -> String {
445 use similar::TextDiff;
446 let diff = TextDiff::from_lines(before, after);
447 let display = path.trim_start_matches('/');
448 let mut out = String::new();
449 out.push_str(&format!("--- a/{display}\n+++ b/{display}\n"));
450 out.push_str(
451 &diff
452 .unified_diff()
453 .context_radius(3)
454 .header("", "")
455 .to_string(),
456 );
457 out
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463 use crate::{DelegatedWork, EnvironmentSnapshot, TokenUsage, ToolInvocation, ToolResult};
464
465 fn base_turn(id: &str, role: Role) -> Turn {
466 Turn {
467 id: id.to_string(),
468 parent_id: None,
469 role,
470 timestamp: "2026-01-01T00:00:00Z".to_string(),
471 text: String::new(),
472 thinking: None,
473 tool_uses: vec![],
474 model: None,
475 stop_reason: None,
476 token_usage: None,
477 environment: None,
478 delegations: vec![],
479 extra: HashMap::new(),
480 }
481 }
482
483 fn view_with(turns: Vec<Turn>) -> ConversationView {
484 ConversationView {
485 id: "abcdef012345".to_string(),
486 started_at: None,
487 last_activity: None,
488 turns,
489 total_usage: None,
490 provider_id: Some("pi".to_string()),
491 files_changed: vec![],
492 session_ids: vec![],
493 events: vec![],
494 }
495 }
496
497 fn conv_change(step: &Step) -> &StructuralChange {
498 let key = step
499 .change
500 .keys()
501 .find(|k| k.contains("://"))
502 .expect("conversation artifact key present");
503 step.change[key].structural.as_ref().unwrap()
504 }
505
506 #[test]
507 fn test_empty_view() {
508 let view = view_with(vec![]);
509 let path = derive_path(&view, &DeriveConfig::default());
510 assert!(path.steps.is_empty());
511 assert_eq!(path.path.head, "");
512 }
513
514 #[test]
515 fn test_single_user_turn() {
516 let mut turn = base_turn("t1", Role::User);
517 turn.text = "hello".into();
518 let view = view_with(vec![turn]);
519 let path = derive_path(&view, &DeriveConfig::default());
520 assert_eq!(path.steps.len(), 1);
521 assert_eq!(path.steps[0].step.actor, "human:user");
522 assert_eq!(path.steps[0].step.id, "step-0001");
523 }
524
525 #[test]
526 fn test_single_assistant_turn() {
527 let mut turn = base_turn("t1", Role::Assistant);
528 turn.model = Some("claude-opus-4-7".into());
529 let view = view_with(vec![turn]);
530 let path = derive_path(&view, &DeriveConfig::default());
531 assert_eq!(path.steps[0].step.actor, "agent:claude-opus-4-7");
532 }
533
534 #[test]
535 fn test_assistant_without_model() {
536 let turn = base_turn("t1", Role::Assistant);
537 let view = view_with(vec![turn]);
538 let path = derive_path(&view, &DeriveConfig::default());
539 assert_eq!(path.steps[0].step.actor, "agent:unknown");
540 }
541
542 #[test]
543 fn test_system_role() {
544 let turn = base_turn("t1", Role::System);
545 let view = view_with(vec![turn]);
546 let path = derive_path(&view, &DeriveConfig::default());
547 assert_eq!(path.steps[0].step.actor, "system:pi");
548 }
549
550 #[test]
551 fn test_other_role() {
552 let turn = base_turn("t1", Role::Other("tool".into()));
553 let view = view_with(vec![turn]);
554 let path = derive_path(&view, &DeriveConfig::default());
555 assert_eq!(path.steps[0].step.actor, "tool:unknown");
556 }
557
558 #[test]
559 fn test_parent_id_preserved() {
560 let t1 = base_turn("t1", Role::User);
561 let mut t2 = base_turn("t2", Role::Assistant);
562 t2.parent_id = Some("t1".into());
563 t2.model = Some("m".into());
564 let view = view_with(vec![t1, t2]);
565 let path = derive_path(&view, &DeriveConfig::default());
566 assert_eq!(path.steps[1].step.parents, vec!["step-0001".to_string()]);
567 }
568
569 fn fw_tool(name: &str, id: &str, input: serde_json::Value) -> ToolInvocation {
570 ToolInvocation {
571 id: id.to_string(),
572 name: name.to_string(),
573 input,
574 result: None,
575 category: Some(ToolCategory::FileWrite),
576 }
577 }
578
579 #[test]
580 fn test_tool_use_filewrite_with_file_path_field() {
581 let mut turn = base_turn("t1", Role::Assistant);
582 turn.tool_uses = vec![fw_tool(
583 "Write",
584 "tu1",
585 serde_json::json!({"file_path": "src/main.rs"}),
586 )];
587 let view = view_with(vec![turn]);
588 let path = derive_path(&view, &DeriveConfig::default());
589 assert!(path.steps[0].change.contains_key("src/main.rs"));
590 let sc = path.steps[0].change["src/main.rs"]
591 .structural
592 .as_ref()
593 .unwrap();
594 assert_eq!(sc.change_type, "file.write");
595 assert_eq!(sc.extra["tool"], serde_json::json!("Write"));
596 assert_eq!(sc.extra["tool_id"], serde_json::json!("tu1"));
597 }
598
599 #[test]
600 fn test_tool_use_filewrite_with_path_field() {
601 let mut turn = base_turn("t1", Role::Assistant);
602 turn.tool_uses = vec![fw_tool("Edit", "tu1", serde_json::json!({"path": "a.rs"}))];
603 let view = view_with(vec![turn]);
604 let path = derive_path(&view, &DeriveConfig::default());
605 assert!(path.steps[0].change.contains_key("a.rs"));
606 }
607
608 #[test]
609 fn test_tool_use_filewrite_with_filename_field() {
610 let mut turn = base_turn("t1", Role::Assistant);
611 turn.tool_uses = vec![fw_tool("W", "tu1", serde_json::json!({"filename": "b.rs"}))];
612 let view = view_with(vec![turn]);
613 let path = derive_path(&view, &DeriveConfig::default());
614 assert!(path.steps[0].change.contains_key("b.rs"));
615 }
616
617 #[test]
618 fn test_tool_use_filewrite_with_file_field() {
619 let mut turn = base_turn("t1", Role::Assistant);
620 turn.tool_uses = vec![fw_tool("W", "tu1", serde_json::json!({"file": "c.rs"}))];
621 let view = view_with(vec![turn]);
622 let path = derive_path(&view, &DeriveConfig::default());
623 assert!(path.steps[0].change.contains_key("c.rs"));
624 }
625
626 #[test]
627 fn test_tool_use_filewrite_no_recognized_field() {
628 let mut turn = base_turn("t1", Role::Assistant);
629 turn.tool_uses = vec![fw_tool("W", "tu1", serde_json::json!({"other": "foo"}))];
630 let view = view_with(vec![turn]);
631 let path = derive_path(&view, &DeriveConfig::default());
632 assert_eq!(path.steps[0].change.len(), 1);
633 let sc = conv_change(&path.steps[0]);
634 assert!(sc.extra.contains_key("tool_uses"));
635 }
636
637 #[test]
638 fn test_tool_use_non_filewrite_ignored() {
639 let mut turn = base_turn("t1", Role::Assistant);
640 turn.tool_uses = vec![ToolInvocation {
641 id: "tu1".into(),
642 name: "Read".into(),
643 input: serde_json::json!({"file_path": "x.rs"}),
644 result: None,
645 category: Some(ToolCategory::FileRead),
646 }];
647 let view = view_with(vec![turn]);
648 let path = derive_path(&view, &DeriveConfig::default());
649 assert!(!path.steps[0].change.contains_key("x.rs"));
650 assert_eq!(path.steps[0].change.len(), 1);
651 }
652
653 #[test]
654 fn test_tool_use_edit_emits_unified_diff() {
655 let mut turn = base_turn("t1", Role::Assistant);
656 turn.tool_uses = vec![fw_tool(
657 "Edit",
658 "tu1",
659 serde_json::json!({
660 "file_path": "src/login.rs",
661 "old_string": "validate_token()",
662 "new_string": "validate_token_v2()",
663 }),
664 )];
665 let view = view_with(vec![turn]);
666 let path = derive_path(&view, &DeriveConfig::default());
667 let ch = &path.steps[0].change["src/login.rs"];
668 let raw = ch.raw.as_deref().expect("edit should emit unified diff");
669 assert!(raw.contains("--- a/src/login.rs"));
670 assert!(raw.contains("+++ b/src/login.rs"));
671 assert!(raw.contains("-validate_token()"));
672 assert!(raw.contains("+validate_token_v2()"));
673 let sc = ch.structural.as_ref().unwrap();
674 assert_eq!(sc.extra["before"], serde_json::json!("validate_token()"));
675 assert_eq!(sc.extra["after"], serde_json::json!("validate_token_v2()"));
676 }
677
678 #[test]
679 fn test_tool_use_write_emits_full_content_diff() {
680 let mut turn = base_turn("t1", Role::Assistant);
681 turn.tool_uses = vec![fw_tool(
682 "Write",
683 "tu1",
684 serde_json::json!({
685 "file_path": "hello.txt",
686 "content": "hi\nthere\n",
687 }),
688 )];
689 let view = view_with(vec![turn]);
690 let path = derive_path(&view, &DeriveConfig::default());
691 let ch = &path.steps[0].change["hello.txt"];
692 let raw = ch.raw.as_deref().expect("write should emit diff");
693 assert!(raw.contains("+hi"));
694 assert!(raw.contains("+there"));
695 let sc = ch.structural.as_ref().unwrap();
696 assert_eq!(sc.extra["after"], serde_json::json!("hi\nthere\n"));
697 assert!(!sc.extra.contains_key("before"));
698 }
699
700 #[test]
701 fn test_file_write_diff_write_without_before_state_is_addition_only() {
702 let input = serde_json::json!({
704 "file_path": "hello.txt",
705 "content": "hi\nthere\n",
706 });
707 let raw =
708 file_write_diff("Write", &input, "hello.txt", None).expect("write should emit diff");
709 assert!(raw.contains("+hi"));
710 assert!(raw.contains("+there"));
711 assert!(
713 !raw.lines()
714 .any(|l| l.starts_with('-') && !l.starts_with("---"))
715 );
716 }
717
718 #[test]
719 fn test_file_write_diff_write_with_before_state_shows_replacement() {
720 let input = serde_json::json!({
721 "file_path": "hello.txt",
722 "content": "hi\nthere\n",
723 });
724 let raw = file_write_diff("Write", &input, "hello.txt", Some("bye\nfriend\n"))
725 .expect("write should emit diff");
726 assert!(raw.contains("-bye"));
728 assert!(raw.contains("-friend"));
729 assert!(raw.contains("+hi"));
731 assert!(raw.contains("+there"));
732 }
733
734 #[test]
735 fn test_file_write_diff_before_state_ignored_for_edit_shape() {
736 let input = serde_json::json!({
739 "file_path": "a.rs",
740 "old_string": "foo",
741 "new_string": "bar",
742 });
743 let raw = file_write_diff("Edit", &input, "a.rs", Some("something else entirely"))
744 .expect("edit should emit diff");
745 assert!(raw.contains("-foo"));
746 assert!(raw.contains("+bar"));
747 assert!(!raw.contains("something else entirely"));
748 }
749
750 #[test]
751 fn test_unified_diff_strips_leading_slash_on_absolute_path() {
752 let raw = unified_diff("/abs/path.rs", "a\n", "b\n");
754 assert!(
755 raw.contains("--- a/abs/path.rs\n"),
756 "missing stripped --- header: {raw}"
757 );
758 assert!(
759 raw.contains("+++ b/abs/path.rs\n"),
760 "missing stripped +++ header: {raw}"
761 );
762 assert!(
763 !raw.contains("a//"),
764 "header should not contain doubled slash: {raw}"
765 );
766 assert!(
767 !raw.contains("b//"),
768 "header should not contain doubled slash: {raw}"
769 );
770 }
771
772 #[test]
773 fn test_unified_diff_preserves_relative_path() {
774 let raw = unified_diff("src/login.rs", "a\n", "b\n");
777 assert!(raw.contains("--- a/src/login.rs\n"), "{raw}");
778 assert!(raw.contains("+++ b/src/login.rs\n"), "{raw}");
779 }
780
781 #[test]
782 fn test_tool_use_multiedit_emits_per_hunk_diff() {
783 let mut turn = base_turn("t1", Role::Assistant);
784 turn.tool_uses = vec![fw_tool(
785 "MultiEdit",
786 "tu1",
787 serde_json::json!({
788 "file_path": "m.rs",
789 "edits": [
790 {"old_string": "foo", "new_string": "bar"},
791 {"old_string": "baz", "new_string": "qux"},
792 ],
793 }),
794 )];
795 let view = view_with(vec![turn]);
796 let path = derive_path(&view, &DeriveConfig::default());
797 let ch = &path.steps[0].change["m.rs"];
798 let raw = ch.raw.as_deref().expect("multiedit should emit diff");
799 assert!(raw.contains("# edit 1/2"));
800 assert!(raw.contains("# edit 2/2"));
801 assert!(raw.contains("-foo"));
802 assert!(raw.contains("+bar"));
803 assert!(raw.contains("-baz"));
804 assert!(raw.contains("+qux"));
805 }
806
807 #[test]
808 fn test_thinking_included_when_enabled() {
809 let mut turn = base_turn("t1", Role::Assistant);
810 turn.thinking = Some("hmm".into());
811 let view = view_with(vec![turn]);
812 let path = derive_path(&view, &DeriveConfig::default());
813 let sc = conv_change(&path.steps[0]);
814 assert_eq!(sc.extra["thinking"], serde_json::json!("hmm"));
815 }
816
817 #[test]
818 fn test_thinking_omitted_when_disabled() {
819 let mut turn = base_turn("t1", Role::Assistant);
820 turn.thinking = Some("hmm".into());
821 let view = view_with(vec![turn]);
822 let cfg = DeriveConfig {
823 include_thinking: false,
824 ..Default::default()
825 };
826 let path = derive_path(&view, &cfg);
827 let sc = conv_change(&path.steps[0]);
828 assert!(!sc.extra.contains_key("thinking"));
829 }
830
831 #[test]
832 fn test_tool_uses_included_when_enabled() {
833 let mut turn = base_turn("t1", Role::Assistant);
834 turn.tool_uses = vec![ToolInvocation {
835 id: "tu1".into(),
836 name: "Read".into(),
837 input: serde_json::json!({}),
838 result: Some(ToolResult {
839 content: "x".into(),
840 is_error: false,
841 }),
842 category: Some(ToolCategory::FileRead),
843 }];
844 let view = view_with(vec![turn]);
845 let path = derive_path(&view, &DeriveConfig::default());
846 let sc = conv_change(&path.steps[0]);
847 assert!(sc.extra.contains_key("tool_uses"));
848 }
849
850 #[test]
851 fn test_tool_uses_omitted_when_disabled() {
852 let mut turn = base_turn("t1", Role::Assistant);
853 turn.tool_uses = vec![ToolInvocation {
854 id: "tu1".into(),
855 name: "Read".into(),
856 input: serde_json::json!({}),
857 result: None,
858 category: Some(ToolCategory::FileRead),
859 }];
860 let view = view_with(vec![turn]);
861 let cfg = DeriveConfig {
862 include_tool_uses: false,
863 ..Default::default()
864 };
865 let path = derive_path(&view, &cfg);
866 let sc = conv_change(&path.steps[0]);
867 assert!(!sc.extra.contains_key("tool_uses"));
868 }
869
870 #[test]
871 fn test_base_uri_from_working_dir() {
872 let mut turn = base_turn("t1", Role::User);
873 turn.environment = Some(EnvironmentSnapshot {
874 working_dir: Some("/Users/alex/proj".into()),
875 ..Default::default()
876 });
877 let view = view_with(vec![turn]);
878 let path = derive_path(&view, &DeriveConfig::default());
879 assert_eq!(path.path.base.unwrap().uri, "file:///Users/alex/proj");
880 }
881
882 #[test]
883 fn test_base_uri_from_config_override() {
884 let mut turn = base_turn("t1", Role::User);
885 turn.environment = Some(EnvironmentSnapshot {
886 working_dir: Some("/Users/alex/proj".into()),
887 ..Default::default()
888 });
889 let view = view_with(vec![turn]);
890 let cfg = DeriveConfig {
891 base_uri: Some("github:org/repo".into()),
892 ..Default::default()
893 };
894 let path = derive_path(&view, &cfg);
895 assert_eq!(path.path.base.unwrap().uri, "github:org/repo");
896 }
897
898 #[test]
899 fn test_base_uri_absent_when_no_source() {
900 let turn = base_turn("t1", Role::User);
901 let view = view_with(vec![turn]);
902 let path = derive_path(&view, &DeriveConfig::default());
903 assert!(path.path.base.is_none());
904 }
905
906 #[test]
907 fn test_path_id_from_config_override() {
908 let view = view_with(vec![]);
909 let cfg = DeriveConfig {
910 path_id: Some("my-custom-id".into()),
911 ..Default::default()
912 };
913 let path = derive_path(&view, &cfg);
914 assert_eq!(path.path.id, "my-custom-id");
915 }
916
917 #[test]
918 fn test_path_id_default_format() {
919 let view = view_with(vec![]);
920 let path = derive_path(&view, &DeriveConfig::default());
921 assert_eq!(path.path.id, "path-pi-abcdef01");
922 }
923
924 #[test]
925 fn test_files_changed_in_meta() {
926 let mut view = view_with(vec![]);
927 view.files_changed = vec!["a.rs".into(), "b.rs".into()];
928 let path = derive_path(&view, &DeriveConfig::default());
929 let meta = path.meta.unwrap();
930 assert_eq!(
931 meta.extra["files_changed"],
932 serde_json::json!(["a.rs", "b.rs"])
933 );
934 }
935
936 #[test]
937 fn test_actors_in_meta() {
938 let u = base_turn("t1", Role::User);
939 let mut a = base_turn("t2", Role::Assistant);
940 a.model = Some("claude-opus-4-7".into());
941 let view = view_with(vec![u, a]);
942 let path = derive_path(&view, &DeriveConfig::default());
943 let actors = path.meta.unwrap().actors.unwrap();
944 assert!(actors.contains_key("human:user"));
945 assert!(actors.contains_key("agent:claude-opus-4-7"));
946 let agent = &actors["agent:claude-opus-4-7"];
947 assert_eq!(agent.provider.as_deref(), Some("pi"));
948 assert_eq!(agent.model.as_deref(), Some("claude-opus-4-7"));
949 let human = &actors["human:user"];
950 assert_eq!(human.name.as_deref(), Some("user"));
951 }
952
953 #[test]
954 fn test_head_is_last_step_id() {
955 let turns = vec![
956 base_turn("t1", Role::User),
957 base_turn("t2", Role::User),
958 base_turn("t3", Role::User),
959 ];
960 let view = view_with(turns);
961 let path = derive_path(&view, &DeriveConfig::default());
962 assert_eq!(path.path.head, "step-0003");
963 }
964
965 #[test]
966 fn test_token_usage_in_extras() {
967 let mut turn = base_turn("t1", Role::Assistant);
968 turn.token_usage = Some(TokenUsage {
969 input_tokens: Some(100),
970 output_tokens: Some(50),
971 cache_read_tokens: None,
972 cache_write_tokens: None,
973 });
974 let view = view_with(vec![turn]);
975 let path = derive_path(&view, &DeriveConfig::default());
976 let sc = conv_change(&path.steps[0]);
977 assert!(sc.extra.contains_key("token_usage"));
978 assert_eq!(
979 sc.extra["token_usage"]["input_tokens"],
980 serde_json::json!(100)
981 );
982 }
983
984 #[test]
985 fn test_delegations_in_extras() {
986 let mut turn = base_turn("t1", Role::Assistant);
987 turn.delegations = vec![DelegatedWork {
988 agent_id: "sub-1".into(),
989 prompt: "do a thing".into(),
990 turns: vec![],
991 result: None,
992 }];
993 let view = view_with(vec![turn]);
994 let path = derive_path(&view, &DeriveConfig::default());
995 let sc = conv_change(&path.steps[0]);
996 assert!(sc.extra.contains_key("delegations"));
997 assert_eq!(
998 sc.extra["delegations"][0]["agent_id"],
999 serde_json::json!("sub-1")
1000 );
1001 }
1002
1003 #[test]
1004 fn test_title_from_config() {
1005 let view = view_with(vec![]);
1006 let cfg = DeriveConfig {
1007 title: Some("My Session".into()),
1008 ..Default::default()
1009 };
1010 let path = derive_path(&view, &cfg);
1011 assert_eq!(path.meta.unwrap().title.as_deref(), Some("My Session"));
1012 }
1013
1014 #[test]
1015 fn test_title_default_when_unset() {
1016 let view = view_with(vec![]);
1017 let path = derive_path(&view, &DeriveConfig::default());
1018 assert_eq!(
1019 path.meta.unwrap().title.as_deref(),
1020 Some("pi session: abcdef01")
1021 );
1022 }
1023
1024 #[test]
1025 fn test_serde_roundtrip() {
1026 let mut t1 = base_turn("t1", Role::User);
1027 t1.text = "hello".into();
1028 t1.environment = Some(EnvironmentSnapshot {
1029 working_dir: Some("/proj".into()),
1030 ..Default::default()
1031 });
1032 let mut t2 = base_turn("t2", Role::Assistant);
1033 t2.parent_id = Some("t1".into());
1034 t2.model = Some("m".into());
1035 t2.tool_uses = vec![fw_tool(
1036 "Write",
1037 "tu1",
1038 serde_json::json!({"file_path": "x.rs"}),
1039 )];
1040
1041 let mut view = view_with(vec![t1, t2]);
1042 view.files_changed = vec!["x.rs".into()];
1043
1044 let path = derive_path(&view, &DeriveConfig::default());
1045 let json = serde_json::to_string(&path).unwrap();
1046 let back: Path = serde_json::from_str(&json).unwrap();
1047 assert_eq!(back.path.id, path.path.id);
1048 assert_eq!(back.path.head, path.path.head);
1049 assert_eq!(back.steps.len(), 2);
1050 assert_eq!(back.steps[1].step.parents, vec!["step-0001".to_string()]);
1051 assert!(back.steps[1].change.contains_key("x.rs"));
1052 }
1053}