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