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 if let Some(usage) = &turn.token_usage
182 && let Ok(v) = serde_json::to_value(usage)
183 {
184 extra.insert("token_usage".to_string(), v);
185 }
186
187 if !turn.delegations.is_empty()
188 && let Ok(v) = serde_json::to_value(&turn.delegations)
189 {
190 extra.insert("delegations".to_string(), v);
191 }
192
193 if let Some(stop_reason) = &turn.stop_reason {
194 extra.insert(
195 "stop_reason".to_string(),
196 serde_json::Value::String(stop_reason.clone()),
197 );
198 }
199
200 if let Some(env) = &turn.environment
201 && let Ok(v) = serde_json::to_value(env)
202 {
203 extra.insert("environment".to_string(), v);
204 }
205
206 step.change.insert(
207 conv_artifact_key.clone(),
208 ArtifactChange {
209 raw: None,
210 structural: Some(StructuralChange {
211 change_type: "conversation.append".to_string(),
212 extra,
213 }),
214 },
215 );
216
217 let attributed: std::collections::HashSet<String> = turn
229 .file_mutations
230 .iter()
231 .filter_map(|fm| fm.tool_id.clone())
232 .collect();
233 for fm in &turn.file_mutations {
234 let mut t_extra: HashMap<String, serde_json::Value> = HashMap::new();
235 if let Some(tid) = &fm.tool_id {
236 t_extra.insert(
237 "tool_id".to_string(),
238 serde_json::Value::String(tid.clone()),
239 );
240 if let Some(tool) = turn.tool_uses.iter().find(|t| &t.id == tid) {
241 t_extra.insert(
242 "tool".to_string(),
243 serde_json::Value::String(tool.name.clone()),
244 );
245 }
246 }
247 if let Some(op) = &fm.operation {
248 t_extra.insert(
249 "operation".to_string(),
250 serde_json::Value::String(op.clone()),
251 );
252 }
253 if let Some(b) = &fm.before {
254 t_extra.insert("before".to_string(), serde_json::Value::String(b.clone()));
255 }
256 if let Some(a) = &fm.after {
257 t_extra.insert("after".to_string(), serde_json::Value::String(a.clone()));
258 }
259 if let Some(rt) = &fm.rename_to {
260 t_extra.insert(
261 "rename_to".to_string(),
262 serde_json::Value::String(rt.clone()),
263 );
264 }
265 step.change.insert(
266 fm.path.clone(),
267 ArtifactChange {
268 raw: fm.raw_diff.clone(),
269 structural: Some(StructuralChange {
270 change_type: "file.write".to_string(),
271 extra: t_extra,
272 }),
273 },
274 );
275 }
276 for tool in &turn.tool_uses {
277 if tool.category != Some(ToolCategory::FileWrite) || attributed.contains(&tool.id) {
278 continue;
279 }
280 let Some(path) = extract_file_path(tool) else {
281 continue;
282 };
283 let (raw, mut t_extra) = file_write_change(tool, &path, None);
284 t_extra.insert(
285 "tool".to_string(),
286 serde_json::Value::String(tool.name.clone()),
287 );
288 t_extra.insert(
289 "tool_id".to_string(),
290 serde_json::Value::String(tool.id.clone()),
291 );
292 step.change.insert(
293 path,
294 ArtifactChange {
295 raw,
296 structural: Some(StructuralChange {
297 change_type: "file.write".to_string(),
298 extra: t_extra,
299 }),
300 },
301 );
302 }
303
304 steps.push(step);
305 }
306
307 let mut last_step_id: Option<String> = steps.last().map(|s| s.step.id.clone());
315 for (idx, event) in view.events.iter().enumerate() {
316 let step_id = if event.id.is_empty() {
318 format!("event-{:04}", idx + 1)
319 } else {
320 event.id.clone()
321 };
322 let actor = format!("tool:{}", provider);
323 actors
324 .entry(actor.clone())
325 .or_insert_with(|| ActorDefinition {
326 name: Some(provider.to_string()),
327 provider: Some(provider.to_string()),
328 ..Default::default()
329 });
330
331 let mut extra: HashMap<String, serde_json::Value> = event
338 .data
339 .iter()
340 .filter(|(k, _)| k.as_str() != "type")
341 .map(|(k, v)| (k.clone(), v.clone()))
342 .collect();
343 if let Some(t) = event.data.get("type") {
346 extra.insert("event_data_type".to_string(), t.clone());
347 }
348 extra.insert(
349 "entry_type".to_string(),
350 serde_json::Value::String(event.event_type.clone()),
351 );
352 if !event.id.is_empty() {
353 extra.insert(
354 "event_source_id".to_string(),
355 serde_json::Value::String(event.id.clone()),
356 );
357 }
358
359 let parents: Vec<String> = event
360 .parent_id
361 .as_ref()
362 .and_then(|pid| turn_to_step.get(pid).cloned())
363 .or_else(|| last_step_id.clone())
364 .into_iter()
365 .collect();
366
367 let mut step = Step {
368 step: StepIdentity {
369 id: step_id.clone(),
370 parents,
371 actor,
372 timestamp: event.timestamp.clone(),
373 },
374 change: HashMap::new(),
375 meta: None,
376 };
377
378 step.change.insert(
379 conv_artifact_key.clone(),
380 ArtifactChange {
381 raw: None,
382 structural: Some(StructuralChange {
383 change_type: "conversation.event".to_string(),
384 extra,
385 }),
386 },
387 );
388 steps.push(step);
389 last_step_id = Some(step_id);
390 }
391
392 let head = steps.last().map(|s| s.step.id.clone()).unwrap_or_default();
393
394 let title = config
396 .title
397 .clone()
398 .unwrap_or_else(|| format!("{} session: {}", provider, id_prefix));
399
400 let mut meta = PathMeta {
401 title: Some(title),
402 kind: Some(PATH_KIND_AGENT_CODING_SESSION.to_string()),
403 source: view.provider_id.clone(),
404 ..Default::default()
405 };
406
407 if !actors.is_empty() {
408 meta.actors = Some(actors);
409 }
410
411 if !view.files_changed.is_empty()
412 && let Ok(v) = serde_json::to_value(&view.files_changed)
413 {
414 meta.extra.insert("files_changed".to_string(), v);
415 }
416
417 if let Some(remote) = view.base.as_ref().and_then(|b| b.vcs_remote.as_ref())
419 && !meta.extra.contains_key("vcs_remote")
420 {
421 meta.extra.insert(
422 "vcs_remote".to_string(),
423 serde_json::Value::String(remote.clone()),
424 );
425 }
426
427 if let Some(producer) = &view.producer
429 && let Ok(v) = serde_json::to_value(producer)
430 {
431 meta.extra.insert("producer".to_string(), v);
432 }
433
434 Path {
435 path: PathIdentity {
436 id: path_id,
437 base,
438 head,
439 graph_ref: None,
440 },
441 steps,
442 meta: Some(meta),
443 }
444}
445
446fn actor_for_turn(turn: &Turn, provider: &str) -> String {
447 match &turn.role {
448 Role::User => "human:user".to_string(),
449 Role::Assistant => {
450 let model = turn.model.as_deref().unwrap_or("unknown");
451 format!("agent:{}", model)
452 }
453 Role::System => format!("tool:{}", provider),
454 Role::Other(_) => format!("tool:{}", provider),
455 }
456}
457
458fn record_actor(
459 actors: &mut HashMap<String, ActorDefinition>,
460 actor: &str,
461 turn: &Turn,
462 provider: &str,
463 _view: &ConversationView,
464) {
465 if actors.contains_key(actor) {
466 return;
467 }
468 let def = if let Some(rest) = actor.strip_prefix("agent:") {
469 ActorDefinition {
470 name: Some(rest.to_string()),
471 provider: Some(provider.to_string()),
472 model: turn.model.clone(),
473 identities: vec![],
474 keys: vec![],
475 }
476 } else if let Some(rest) = actor.strip_prefix("human:") {
477 ActorDefinition {
478 name: Some(rest.to_string()),
479 ..Default::default()
480 }
481 } else {
482 let name = actor.split_once(':').map(|x| x.1).unwrap_or("").to_string();
483 ActorDefinition {
484 name: Some(name),
485 provider: Some(provider.to_string()),
486 ..Default::default()
487 }
488 };
489 actors.insert(actor.to_string(), def);
490}
491
492fn extract_file_path(tool: &ToolInvocation) -> Option<String> {
493 for field in &["file_path", "path", "filename", "file"] {
494 if let Some(v) = tool.input.get(*field)
495 && let Some(s) = v.as_str()
496 {
497 return Some(s.to_string());
498 }
499 }
500 None
501}
502
503fn file_write_change(
513 tool: &ToolInvocation,
514 path: &str,
515 before_state: Option<&str>,
516) -> (Option<String>, HashMap<String, serde_json::Value>) {
517 let input = &tool.input;
518 let str_field = |k: &str| input.get(k).and_then(|v| v.as_str()).map(str::to_string);
519
520 let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
521
522 if let (Some(old), Some(new)) = (str_field("old_string"), str_field("new_string")) {
523 extra.insert("before".to_string(), serde_json::Value::String(old.clone()));
524 extra.insert("after".to_string(), serde_json::Value::String(new.clone()));
525 } else if let Some(content) = str_field("content") {
526 if let Some(before) = before_state {
527 extra.insert(
528 "before".to_string(),
529 serde_json::Value::String(before.to_string()),
530 );
531 }
532 extra.insert("after".to_string(), serde_json::Value::String(content));
533 } else if let Some(edits) = input.get("edits").and_then(|v| v.as_array()) {
534 extra.insert("edits".to_string(), serde_json::Value::Array(edits.clone()));
535 }
536
537 (
538 file_write_diff(&tool.name, input, path, before_state),
539 extra,
540 )
541}
542
543pub fn file_write_diff(
571 tool_name: &str,
572 input: &serde_json::Value,
573 path: &str,
574 before_state: Option<&str>,
575) -> Option<String> {
576 let str_field = |k: &str| input.get(k).and_then(|v| v.as_str());
577
578 if let (Some(old), Some(new)) = (str_field("old_string"), str_field("new_string")) {
580 return Some(unified_diff(path, old, new));
581 }
582
583 if let Some(content) = str_field("content") {
586 let before = before_state.unwrap_or("");
587 return Some(unified_diff(path, before, content));
588 }
589
590 if let Some(edits) = input.get("edits").and_then(|v| v.as_array()) {
592 if edits.is_empty() {
593 return None;
594 }
595 let mut parts: Vec<String> = Vec::new();
596 for (idx, edit) in edits.iter().enumerate() {
597 let old = edit
598 .get("old_string")
599 .and_then(|v| v.as_str())
600 .unwrap_or("");
601 let new = edit
602 .get("new_string")
603 .and_then(|v| v.as_str())
604 .unwrap_or("");
605 let header = format!("# edit {}/{}", idx + 1, edits.len());
606 parts.push(format!("{header}\n{}", unified_diff(path, old, new)));
607 }
608 return Some(parts.join("\n"));
609 }
610
611 let _ = tool_name;
614 None
615}
616
617pub fn unified_diff(path: &str, before: &str, after: &str) -> String {
627 use similar::TextDiff;
628 let diff = TextDiff::from_lines(before, after);
629 let display = path.trim_start_matches('/');
630 let mut out = String::new();
631 out.push_str(&format!("--- a/{display}\n+++ b/{display}\n"));
632 out.push_str(
633 &diff
634 .unified_diff()
635 .context_radius(3)
636 .header("", "")
637 .to_string(),
638 );
639 out
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645 use crate::{DelegatedWork, EnvironmentSnapshot, TokenUsage, ToolInvocation, ToolResult};
646
647 fn base_turn(id: &str, role: Role) -> Turn {
648 Turn {
649 id: id.to_string(),
650 parent_id: None,
651 role,
652 timestamp: "2026-01-01T00:00:00Z".to_string(),
653 text: String::new(),
654 thinking: None,
655 tool_uses: vec![],
656 model: None,
657 stop_reason: None,
658 token_usage: None,
659 environment: None,
660 delegations: vec![],
661 file_mutations: Vec::new(),
662 }
663 }
664
665 fn view_with(turns: Vec<Turn>) -> ConversationView {
666 ConversationView {
667 id: "abcdef012345".to_string(),
668 turns,
669 provider_id: Some("pi".to_string()),
670 ..Default::default()
671 }
672 }
673
674 fn conv_change(step: &Step) -> &StructuralChange {
675 let key = step
676 .change
677 .keys()
678 .find(|k| k.contains("://"))
679 .expect("conversation artifact key present");
680 step.change[key].structural.as_ref().unwrap()
681 }
682
683 #[test]
684 fn test_empty_view() {
685 let view = view_with(vec![]);
686 let path = derive_path(&view, &DeriveConfig::default());
687 assert!(path.steps.is_empty());
688 assert_eq!(path.path.head, "");
689 }
690
691 #[test]
692 fn test_meta_kind_is_convo() {
693 let view = view_with(vec![base_turn("t1", Role::User)]);
694 let path = derive_path(&view, &DeriveConfig::default());
695 assert_eq!(
696 path.meta.as_ref().unwrap().kind.as_deref(),
697 Some(PATH_KIND_AGENT_CODING_SESSION)
698 );
699 let json = serde_json::to_string(&path).unwrap();
701 assert!(
702 json.contains(r#""kind":"https://toolpath.net/kinds/agent-coding-session/v1.0.0""#)
703 );
704 }
705
706 #[test]
707 fn test_single_user_turn() {
708 let mut turn = base_turn("t1", Role::User);
709 turn.text = "hello".into();
710 let view = view_with(vec![turn]);
711 let path = derive_path(&view, &DeriveConfig::default());
712 assert_eq!(path.steps.len(), 1);
713 assert_eq!(path.steps[0].step.actor, "human:user");
714 assert_eq!(path.steps[0].step.id, "t1");
715 }
716
717 #[test]
718 fn test_single_assistant_turn() {
719 let mut turn = base_turn("t1", Role::Assistant);
720 turn.model = Some("claude-opus-4-7".into());
721 let view = view_with(vec![turn]);
722 let path = derive_path(&view, &DeriveConfig::default());
723 assert_eq!(path.steps[0].step.actor, "agent:claude-opus-4-7");
724 }
725
726 #[test]
727 fn test_assistant_without_model() {
728 let turn = base_turn("t1", Role::Assistant);
729 let view = view_with(vec![turn]);
730 let path = derive_path(&view, &DeriveConfig::default());
731 assert_eq!(path.steps[0].step.actor, "agent:unknown");
732 }
733
734 #[test]
735 fn test_system_role() {
736 let turn = base_turn("t1", Role::System);
737 let view = view_with(vec![turn]);
738 let path = derive_path(&view, &DeriveConfig::default());
739 assert_eq!(path.steps[0].step.actor, "tool:pi");
740 }
741
742 #[test]
743 fn test_other_role() {
744 let turn = base_turn("t1", Role::Other("tool".into()));
745 let view = view_with(vec![turn]);
746 let path = derive_path(&view, &DeriveConfig::default());
747 assert_eq!(path.steps[0].step.actor, "tool:pi");
748 }
749
750 #[test]
751 fn test_parent_id_preserved() {
752 let t1 = base_turn("t1", Role::User);
753 let mut t2 = base_turn("t2", Role::Assistant);
754 t2.parent_id = Some("t1".into());
755 t2.model = Some("m".into());
756 let view = view_with(vec![t1, t2]);
757 let path = derive_path(&view, &DeriveConfig::default());
758 assert_eq!(path.steps[1].step.parents, vec!["t1".to_string()]);
759 }
760
761 #[test]
762 fn derived_path_validates_against_base_schema() {
763 let user = base_turn("t1", Role::User);
764 let mut assistant = base_turn("t2", Role::Assistant);
765 assistant.parent_id = Some("t1".into());
766 assistant.model = Some("gpt-5.5".into());
767 let system = base_turn("t3", Role::System);
768 let other = base_turn("t4", Role::Other("bash".into()));
769
770 let mut view = view_with(vec![user, assistant, system, other]);
771 view.events.push(crate::ConversationEvent {
772 id: "e1".into(),
773 timestamp: "2026-01-01T00:00:00Z".into(),
774 parent_id: None,
775 event_type: "attachment".into(),
776 data: HashMap::new(),
777 });
778
779 let path = derive_path(&view, &DeriveConfig::default());
780 let graph = serde_json::json!({
781 "graph": { "id": "g1" },
782 "paths": [serde_json::to_value(&path).unwrap()],
783 });
784
785 let schema: serde_json::Value = serde_json::from_str(toolpath::SCHEMA_JSON).unwrap();
786 let validator = jsonschema::validator_for(&schema).unwrap();
787 let errors: Vec<String> = validator
788 .iter_errors(&graph)
789 .map(|e| format!("at {}: {e}", e.instance_path()))
790 .collect();
791 assert!(
792 errors.is_empty(),
793 "base-schema violations:\n{}",
794 errors.join("\n")
795 );
796 }
797
798 #[test]
799 fn derived_path_conforms_to_agent_coding_session_kind() {
800 let mut user = base_turn("t1", Role::User);
805 user.text = "implement the feature".into();
806
807 let mut assistant = base_turn("t2", Role::Assistant);
808 assistant.parent_id = Some("t1".into());
809 assistant.model = Some("gpt-5.5".into());
810 assistant.text = "on it".into();
811 assistant.thinking = Some("plan the edit".into());
812 assistant.stop_reason = Some("tool_use".into());
813 assistant.token_usage = Some(TokenUsage {
814 input_tokens: Some(100),
815 output_tokens: Some(20),
816 cache_read_tokens: Some(50),
817 cache_write_tokens: None,
818 });
819 assistant.environment = Some(EnvironmentSnapshot {
820 working_dir: Some("/repo".into()),
821 vcs_branch: Some("main".into()),
822 vcs_revision: None,
823 });
824 assistant.tool_uses = vec![ToolInvocation {
825 id: "call-1".into(),
826 name: "write_file".into(),
827 input: serde_json::json!({ "file_path": "a.rs", "content": "fn main() {}" }),
828 result: Some(ToolResult {
829 content: "ok".into(),
830 is_error: false,
831 }),
832 category: Some(crate::ToolCategory::FileWrite),
833 }];
834 assistant.file_mutations = vec![crate::FileMutation {
835 path: "a.rs".into(),
836 tool_id: Some("call-1".into()),
837 operation: Some("add".into()),
838 raw_diff: Some("@@ -0,0 +1 @@\n+fn main() {}".into()),
839 before: None,
840 after: Some("fn main() {}".into()),
841 rename_to: None,
842 }];
843 assistant.delegations = vec![DelegatedWork {
844 agent_id: "sub-1".into(),
845 prompt: "do the subtask".into(),
846 turns: vec![],
847 result: Some("done".into()),
848 }];
849
850 let mut system = base_turn("t3", Role::System);
851 system.parent_id = Some("t2".into());
852 system.text = "system note".into();
853
854 let mut other = base_turn("t4", Role::Other("tool".into()));
855 other.parent_id = Some("t3".into());
856 other.text = "tool output".into();
857
858 let mut view = view_with(vec![user, assistant, system, other]);
859 view.events.push(crate::ConversationEvent {
860 id: "e1".into(),
861 timestamp: "2026-01-01T00:00:00Z".into(),
862 parent_id: None,
863 event_type: "attachment".into(),
864 data: HashMap::new(),
865 });
866
867 let path = derive_path(&view, &DeriveConfig::default());
868 assert_eq!(
869 path.meta.as_ref().and_then(|m| m.kind.as_deref()),
870 Some(toolpath::v1::PATH_KIND_AGENT_CODING_SESSION),
871 "derive_path must stamp the agent-coding-session kind"
872 );
873
874 let schema_src = std::fs::read_to_string(concat!(
875 env!("CARGO_MANIFEST_DIR"),
876 "/../path-cli/kinds/agent-coding-session/v1.0.0/schema.json"
877 ))
878 .expect("read kind schema");
879 let schema: serde_json::Value = serde_json::from_str(&schema_src).unwrap();
880 let validator = jsonschema::validator_for(&schema).unwrap();
881 let value = serde_json::to_value(&path).unwrap();
882 let errors: Vec<String> = validator
883 .iter_errors(&value)
884 .map(|e| format!("at {}: {e}", e.instance_path()))
885 .collect();
886 assert!(
887 errors.is_empty(),
888 "kind-schema violations:\n{}",
889 errors.join("\n")
890 );
891 }
892
893 fn fw_tool(name: &str, id: &str, input: serde_json::Value) -> ToolInvocation {
894 ToolInvocation {
895 id: id.to_string(),
896 name: name.to_string(),
897 input,
898 result: None,
899 category: Some(ToolCategory::FileWrite),
900 }
901 }
902
903 #[test]
904 fn test_tool_use_filewrite_with_file_path_field() {
905 let mut turn = base_turn("t1", Role::Assistant);
906 turn.tool_uses = vec![fw_tool(
907 "Write",
908 "tu1",
909 serde_json::json!({"file_path": "src/main.rs"}),
910 )];
911 let view = view_with(vec![turn]);
912 let path = derive_path(&view, &DeriveConfig::default());
913 assert!(path.steps[0].change.contains_key("src/main.rs"));
914 let sc = path.steps[0].change["src/main.rs"]
915 .structural
916 .as_ref()
917 .unwrap();
918 assert_eq!(sc.change_type, "file.write");
919 assert_eq!(sc.extra["tool"], serde_json::json!("Write"));
920 assert_eq!(sc.extra["tool_id"], serde_json::json!("tu1"));
921 }
922
923 #[test]
924 fn test_tool_use_filewrite_with_path_field() {
925 let mut turn = base_turn("t1", Role::Assistant);
926 turn.tool_uses = vec![fw_tool("Edit", "tu1", serde_json::json!({"path": "a.rs"}))];
927 let view = view_with(vec![turn]);
928 let path = derive_path(&view, &DeriveConfig::default());
929 assert!(path.steps[0].change.contains_key("a.rs"));
930 }
931
932 #[test]
933 fn test_tool_use_filewrite_with_filename_field() {
934 let mut turn = base_turn("t1", Role::Assistant);
935 turn.tool_uses = vec![fw_tool("W", "tu1", serde_json::json!({"filename": "b.rs"}))];
936 let view = view_with(vec![turn]);
937 let path = derive_path(&view, &DeriveConfig::default());
938 assert!(path.steps[0].change.contains_key("b.rs"));
939 }
940
941 #[test]
942 fn test_tool_use_filewrite_with_file_field() {
943 let mut turn = base_turn("t1", Role::Assistant);
944 turn.tool_uses = vec![fw_tool("W", "tu1", serde_json::json!({"file": "c.rs"}))];
945 let view = view_with(vec![turn]);
946 let path = derive_path(&view, &DeriveConfig::default());
947 assert!(path.steps[0].change.contains_key("c.rs"));
948 }
949
950 #[test]
951 fn test_tool_use_filewrite_no_recognized_field() {
952 let mut turn = base_turn("t1", Role::Assistant);
953 turn.tool_uses = vec![fw_tool("W", "tu1", serde_json::json!({"other": "foo"}))];
954 let view = view_with(vec![turn]);
955 let path = derive_path(&view, &DeriveConfig::default());
956 assert_eq!(path.steps[0].change.len(), 1);
957 let sc = conv_change(&path.steps[0]);
958 assert!(sc.extra.contains_key("tool_uses"));
959 }
960
961 #[test]
962 fn test_tool_use_non_filewrite_ignored() {
963 let mut turn = base_turn("t1", Role::Assistant);
964 turn.tool_uses = vec![ToolInvocation {
965 id: "tu1".into(),
966 name: "Read".into(),
967 input: serde_json::json!({"file_path": "x.rs"}),
968 result: None,
969 category: Some(ToolCategory::FileRead),
970 }];
971 let view = view_with(vec![turn]);
972 let path = derive_path(&view, &DeriveConfig::default());
973 assert!(!path.steps[0].change.contains_key("x.rs"));
974 assert_eq!(path.steps[0].change.len(), 1);
975 }
976
977 #[test]
978 fn test_tool_use_edit_emits_unified_diff() {
979 let mut turn = base_turn("t1", Role::Assistant);
980 turn.tool_uses = vec![fw_tool(
981 "Edit",
982 "tu1",
983 serde_json::json!({
984 "file_path": "src/login.rs",
985 "old_string": "validate_token()",
986 "new_string": "validate_token_v2()",
987 }),
988 )];
989 let view = view_with(vec![turn]);
990 let path = derive_path(&view, &DeriveConfig::default());
991 let ch = &path.steps[0].change["src/login.rs"];
992 let raw = ch.raw.as_deref().expect("edit should emit unified diff");
993 assert!(raw.contains("--- a/src/login.rs"));
994 assert!(raw.contains("+++ b/src/login.rs"));
995 assert!(raw.contains("-validate_token()"));
996 assert!(raw.contains("+validate_token_v2()"));
997 let sc = ch.structural.as_ref().unwrap();
998 assert_eq!(sc.extra["before"], serde_json::json!("validate_token()"));
999 assert_eq!(sc.extra["after"], serde_json::json!("validate_token_v2()"));
1000 }
1001
1002 #[test]
1003 fn test_tool_use_write_emits_full_content_diff() {
1004 let mut turn = base_turn("t1", Role::Assistant);
1005 turn.tool_uses = vec![fw_tool(
1006 "Write",
1007 "tu1",
1008 serde_json::json!({
1009 "file_path": "hello.txt",
1010 "content": "hi\nthere\n",
1011 }),
1012 )];
1013 let view = view_with(vec![turn]);
1014 let path = derive_path(&view, &DeriveConfig::default());
1015 let ch = &path.steps[0].change["hello.txt"];
1016 let raw = ch.raw.as_deref().expect("write should emit diff");
1017 assert!(raw.contains("+hi"));
1018 assert!(raw.contains("+there"));
1019 let sc = ch.structural.as_ref().unwrap();
1020 assert_eq!(sc.extra["after"], serde_json::json!("hi\nthere\n"));
1021 assert!(!sc.extra.contains_key("before"));
1022 }
1023
1024 #[test]
1025 fn test_file_write_diff_write_without_before_state_is_addition_only() {
1026 let input = serde_json::json!({
1028 "file_path": "hello.txt",
1029 "content": "hi\nthere\n",
1030 });
1031 let raw =
1032 file_write_diff("Write", &input, "hello.txt", None).expect("write should emit diff");
1033 assert!(raw.contains("+hi"));
1034 assert!(raw.contains("+there"));
1035 assert!(
1037 !raw.lines()
1038 .any(|l| l.starts_with('-') && !l.starts_with("---"))
1039 );
1040 }
1041
1042 #[test]
1043 fn test_file_write_diff_write_with_before_state_shows_replacement() {
1044 let input = serde_json::json!({
1045 "file_path": "hello.txt",
1046 "content": "hi\nthere\n",
1047 });
1048 let raw = file_write_diff("Write", &input, "hello.txt", Some("bye\nfriend\n"))
1049 .expect("write should emit diff");
1050 assert!(raw.contains("-bye"));
1052 assert!(raw.contains("-friend"));
1053 assert!(raw.contains("+hi"));
1055 assert!(raw.contains("+there"));
1056 }
1057
1058 #[test]
1059 fn test_file_write_diff_before_state_ignored_for_edit_shape() {
1060 let input = serde_json::json!({
1063 "file_path": "a.rs",
1064 "old_string": "foo",
1065 "new_string": "bar",
1066 });
1067 let raw = file_write_diff("Edit", &input, "a.rs", Some("something else entirely"))
1068 .expect("edit should emit diff");
1069 assert!(raw.contains("-foo"));
1070 assert!(raw.contains("+bar"));
1071 assert!(!raw.contains("something else entirely"));
1072 }
1073
1074 #[test]
1075 fn test_unified_diff_strips_leading_slash_on_absolute_path() {
1076 let raw = unified_diff("/abs/path.rs", "a\n", "b\n");
1078 assert!(
1079 raw.contains("--- a/abs/path.rs\n"),
1080 "missing stripped --- header: {raw}"
1081 );
1082 assert!(
1083 raw.contains("+++ b/abs/path.rs\n"),
1084 "missing stripped +++ header: {raw}"
1085 );
1086 assert!(
1087 !raw.contains("a//"),
1088 "header should not contain doubled slash: {raw}"
1089 );
1090 assert!(
1091 !raw.contains("b//"),
1092 "header should not contain doubled slash: {raw}"
1093 );
1094 }
1095
1096 #[test]
1097 fn test_unified_diff_preserves_relative_path() {
1098 let raw = unified_diff("src/login.rs", "a\n", "b\n");
1101 assert!(raw.contains("--- a/src/login.rs\n"), "{raw}");
1102 assert!(raw.contains("+++ b/src/login.rs\n"), "{raw}");
1103 }
1104
1105 #[test]
1106 fn test_tool_use_multiedit_emits_per_hunk_diff() {
1107 let mut turn = base_turn("t1", Role::Assistant);
1108 turn.tool_uses = vec![fw_tool(
1109 "MultiEdit",
1110 "tu1",
1111 serde_json::json!({
1112 "file_path": "m.rs",
1113 "edits": [
1114 {"old_string": "foo", "new_string": "bar"},
1115 {"old_string": "baz", "new_string": "qux"},
1116 ],
1117 }),
1118 )];
1119 let view = view_with(vec![turn]);
1120 let path = derive_path(&view, &DeriveConfig::default());
1121 let ch = &path.steps[0].change["m.rs"];
1122 let raw = ch.raw.as_deref().expect("multiedit should emit diff");
1123 assert!(raw.contains("# edit 1/2"));
1124 assert!(raw.contains("# edit 2/2"));
1125 assert!(raw.contains("-foo"));
1126 assert!(raw.contains("+bar"));
1127 assert!(raw.contains("-baz"));
1128 assert!(raw.contains("+qux"));
1129 }
1130
1131 #[test]
1132 fn test_thinking_included_when_enabled() {
1133 let mut turn = base_turn("t1", Role::Assistant);
1134 turn.thinking = Some("hmm".into());
1135 let view = view_with(vec![turn]);
1136 let path = derive_path(&view, &DeriveConfig::default());
1137 let sc = conv_change(&path.steps[0]);
1138 assert_eq!(sc.extra["thinking"], serde_json::json!("hmm"));
1139 }
1140
1141 #[test]
1142 fn test_thinking_omitted_when_disabled() {
1143 let mut turn = base_turn("t1", Role::Assistant);
1144 turn.thinking = Some("hmm".into());
1145 let view = view_with(vec![turn]);
1146 let cfg = DeriveConfig {
1147 include_thinking: false,
1148 ..Default::default()
1149 };
1150 let path = derive_path(&view, &cfg);
1151 let sc = conv_change(&path.steps[0]);
1152 assert!(!sc.extra.contains_key("thinking"));
1153 }
1154
1155 #[test]
1156 fn test_tool_uses_included_when_enabled() {
1157 let mut turn = base_turn("t1", Role::Assistant);
1158 turn.tool_uses = vec![ToolInvocation {
1159 id: "tu1".into(),
1160 name: "Read".into(),
1161 input: serde_json::json!({}),
1162 result: Some(ToolResult {
1163 content: "x".into(),
1164 is_error: false,
1165 }),
1166 category: Some(ToolCategory::FileRead),
1167 }];
1168 let view = view_with(vec![turn]);
1169 let path = derive_path(&view, &DeriveConfig::default());
1170 let sc = conv_change(&path.steps[0]);
1171 assert!(sc.extra.contains_key("tool_uses"));
1172 }
1173
1174 #[test]
1175 fn test_tool_uses_omitted_when_disabled() {
1176 let mut turn = base_turn("t1", Role::Assistant);
1177 turn.tool_uses = vec![ToolInvocation {
1178 id: "tu1".into(),
1179 name: "Read".into(),
1180 input: serde_json::json!({}),
1181 result: None,
1182 category: Some(ToolCategory::FileRead),
1183 }];
1184 let view = view_with(vec![turn]);
1185 let cfg = DeriveConfig {
1186 include_tool_uses: false,
1187 ..Default::default()
1188 };
1189 let path = derive_path(&view, &cfg);
1190 let sc = conv_change(&path.steps[0]);
1191 assert!(!sc.extra.contains_key("tool_uses"));
1192 }
1193
1194 #[test]
1195 fn test_base_uri_from_working_dir() {
1196 let mut turn = base_turn("t1", Role::User);
1197 turn.environment = Some(EnvironmentSnapshot {
1198 working_dir: Some("/Users/alex/proj".into()),
1199 ..Default::default()
1200 });
1201 let view = view_with(vec![turn]);
1202 let path = derive_path(&view, &DeriveConfig::default());
1203 assert_eq!(path.path.base.unwrap().uri, "file:///Users/alex/proj");
1204 }
1205
1206 #[test]
1207 fn test_base_uri_from_config_override() {
1208 let mut turn = base_turn("t1", Role::User);
1209 turn.environment = Some(EnvironmentSnapshot {
1210 working_dir: Some("/Users/alex/proj".into()),
1211 ..Default::default()
1212 });
1213 let view = view_with(vec![turn]);
1214 let cfg = DeriveConfig {
1215 base_uri: Some("github:org/repo".into()),
1216 ..Default::default()
1217 };
1218 let path = derive_path(&view, &cfg);
1219 assert_eq!(path.path.base.unwrap().uri, "github:org/repo");
1220 }
1221
1222 #[test]
1223 fn test_base_uri_absent_when_no_source() {
1224 let turn = base_turn("t1", Role::User);
1225 let view = view_with(vec![turn]);
1226 let path = derive_path(&view, &DeriveConfig::default());
1227 assert!(path.path.base.is_none());
1228 }
1229
1230 #[test]
1231 fn test_path_id_from_config_override() {
1232 let view = view_with(vec![]);
1233 let cfg = DeriveConfig {
1234 path_id: Some("my-custom-id".into()),
1235 ..Default::default()
1236 };
1237 let path = derive_path(&view, &cfg);
1238 assert_eq!(path.path.id, "my-custom-id");
1239 }
1240
1241 #[test]
1242 fn test_path_id_default_format() {
1243 let view = view_with(vec![]);
1244 let path = derive_path(&view, &DeriveConfig::default());
1245 assert_eq!(path.path.id, "path-pi-abcdef01");
1246 }
1247
1248 #[test]
1249 fn test_files_changed_in_meta() {
1250 let mut view = view_with(vec![]);
1251 view.files_changed = vec!["a.rs".into(), "b.rs".into()];
1252 let path = derive_path(&view, &DeriveConfig::default());
1253 let meta = path.meta.unwrap();
1254 assert_eq!(
1255 meta.extra["files_changed"],
1256 serde_json::json!(["a.rs", "b.rs"])
1257 );
1258 }
1259
1260 #[test]
1261 fn test_actors_in_meta() {
1262 let u = base_turn("t1", Role::User);
1263 let mut a = base_turn("t2", Role::Assistant);
1264 a.model = Some("claude-opus-4-7".into());
1265 let view = view_with(vec![u, a]);
1266 let path = derive_path(&view, &DeriveConfig::default());
1267 let actors = path.meta.unwrap().actors.unwrap();
1268 assert!(actors.contains_key("human:user"));
1269 assert!(actors.contains_key("agent:claude-opus-4-7"));
1270 let agent = &actors["agent:claude-opus-4-7"];
1271 assert_eq!(agent.provider.as_deref(), Some("pi"));
1272 assert_eq!(agent.model.as_deref(), Some("claude-opus-4-7"));
1273 let human = &actors["human:user"];
1274 assert_eq!(human.name.as_deref(), Some("user"));
1275 }
1276
1277 #[test]
1278 fn test_head_is_last_step_id() {
1279 let turns = vec![
1280 base_turn("t1", Role::User),
1281 base_turn("t2", Role::User),
1282 base_turn("t3", Role::User),
1283 ];
1284 let view = view_with(turns);
1285 let path = derive_path(&view, &DeriveConfig::default());
1286 assert_eq!(path.path.head, "t3");
1287 }
1288
1289 #[test]
1290 fn test_token_usage_in_extras() {
1291 let mut turn = base_turn("t1", Role::Assistant);
1292 turn.token_usage = Some(TokenUsage {
1293 input_tokens: Some(100),
1294 output_tokens: Some(50),
1295 cache_read_tokens: None,
1296 cache_write_tokens: None,
1297 });
1298 let view = view_with(vec![turn]);
1299 let path = derive_path(&view, &DeriveConfig::default());
1300 let sc = conv_change(&path.steps[0]);
1301 assert!(sc.extra.contains_key("token_usage"));
1302 assert_eq!(
1303 sc.extra["token_usage"]["input_tokens"],
1304 serde_json::json!(100)
1305 );
1306 }
1307
1308 #[test]
1309 fn test_delegations_in_extras() {
1310 let mut turn = base_turn("t1", Role::Assistant);
1311 turn.delegations = vec![DelegatedWork {
1312 agent_id: "sub-1".into(),
1313 prompt: "do a thing".into(),
1314 turns: vec![],
1315 result: None,
1316 }];
1317 let view = view_with(vec![turn]);
1318 let path = derive_path(&view, &DeriveConfig::default());
1319 let sc = conv_change(&path.steps[0]);
1320 assert!(sc.extra.contains_key("delegations"));
1321 assert_eq!(
1322 sc.extra["delegations"][0]["agent_id"],
1323 serde_json::json!("sub-1")
1324 );
1325 }
1326
1327 #[test]
1328 fn test_title_from_config() {
1329 let view = view_with(vec![]);
1330 let cfg = DeriveConfig {
1331 title: Some("My Session".into()),
1332 ..Default::default()
1333 };
1334 let path = derive_path(&view, &cfg);
1335 assert_eq!(path.meta.unwrap().title.as_deref(), Some("My Session"));
1336 }
1337
1338 #[test]
1339 fn test_title_default_when_unset() {
1340 let view = view_with(vec![]);
1341 let path = derive_path(&view, &DeriveConfig::default());
1342 assert_eq!(
1343 path.meta.unwrap().title.as_deref(),
1344 Some("pi session: abcdef01")
1345 );
1346 }
1347
1348 #[test]
1349 fn test_serde_roundtrip() {
1350 let mut t1 = base_turn("t1", Role::User);
1351 t1.text = "hello".into();
1352 t1.environment = Some(EnvironmentSnapshot {
1353 working_dir: Some("/proj".into()),
1354 ..Default::default()
1355 });
1356 let mut t2 = base_turn("t2", Role::Assistant);
1357 t2.parent_id = Some("t1".into());
1358 t2.model = Some("m".into());
1359 t2.tool_uses = vec![fw_tool(
1360 "Write",
1361 "tu1",
1362 serde_json::json!({"file_path": "x.rs"}),
1363 )];
1364
1365 let mut view = view_with(vec![t1, t2]);
1366 view.files_changed = vec!["x.rs".into()];
1367
1368 let path = derive_path(&view, &DeriveConfig::default());
1369 let json = serde_json::to_string(&path).unwrap();
1370 let back: Path = serde_json::from_str(&json).unwrap();
1371 assert_eq!(back.path.id, path.path.id);
1372 assert_eq!(back.path.head, path.path.head);
1373 assert_eq!(back.steps.len(), 2);
1374 assert_eq!(back.steps[1].step.parents, vec!["t1".to_string()]);
1375 assert!(back.steps[1].change.contains_key("x.rs"));
1376 }
1377}