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 by_id: HashMap<String, usize> = HashMap::new();
108 let mut turn_to_step: HashMap<String, String> = HashMap::new();
109 let mut actors: HashMap<String, ActorDefinition> = HashMap::new();
110
111 for (idx, turn) in view.turns.iter().enumerate() {
112 let step_id = if turn.id.is_empty() {
115 format!("step-{:04}", idx + 1)
116 } else {
117 turn.id.clone()
118 };
119
120 let actor = actor_for_turn(turn, provider);
121 record_actor(&mut actors, &actor, turn, provider, view);
122
123 let mut step = Step {
124 step: StepIdentity {
125 id: step_id,
126 parents: Vec::new(),
127 actor,
128 timestamp: turn.timestamp.clone(),
129 },
130 change: HashMap::new(),
131 meta: None,
132 };
133
134 if let Some(parent_id) = &turn.parent_id
136 && let Some(parent_step_id) = turn_to_step.get(parent_id)
137 {
138 step.step.parents.push(parent_step_id.clone());
139 }
140
141 let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
143 extra.insert(
144 "role".to_string(),
145 serde_json::Value::String(turn.role.to_string()),
146 );
147 extra.insert(
148 "text".to_string(),
149 serde_json::Value::String(turn.text.clone()),
150 );
151
152 if config.include_thinking
153 && let Some(thinking) = &turn.thinking
154 {
155 extra.insert(
156 "thinking".to_string(),
157 serde_json::Value::String(thinking.clone()),
158 );
159 }
160
161 if config.include_tool_uses && !turn.tool_uses.is_empty() {
162 let arr: Vec<serde_json::Value> = turn
163 .tool_uses
164 .iter()
165 .map(|t| {
166 let mut obj = serde_json::json!({
167 "id": t.id,
168 "name": t.name,
169 "input": t.input,
170 "category": t.category,
171 });
172 if let Some(result) = &t.result
173 && let Ok(v) = serde_json::to_value(result)
174 {
175 obj.as_object_mut().unwrap().insert("result".to_string(), v);
176 }
177 obj
178 })
179 .collect();
180 extra.insert("tool_uses".to_string(), serde_json::Value::Array(arr));
181 }
182
183 let last_of_message = match &turn.group_id {
189 None => true,
190 Some(mid) => view
191 .turns
192 .get(idx + 1)
193 .is_none_or(|next| next.group_id.as_ref() != Some(mid)),
194 };
195 if last_of_message
196 && let Some(usage) = &turn.token_usage
197 && let Ok(v) = serde_json::to_value(usage)
198 {
199 extra.insert("token_usage".to_string(), v);
200 }
201
202 if let Some(attr) = &turn.attributed_token_usage
207 && let Ok(v) = serde_json::to_value(attr)
208 {
209 extra.insert("attributed_token_usage".to_string(), v);
210 }
211
212 if let Some(mid) = &turn.group_id {
213 extra.insert(
214 "group_id".to_string(),
215 serde_json::Value::String(mid.clone()),
216 );
217 }
218
219 if !turn.delegations.is_empty()
220 && let Ok(v) = serde_json::to_value(&turn.delegations)
221 {
222 extra.insert("delegations".to_string(), v);
223 }
224
225 if let Some(stop_reason) = &turn.stop_reason {
226 extra.insert(
227 "stop_reason".to_string(),
228 serde_json::Value::String(stop_reason.clone()),
229 );
230 }
231
232 if let Some(env) = &turn.environment
233 && let Ok(v) = serde_json::to_value(env)
234 {
235 extra.insert("environment".to_string(), v);
236 }
237
238 step.change.insert(
239 conv_artifact_key.clone(),
240 ArtifactChange {
241 raw: None,
242 structural: Some(StructuralChange {
243 change_type: "conversation.append".to_string(),
244 extra,
245 }),
246 },
247 );
248
249 let attributed: std::collections::HashSet<String> = turn
261 .file_mutations
262 .iter()
263 .filter_map(|fm| fm.tool_id.clone())
264 .collect();
265 for fm in &turn.file_mutations {
266 let mut t_extra: HashMap<String, serde_json::Value> = HashMap::new();
267 if let Some(tid) = &fm.tool_id {
268 t_extra.insert(
269 "tool_id".to_string(),
270 serde_json::Value::String(tid.clone()),
271 );
272 if let Some(tool) = turn.tool_uses.iter().find(|t| &t.id == tid) {
273 t_extra.insert(
274 "tool".to_string(),
275 serde_json::Value::String(tool.name.clone()),
276 );
277 }
278 }
279 if let Some(op) = &fm.operation {
280 t_extra.insert(
281 "operation".to_string(),
282 serde_json::Value::String(op.clone()),
283 );
284 }
285 if let Some(b) = &fm.before {
286 t_extra.insert("before".to_string(), serde_json::Value::String(b.clone()));
287 }
288 if let Some(a) = &fm.after {
289 t_extra.insert("after".to_string(), serde_json::Value::String(a.clone()));
290 }
291 if let Some(rt) = &fm.rename_to {
292 t_extra.insert(
293 "rename_to".to_string(),
294 serde_json::Value::String(rt.clone()),
295 );
296 }
297 step.change.insert(
298 fm.path.clone(),
299 ArtifactChange {
300 raw: fm.raw_diff.clone(),
301 structural: Some(StructuralChange {
302 change_type: "file.write".to_string(),
303 extra: t_extra,
304 }),
305 },
306 );
307 }
308 for tool in &turn.tool_uses {
309 if tool.category != Some(ToolCategory::FileWrite) || attributed.contains(&tool.id) {
310 continue;
311 }
312 let Some(path) = extract_file_path(tool) else {
313 continue;
314 };
315 let (raw, mut t_extra) = file_write_change(tool, &path, None);
316 t_extra.insert(
317 "tool".to_string(),
318 serde_json::Value::String(tool.name.clone()),
319 );
320 t_extra.insert(
321 "tool_id".to_string(),
322 serde_json::Value::String(tool.id.clone()),
323 );
324 step.change.insert(
325 path,
326 ArtifactChange {
327 raw,
328 structural: Some(StructuralChange {
329 change_type: "file.write".to_string(),
330 extra: t_extra,
331 }),
332 },
333 );
334 }
335
336 let final_id = push_step_and_dedup(&mut steps, &mut by_id, step);
337 turn_to_step.insert(turn.id.clone(), final_id);
338 }
339
340 let mut last_step_id: Option<String> = steps.last().map(|s| s.step.id.clone());
348 for (idx, event) in view.events.iter().enumerate() {
349 let step_id = if event.id.is_empty() {
351 format!("event-{:04}", idx + 1)
352 } else {
353 event.id.clone()
354 };
355 let actor = format!("tool:{}", provider);
356 actors
357 .entry(actor.clone())
358 .or_insert_with(|| ActorDefinition {
359 name: Some(provider.to_string()),
360 provider: Some(provider.to_string()),
361 ..Default::default()
362 });
363
364 let mut extra: HashMap<String, serde_json::Value> = event
371 .data
372 .iter()
373 .filter(|(k, _)| k.as_str() != "type")
374 .map(|(k, v)| (k.clone(), v.clone()))
375 .collect();
376 if let Some(t) = event.data.get("type") {
379 extra.insert("event_data_type".to_string(), t.clone());
380 }
381 extra.insert(
382 "entry_type".to_string(),
383 serde_json::Value::String(event.event_type.clone()),
384 );
385 if !event.id.is_empty() {
386 extra.insert(
387 "event_source_id".to_string(),
388 serde_json::Value::String(event.id.clone()),
389 );
390 }
391
392 let parents: Vec<String> = event
393 .parent_id
394 .as_ref()
395 .and_then(|pid| turn_to_step.get(pid).cloned())
396 .or_else(|| last_step_id.clone())
397 .into_iter()
398 .collect();
399
400 let mut step = Step {
401 step: StepIdentity {
402 id: step_id.clone(),
403 parents,
404 actor,
405 timestamp: event.timestamp.clone(),
406 },
407 change: HashMap::new(),
408 meta: None,
409 };
410
411 step.change.insert(
412 conv_artifact_key.clone(),
413 ArtifactChange {
414 raw: None,
415 structural: Some(StructuralChange {
416 change_type: "conversation.event".to_string(),
417 extra,
418 }),
419 },
420 );
421 last_step_id = Some(push_step_and_dedup(&mut steps, &mut by_id, step));
422 }
423
424 let head = steps.last().map(|s| s.step.id.clone()).unwrap_or_default();
425
426 let title = config
428 .title
429 .clone()
430 .unwrap_or_else(|| format!("{} session: {}", provider, id_prefix));
431
432 let mut meta = PathMeta {
433 title: Some(title),
434 kind: Some(PATH_KIND_AGENT_CODING_SESSION.to_string()),
435 source: view.provider_id.clone(),
436 ..Default::default()
437 };
438
439 if !actors.is_empty() {
440 meta.actors = Some(actors);
441 }
442
443 if !view.files_changed.is_empty()
444 && let Ok(v) = serde_json::to_value(&view.files_changed)
445 {
446 meta.extra.insert("files_changed".to_string(), v);
447 }
448
449 if let Some(remote) = view.base.as_ref().and_then(|b| b.vcs_remote.as_ref())
451 && !meta.extra.contains_key("vcs_remote")
452 {
453 meta.extra.insert(
454 "vcs_remote".to_string(),
455 serde_json::Value::String(remote.clone()),
456 );
457 }
458
459 if let Some(producer) = &view.producer
461 && let Ok(v) = serde_json::to_value(producer)
462 {
463 meta.extra.insert("producer".to_string(), v);
464 }
465
466 Path {
467 path: PathIdentity {
468 id: path_id,
469 base,
470 head,
471 graph_ref: None,
472 },
473 steps,
474 meta: Some(meta),
475 }
476}
477
478fn push_step_and_dedup(
487 steps: &mut Vec<Step>,
488 by_id: &mut HashMap<String, usize>,
489 mut step: Step,
490) -> String {
491 let id = step.step.id.clone();
492 let Some(&existing) = by_id.get(&id) else {
493 by_id.insert(id.clone(), steps.len());
494 steps.push(step);
495 return id;
496 };
497 if serde_value_eq(&steps[existing], &step) {
498 return id;
499 }
500 let mut n = 2u32;
501 let mut renamed = format!("{id}#{n}");
502 while by_id.contains_key(&renamed) {
503 n += 1;
504 renamed = format!("{id}#{n}");
505 }
506 step.step.id = renamed.clone();
507 by_id.insert(renamed.clone(), steps.len());
508 steps.push(step);
509 renamed
510}
511
512fn serde_value_eq(a: &Step, b: &Step) -> bool {
516 serde_json::to_value(a).ok() == serde_json::to_value(b).ok()
517}
518
519fn actor_for_turn(turn: &Turn, provider: &str) -> String {
520 match &turn.role {
521 Role::User => "human:user".to_string(),
522 Role::Assistant => {
523 let model = turn.model.as_deref().unwrap_or("unknown");
524 format!("agent:{}", model)
525 }
526 Role::System => format!("tool:{}", provider),
527 Role::Other(_) => format!("tool:{}", provider),
528 }
529}
530
531fn record_actor(
532 actors: &mut HashMap<String, ActorDefinition>,
533 actor: &str,
534 turn: &Turn,
535 provider: &str,
536 _view: &ConversationView,
537) {
538 if actors.contains_key(actor) {
539 return;
540 }
541 let def = if let Some(rest) = actor.strip_prefix("agent:") {
542 ActorDefinition {
543 name: Some(rest.to_string()),
544 provider: Some(provider.to_string()),
545 model: turn.model.clone(),
546 identities: vec![],
547 keys: vec![],
548 }
549 } else if let Some(rest) = actor.strip_prefix("human:") {
550 ActorDefinition {
551 name: Some(rest.to_string()),
552 ..Default::default()
553 }
554 } else {
555 let name = actor.split_once(':').map(|x| x.1).unwrap_or("").to_string();
556 ActorDefinition {
557 name: Some(name),
558 provider: Some(provider.to_string()),
559 ..Default::default()
560 }
561 };
562 actors.insert(actor.to_string(), def);
563}
564
565fn extract_file_path(tool: &ToolInvocation) -> Option<String> {
566 for field in &["file_path", "path", "filename", "file"] {
567 if let Some(v) = tool.input.get(*field)
568 && let Some(s) = v.as_str()
569 {
570 return Some(s.to_string());
571 }
572 }
573 None
574}
575
576fn file_write_change(
586 tool: &ToolInvocation,
587 path: &str,
588 before_state: Option<&str>,
589) -> (Option<String>, HashMap<String, serde_json::Value>) {
590 let input = &tool.input;
591 let str_field = |k: &str| input.get(k).and_then(|v| v.as_str()).map(str::to_string);
592
593 let mut extra: HashMap<String, serde_json::Value> = HashMap::new();
594
595 if let (Some(old), Some(new)) = (str_field("old_string"), str_field("new_string")) {
596 extra.insert("before".to_string(), serde_json::Value::String(old.clone()));
597 extra.insert("after".to_string(), serde_json::Value::String(new.clone()));
598 } else if let Some(content) = str_field("content") {
599 if let Some(before) = before_state {
600 extra.insert(
601 "before".to_string(),
602 serde_json::Value::String(before.to_string()),
603 );
604 }
605 extra.insert("after".to_string(), serde_json::Value::String(content));
606 } else if let Some(edits) = input.get("edits").and_then(|v| v.as_array()) {
607 extra.insert("edits".to_string(), serde_json::Value::Array(edits.clone()));
608 }
609
610 (
611 file_write_diff(&tool.name, input, path, before_state),
612 extra,
613 )
614}
615
616pub fn file_write_diff(
644 tool_name: &str,
645 input: &serde_json::Value,
646 path: &str,
647 before_state: Option<&str>,
648) -> Option<String> {
649 let str_field = |k: &str| input.get(k).and_then(|v| v.as_str());
650
651 if let (Some(old), Some(new)) = (str_field("old_string"), str_field("new_string")) {
653 return Some(unified_diff(path, old, new));
654 }
655
656 if let Some(content) = str_field("content") {
659 let before = before_state.unwrap_or("");
660 return Some(unified_diff(path, before, content));
661 }
662
663 if let Some(edits) = input.get("edits").and_then(|v| v.as_array()) {
665 if edits.is_empty() {
666 return None;
667 }
668 let mut parts: Vec<String> = Vec::new();
669 for (idx, edit) in edits.iter().enumerate() {
670 let old = edit
671 .get("old_string")
672 .and_then(|v| v.as_str())
673 .unwrap_or("");
674 let new = edit
675 .get("new_string")
676 .and_then(|v| v.as_str())
677 .unwrap_or("");
678 let header = format!("# edit {}/{}", idx + 1, edits.len());
679 parts.push(format!("{header}\n{}", unified_diff(path, old, new)));
680 }
681 return Some(parts.join("\n"));
682 }
683
684 let _ = tool_name;
687 None
688}
689
690pub fn unified_diff(path: &str, before: &str, after: &str) -> String {
700 use similar::TextDiff;
701 let diff = TextDiff::from_lines(before, after);
702 let display = path.trim_start_matches('/');
703 let mut out = String::new();
704 out.push_str(&format!("--- a/{display}\n+++ b/{display}\n"));
705 out.push_str(
706 &diff
707 .unified_diff()
708 .context_radius(3)
709 .header("", "")
710 .to_string(),
711 );
712 out
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718 use crate::{DelegatedWork, EnvironmentSnapshot, TokenUsage, ToolInvocation, ToolResult};
719
720 fn base_turn(id: &str, role: Role) -> Turn {
721 Turn {
722 id: id.to_string(),
723 parent_id: None,
724 group_id: None,
725 role,
726 timestamp: "2026-01-01T00:00:00Z".to_string(),
727 text: String::new(),
728 thinking: None,
729 tool_uses: vec![],
730 model: None,
731 stop_reason: None,
732 token_usage: None,
733 attributed_token_usage: None,
734 environment: None,
735 delegations: vec![],
736 file_mutations: Vec::new(),
737 }
738 }
739
740 fn view_with(turns: Vec<Turn>) -> ConversationView {
741 ConversationView {
742 id: "abcdef012345".to_string(),
743 turns,
744 provider_id: Some("pi".to_string()),
745 ..Default::default()
746 }
747 }
748
749 fn conv_change(step: &Step) -> &StructuralChange {
750 let key = step
751 .change
752 .keys()
753 .find(|k| k.contains("://"))
754 .expect("conversation artifact key present");
755 step.change[key].structural.as_ref().unwrap()
756 }
757
758 #[test]
759 fn test_duplicate_id_identical_content_is_dropped() {
760 let mut first = base_turn("dup", Role::User);
762 first.text = "same".into();
763 let mid = base_turn("mid", Role::Assistant);
764 let mut second = base_turn("dup", Role::User);
765 second.text = "same".into();
766 let view = view_with(vec![first, mid, second]);
767
768 let path = derive_path(&view, &DeriveConfig::default());
769 let ids: Vec<&str> = path.steps.iter().map(|s| s.step.id.as_str()).collect();
770 assert_eq!(ids, vec!["dup", "mid"], "identical re-emission is dropped");
771 }
772
773 #[test]
774 fn test_duplicate_id_different_content_is_renamed() {
775 let mut first = base_turn("dup", Role::User);
778 first.text = "original".into();
779 let mid = base_turn("mid", Role::Assistant);
780 let mut second = base_turn("dup", Role::User);
781 second.text = "replayed".into();
782 let view = view_with(vec![first, mid, second]);
783
784 let path = derive_path(&view, &DeriveConfig::default());
785 let ids: Vec<&str> = path.steps.iter().map(|s| s.step.id.as_str()).collect();
786 assert_eq!(ids, vec!["dup", "mid", "dup#2"]);
787 assert_eq!(
788 conv_change(&path.steps[0]).extra["text"],
789 serde_json::json!("original")
790 );
791 assert_eq!(
792 conv_change(&path.steps[2]).extra["text"],
793 serde_json::json!("replayed")
794 );
795 }
796
797 #[test]
798 fn test_renamed_duplicate_keeps_parent_references_correct() {
799 let mut first = base_turn("dup", Role::User);
804 first.text = "original".into();
805 let mut second = base_turn("dup", Role::User); second.text = "replayed".into();
807 let mut child = base_turn("child", Role::Assistant);
808 child.parent_id = Some("dup".into());
809 let view = view_with(vec![first, second, child]);
810
811 let path = derive_path(&view, &DeriveConfig::default());
812 let ids: Vec<&str> = path.steps.iter().map(|s| s.step.id.as_str()).collect();
813 assert_eq!(ids, vec!["dup", "dup#2", "child"]);
814 assert_eq!(
815 path.steps[2].step.parents,
816 vec!["dup#2".to_string()],
817 "child parents on the renamed later duplicate, not the first `dup`"
818 );
819 }
820
821 #[test]
822 fn test_duplicate_event_ids_are_resolved_to_unique_ids() {
823 let a = base_turn("t1", Role::User);
827 let mut view = view_with(vec![a]);
828 for v in ["v1", "v2"] {
829 view.events.push(crate::ConversationEvent {
830 id: "evt".into(), timestamp: "2026-01-01T00:00:00Z".into(),
832 parent_id: None,
833 event_type: "attachment".into(),
834 data: std::collections::HashMap::from([("k".to_string(), serde_json::json!(v))]),
835 });
836 }
837
838 let path = derive_path(&view, &DeriveConfig::default());
839 let ids: Vec<&str> = path.steps.iter().map(|s| s.step.id.as_str()).collect();
840 let unique: std::collections::HashSet<&&str> = ids.iter().collect();
841 assert_eq!(unique.len(), ids.len(), "step ids must be unique: {ids:?}");
842 assert!(
843 ids.contains(&"evt") && ids.contains(&"evt#2"),
844 "both events survive with distinct ids: {ids:?}"
845 );
846 }
847
848 #[test]
849 fn test_empty_view() {
850 let view = view_with(vec![]);
851 let path = derive_path(&view, &DeriveConfig::default());
852 assert!(path.steps.is_empty());
853 assert_eq!(path.path.head, "");
854 }
855
856 #[test]
857 fn test_meta_kind_is_convo() {
858 let view = view_with(vec![base_turn("t1", Role::User)]);
859 let path = derive_path(&view, &DeriveConfig::default());
860 assert_eq!(
861 path.meta.as_ref().unwrap().kind.as_deref(),
862 Some(PATH_KIND_AGENT_CODING_SESSION)
863 );
864 let json = serde_json::to_string(&path).unwrap();
866 assert!(
867 json.contains(r#""kind":"https://toolpath.net/kinds/agent-coding-session/v1.1.0""#)
868 );
869 }
870
871 #[test]
872 fn test_token_usage_breakdowns_round_trip() {
873 use std::collections::BTreeMap;
874 let mut breakdowns = BTreeMap::new();
877 breakdowns.insert(
878 "output".to_string(),
879 BTreeMap::from([("reasoning".to_string(), 450u32)]),
880 );
881 let mut turn = base_turn("t1", Role::Assistant);
882 turn.model = Some("claude-opus-4-7".into());
883 turn.token_usage = Some(TokenUsage {
884 input_tokens: Some(100),
885 output_tokens: Some(900),
886 breakdowns: breakdowns.clone(),
887 ..Default::default()
888 });
889 let view = view_with(vec![turn]);
890
891 let path = derive_path(&view, &DeriveConfig::default());
892 let extracted = crate::extract::extract_conversation(&path);
893
894 let usage = extracted.turns[0]
895 .token_usage
896 .as_ref()
897 .expect("token_usage survives round-trip");
898 assert_eq!(usage.input_tokens, Some(100));
899 assert_eq!(usage.output_tokens, Some(900));
900 assert_eq!(usage.breakdowns, breakdowns);
901 assert_eq!(usage.breakdowns["output"]["reasoning"], 450);
902 }
903
904 #[test]
905 fn test_token_usage_empty_breakdowns_omitted_in_json() {
906 let usage = TokenUsage {
909 input_tokens: Some(10),
910 output_tokens: Some(20),
911 ..Default::default()
912 };
913 let json = serde_json::to_string(&usage).unwrap();
914 assert!(
915 !json.contains("breakdowns"),
916 "empty breakdowns must be omitted, got: {json}"
917 );
918 }
919
920 #[test]
921 fn test_token_usage_absent_breakdowns_defaults_empty() {
922 let usage: TokenUsage =
925 serde_json::from_str(r#"{"input_tokens":10,"output_tokens":20}"#).unwrap();
926 assert!(usage.breakdowns.is_empty());
927 }
928
929 #[test]
930 fn test_single_user_turn() {
931 let mut turn = base_turn("t1", Role::User);
932 turn.text = "hello".into();
933 let view = view_with(vec![turn]);
934 let path = derive_path(&view, &DeriveConfig::default());
935 assert_eq!(path.steps.len(), 1);
936 assert_eq!(path.steps[0].step.actor, "human:user");
937 assert_eq!(path.steps[0].step.id, "t1");
938 }
939
940 #[test]
941 fn test_single_assistant_turn() {
942 let mut turn = base_turn("t1", Role::Assistant);
943 turn.model = Some("claude-opus-4-7".into());
944 let view = view_with(vec![turn]);
945 let path = derive_path(&view, &DeriveConfig::default());
946 assert_eq!(path.steps[0].step.actor, "agent:claude-opus-4-7");
947 }
948
949 #[test]
950 fn test_assistant_without_model() {
951 let turn = base_turn("t1", Role::Assistant);
952 let view = view_with(vec![turn]);
953 let path = derive_path(&view, &DeriveConfig::default());
954 assert_eq!(path.steps[0].step.actor, "agent:unknown");
955 }
956
957 #[test]
958 fn test_system_role() {
959 let turn = base_turn("t1", Role::System);
960 let view = view_with(vec![turn]);
961 let path = derive_path(&view, &DeriveConfig::default());
962 assert_eq!(path.steps[0].step.actor, "tool:pi");
963 }
964
965 #[test]
966 fn test_other_role() {
967 let turn = base_turn("t1", Role::Other("tool".into()));
968 let view = view_with(vec![turn]);
969 let path = derive_path(&view, &DeriveConfig::default());
970 assert_eq!(path.steps[0].step.actor, "tool:pi");
971 }
972
973 #[test]
974 fn test_parent_id_preserved() {
975 let t1 = base_turn("t1", Role::User);
976 let mut t2 = base_turn("t2", Role::Assistant);
977 t2.parent_id = Some("t1".into());
978 t2.model = Some("m".into());
979 let view = view_with(vec![t1, t2]);
980 let path = derive_path(&view, &DeriveConfig::default());
981 assert_eq!(path.steps[1].step.parents, vec!["t1".to_string()]);
982 }
983
984 #[test]
985 fn derived_path_validates_against_base_schema() {
986 let user = base_turn("t1", Role::User);
987 let mut assistant = base_turn("t2", Role::Assistant);
988 assistant.parent_id = Some("t1".into());
989 assistant.model = Some("gpt-5.5".into());
990 let system = base_turn("t3", Role::System);
991 let other = base_turn("t4", Role::Other("bash".into()));
992
993 let mut view = view_with(vec![user, assistant, system, other]);
994 view.events.push(crate::ConversationEvent {
995 id: "e1".into(),
996 timestamp: "2026-01-01T00:00:00Z".into(),
997 parent_id: None,
998 event_type: "attachment".into(),
999 data: HashMap::new(),
1000 });
1001
1002 let path = derive_path(&view, &DeriveConfig::default());
1003 let graph = serde_json::json!({
1004 "graph": { "id": "g1" },
1005 "paths": [serde_json::to_value(&path).unwrap()],
1006 });
1007
1008 let schema: serde_json::Value = serde_json::from_str(toolpath::SCHEMA_JSON).unwrap();
1009 let validator = jsonschema::validator_for(&schema).unwrap();
1010 let errors: Vec<String> = validator
1011 .iter_errors(&graph)
1012 .map(|e| format!("at {}: {e}", e.instance_path()))
1013 .collect();
1014 assert!(
1015 errors.is_empty(),
1016 "base-schema violations:\n{}",
1017 errors.join("\n")
1018 );
1019 }
1020
1021 #[test]
1022 fn derived_path_conforms_to_agent_coding_session_kind() {
1023 let mut user = base_turn("t1", Role::User);
1028 user.text = "implement the feature".into();
1029
1030 let mut assistant = base_turn("t2", Role::Assistant);
1031 assistant.parent_id = Some("t1".into());
1032 assistant.group_id = Some("msg_t2".into());
1033 assistant.model = Some("gpt-5.5".into());
1034 assistant.text = "on it".into();
1035 assistant.thinking = Some("plan the edit".into());
1036 assistant.stop_reason = Some("tool_use".into());
1037 assistant.token_usage = Some(TokenUsage {
1038 input_tokens: Some(100),
1039 output_tokens: Some(20),
1040 cache_read_tokens: Some(50),
1041 cache_write_tokens: None,
1042 ..Default::default()
1043 });
1044 assistant.attributed_token_usage = Some(TokenUsage {
1045 output_tokens: Some(20),
1046 ..Default::default()
1047 });
1048 assistant.environment = Some(EnvironmentSnapshot {
1049 working_dir: Some("/repo".into()),
1050 vcs_branch: Some("main".into()),
1051 vcs_revision: None,
1052 });
1053 assistant.tool_uses = vec![ToolInvocation {
1054 id: "call-1".into(),
1055 name: "write_file".into(),
1056 input: serde_json::json!({ "file_path": "a.rs", "content": "fn main() {}" }),
1057 result: Some(ToolResult {
1058 content: "ok".into(),
1059 is_error: false,
1060 }),
1061 category: Some(crate::ToolCategory::FileWrite),
1062 }];
1063 assistant.file_mutations = vec![crate::FileMutation {
1064 path: "a.rs".into(),
1065 tool_id: Some("call-1".into()),
1066 operation: Some("add".into()),
1067 raw_diff: Some("@@ -0,0 +1 @@\n+fn main() {}".into()),
1068 before: None,
1069 after: Some("fn main() {}".into()),
1070 rename_to: None,
1071 }];
1072 assistant.delegations = vec![DelegatedWork {
1073 agent_id: "sub-1".into(),
1074 prompt: "do the subtask".into(),
1075 turns: vec![],
1076 result: Some("done".into()),
1077 }];
1078
1079 let mut system = base_turn("t3", Role::System);
1080 system.parent_id = Some("t2".into());
1081 system.text = "system note".into();
1082
1083 let mut other = base_turn("t4", Role::Other("tool".into()));
1084 other.parent_id = Some("t3".into());
1085 other.text = "tool output".into();
1086
1087 let mut view = view_with(vec![user, assistant, system, other]);
1088 view.events.push(crate::ConversationEvent {
1089 id: "e1".into(),
1090 timestamp: "2026-01-01T00:00:00Z".into(),
1091 parent_id: None,
1092 event_type: "attachment".into(),
1093 data: HashMap::new(),
1094 });
1095
1096 let path = derive_path(&view, &DeriveConfig::default());
1097 assert_eq!(
1098 path.meta.as_ref().and_then(|m| m.kind.as_deref()),
1099 Some(toolpath::v1::PATH_KIND_AGENT_CODING_SESSION),
1100 "derive_path must stamp the agent-coding-session kind"
1101 );
1102
1103 let schema_src = std::fs::read_to_string(concat!(
1104 env!("CARGO_MANIFEST_DIR"),
1105 "/../path-cli/kinds/agent-coding-session/v1.1.0/schema.json"
1106 ))
1107 .expect("read kind schema");
1108 let schema: serde_json::Value = serde_json::from_str(&schema_src).unwrap();
1109 let validator = jsonschema::validator_for(&schema).unwrap();
1110 let value = serde_json::to_value(&path).unwrap();
1111 let errors: Vec<String> = validator
1112 .iter_errors(&value)
1113 .map(|e| format!("at {}: {e}", e.instance_path()))
1114 .collect();
1115 assert!(
1116 errors.is_empty(),
1117 "kind-schema violations:\n{}",
1118 errors.join("\n")
1119 );
1120 }
1121
1122 fn fw_tool(name: &str, id: &str, input: serde_json::Value) -> ToolInvocation {
1123 ToolInvocation {
1124 id: id.to_string(),
1125 name: name.to_string(),
1126 input,
1127 result: None,
1128 category: Some(ToolCategory::FileWrite),
1129 }
1130 }
1131
1132 #[test]
1133 fn test_tool_use_filewrite_with_file_path_field() {
1134 let mut turn = base_turn("t1", Role::Assistant);
1135 turn.tool_uses = vec![fw_tool(
1136 "Write",
1137 "tu1",
1138 serde_json::json!({"file_path": "src/main.rs"}),
1139 )];
1140 let view = view_with(vec![turn]);
1141 let path = derive_path(&view, &DeriveConfig::default());
1142 assert!(path.steps[0].change.contains_key("src/main.rs"));
1143 let sc = path.steps[0].change["src/main.rs"]
1144 .structural
1145 .as_ref()
1146 .unwrap();
1147 assert_eq!(sc.change_type, "file.write");
1148 assert_eq!(sc.extra["tool"], serde_json::json!("Write"));
1149 assert_eq!(sc.extra["tool_id"], serde_json::json!("tu1"));
1150 }
1151
1152 #[test]
1153 fn test_tool_use_filewrite_with_path_field() {
1154 let mut turn = base_turn("t1", Role::Assistant);
1155 turn.tool_uses = vec![fw_tool("Edit", "tu1", serde_json::json!({"path": "a.rs"}))];
1156 let view = view_with(vec![turn]);
1157 let path = derive_path(&view, &DeriveConfig::default());
1158 assert!(path.steps[0].change.contains_key("a.rs"));
1159 }
1160
1161 #[test]
1162 fn test_tool_use_filewrite_with_filename_field() {
1163 let mut turn = base_turn("t1", Role::Assistant);
1164 turn.tool_uses = vec![fw_tool("W", "tu1", serde_json::json!({"filename": "b.rs"}))];
1165 let view = view_with(vec![turn]);
1166 let path = derive_path(&view, &DeriveConfig::default());
1167 assert!(path.steps[0].change.contains_key("b.rs"));
1168 }
1169
1170 #[test]
1171 fn test_tool_use_filewrite_with_file_field() {
1172 let mut turn = base_turn("t1", Role::Assistant);
1173 turn.tool_uses = vec![fw_tool("W", "tu1", serde_json::json!({"file": "c.rs"}))];
1174 let view = view_with(vec![turn]);
1175 let path = derive_path(&view, &DeriveConfig::default());
1176 assert!(path.steps[0].change.contains_key("c.rs"));
1177 }
1178
1179 #[test]
1180 fn test_tool_use_filewrite_no_recognized_field() {
1181 let mut turn = base_turn("t1", Role::Assistant);
1182 turn.tool_uses = vec![fw_tool("W", "tu1", serde_json::json!({"other": "foo"}))];
1183 let view = view_with(vec![turn]);
1184 let path = derive_path(&view, &DeriveConfig::default());
1185 assert_eq!(path.steps[0].change.len(), 1);
1186 let sc = conv_change(&path.steps[0]);
1187 assert!(sc.extra.contains_key("tool_uses"));
1188 }
1189
1190 #[test]
1191 fn test_tool_use_non_filewrite_ignored() {
1192 let mut turn = base_turn("t1", Role::Assistant);
1193 turn.tool_uses = vec![ToolInvocation {
1194 id: "tu1".into(),
1195 name: "Read".into(),
1196 input: serde_json::json!({"file_path": "x.rs"}),
1197 result: None,
1198 category: Some(ToolCategory::FileRead),
1199 }];
1200 let view = view_with(vec![turn]);
1201 let path = derive_path(&view, &DeriveConfig::default());
1202 assert!(!path.steps[0].change.contains_key("x.rs"));
1203 assert_eq!(path.steps[0].change.len(), 1);
1204 }
1205
1206 #[test]
1207 fn test_tool_use_edit_emits_unified_diff() {
1208 let mut turn = base_turn("t1", Role::Assistant);
1209 turn.tool_uses = vec![fw_tool(
1210 "Edit",
1211 "tu1",
1212 serde_json::json!({
1213 "file_path": "src/login.rs",
1214 "old_string": "validate_token()",
1215 "new_string": "validate_token_v2()",
1216 }),
1217 )];
1218 let view = view_with(vec![turn]);
1219 let path = derive_path(&view, &DeriveConfig::default());
1220 let ch = &path.steps[0].change["src/login.rs"];
1221 let raw = ch.raw.as_deref().expect("edit should emit unified diff");
1222 assert!(raw.contains("--- a/src/login.rs"));
1223 assert!(raw.contains("+++ b/src/login.rs"));
1224 assert!(raw.contains("-validate_token()"));
1225 assert!(raw.contains("+validate_token_v2()"));
1226 let sc = ch.structural.as_ref().unwrap();
1227 assert_eq!(sc.extra["before"], serde_json::json!("validate_token()"));
1228 assert_eq!(sc.extra["after"], serde_json::json!("validate_token_v2()"));
1229 }
1230
1231 #[test]
1232 fn test_tool_use_write_emits_full_content_diff() {
1233 let mut turn = base_turn("t1", Role::Assistant);
1234 turn.tool_uses = vec![fw_tool(
1235 "Write",
1236 "tu1",
1237 serde_json::json!({
1238 "file_path": "hello.txt",
1239 "content": "hi\nthere\n",
1240 }),
1241 )];
1242 let view = view_with(vec![turn]);
1243 let path = derive_path(&view, &DeriveConfig::default());
1244 let ch = &path.steps[0].change["hello.txt"];
1245 let raw = ch.raw.as_deref().expect("write should emit diff");
1246 assert!(raw.contains("+hi"));
1247 assert!(raw.contains("+there"));
1248 let sc = ch.structural.as_ref().unwrap();
1249 assert_eq!(sc.extra["after"], serde_json::json!("hi\nthere\n"));
1250 assert!(!sc.extra.contains_key("before"));
1251 }
1252
1253 #[test]
1254 fn test_file_write_diff_write_without_before_state_is_addition_only() {
1255 let input = serde_json::json!({
1257 "file_path": "hello.txt",
1258 "content": "hi\nthere\n",
1259 });
1260 let raw =
1261 file_write_diff("Write", &input, "hello.txt", None).expect("write should emit diff");
1262 assert!(raw.contains("+hi"));
1263 assert!(raw.contains("+there"));
1264 assert!(
1266 !raw.lines()
1267 .any(|l| l.starts_with('-') && !l.starts_with("---"))
1268 );
1269 }
1270
1271 #[test]
1272 fn test_file_write_diff_write_with_before_state_shows_replacement() {
1273 let input = serde_json::json!({
1274 "file_path": "hello.txt",
1275 "content": "hi\nthere\n",
1276 });
1277 let raw = file_write_diff("Write", &input, "hello.txt", Some("bye\nfriend\n"))
1278 .expect("write should emit diff");
1279 assert!(raw.contains("-bye"));
1281 assert!(raw.contains("-friend"));
1282 assert!(raw.contains("+hi"));
1284 assert!(raw.contains("+there"));
1285 }
1286
1287 #[test]
1288 fn test_file_write_diff_before_state_ignored_for_edit_shape() {
1289 let input = serde_json::json!({
1292 "file_path": "a.rs",
1293 "old_string": "foo",
1294 "new_string": "bar",
1295 });
1296 let raw = file_write_diff("Edit", &input, "a.rs", Some("something else entirely"))
1297 .expect("edit should emit diff");
1298 assert!(raw.contains("-foo"));
1299 assert!(raw.contains("+bar"));
1300 assert!(!raw.contains("something else entirely"));
1301 }
1302
1303 #[test]
1304 fn test_unified_diff_strips_leading_slash_on_absolute_path() {
1305 let raw = unified_diff("/abs/path.rs", "a\n", "b\n");
1307 assert!(
1308 raw.contains("--- a/abs/path.rs\n"),
1309 "missing stripped --- header: {raw}"
1310 );
1311 assert!(
1312 raw.contains("+++ b/abs/path.rs\n"),
1313 "missing stripped +++ header: {raw}"
1314 );
1315 assert!(
1316 !raw.contains("a//"),
1317 "header should not contain doubled slash: {raw}"
1318 );
1319 assert!(
1320 !raw.contains("b//"),
1321 "header should not contain doubled slash: {raw}"
1322 );
1323 }
1324
1325 #[test]
1326 fn test_unified_diff_preserves_relative_path() {
1327 let raw = unified_diff("src/login.rs", "a\n", "b\n");
1330 assert!(raw.contains("--- a/src/login.rs\n"), "{raw}");
1331 assert!(raw.contains("+++ b/src/login.rs\n"), "{raw}");
1332 }
1333
1334 #[test]
1335 fn test_tool_use_multiedit_emits_per_hunk_diff() {
1336 let mut turn = base_turn("t1", Role::Assistant);
1337 turn.tool_uses = vec![fw_tool(
1338 "MultiEdit",
1339 "tu1",
1340 serde_json::json!({
1341 "file_path": "m.rs",
1342 "edits": [
1343 {"old_string": "foo", "new_string": "bar"},
1344 {"old_string": "baz", "new_string": "qux"},
1345 ],
1346 }),
1347 )];
1348 let view = view_with(vec![turn]);
1349 let path = derive_path(&view, &DeriveConfig::default());
1350 let ch = &path.steps[0].change["m.rs"];
1351 let raw = ch.raw.as_deref().expect("multiedit should emit diff");
1352 assert!(raw.contains("# edit 1/2"));
1353 assert!(raw.contains("# edit 2/2"));
1354 assert!(raw.contains("-foo"));
1355 assert!(raw.contains("+bar"));
1356 assert!(raw.contains("-baz"));
1357 assert!(raw.contains("+qux"));
1358 }
1359
1360 #[test]
1361 fn test_thinking_included_when_enabled() {
1362 let mut turn = base_turn("t1", Role::Assistant);
1363 turn.thinking = Some("hmm".into());
1364 let view = view_with(vec![turn]);
1365 let path = derive_path(&view, &DeriveConfig::default());
1366 let sc = conv_change(&path.steps[0]);
1367 assert_eq!(sc.extra["thinking"], serde_json::json!("hmm"));
1368 }
1369
1370 #[test]
1371 fn test_thinking_omitted_when_disabled() {
1372 let mut turn = base_turn("t1", Role::Assistant);
1373 turn.thinking = Some("hmm".into());
1374 let view = view_with(vec![turn]);
1375 let cfg = DeriveConfig {
1376 include_thinking: false,
1377 ..Default::default()
1378 };
1379 let path = derive_path(&view, &cfg);
1380 let sc = conv_change(&path.steps[0]);
1381 assert!(!sc.extra.contains_key("thinking"));
1382 }
1383
1384 #[test]
1385 fn test_tool_uses_included_when_enabled() {
1386 let mut turn = base_turn("t1", Role::Assistant);
1387 turn.tool_uses = vec![ToolInvocation {
1388 id: "tu1".into(),
1389 name: "Read".into(),
1390 input: serde_json::json!({}),
1391 result: Some(ToolResult {
1392 content: "x".into(),
1393 is_error: false,
1394 }),
1395 category: Some(ToolCategory::FileRead),
1396 }];
1397 let view = view_with(vec![turn]);
1398 let path = derive_path(&view, &DeriveConfig::default());
1399 let sc = conv_change(&path.steps[0]);
1400 assert!(sc.extra.contains_key("tool_uses"));
1401 }
1402
1403 #[test]
1404 fn test_tool_uses_omitted_when_disabled() {
1405 let mut turn = base_turn("t1", Role::Assistant);
1406 turn.tool_uses = vec![ToolInvocation {
1407 id: "tu1".into(),
1408 name: "Read".into(),
1409 input: serde_json::json!({}),
1410 result: None,
1411 category: Some(ToolCategory::FileRead),
1412 }];
1413 let view = view_with(vec![turn]);
1414 let cfg = DeriveConfig {
1415 include_tool_uses: false,
1416 ..Default::default()
1417 };
1418 let path = derive_path(&view, &cfg);
1419 let sc = conv_change(&path.steps[0]);
1420 assert!(!sc.extra.contains_key("tool_uses"));
1421 }
1422
1423 #[test]
1424 fn test_base_uri_from_working_dir() {
1425 let mut turn = base_turn("t1", Role::User);
1426 turn.environment = Some(EnvironmentSnapshot {
1427 working_dir: Some("/Users/alex/proj".into()),
1428 ..Default::default()
1429 });
1430 let view = view_with(vec![turn]);
1431 let path = derive_path(&view, &DeriveConfig::default());
1432 assert_eq!(path.path.base.unwrap().uri, "file:///Users/alex/proj");
1433 }
1434
1435 #[test]
1436 fn test_base_uri_from_config_override() {
1437 let mut turn = base_turn("t1", Role::User);
1438 turn.environment = Some(EnvironmentSnapshot {
1439 working_dir: Some("/Users/alex/proj".into()),
1440 ..Default::default()
1441 });
1442 let view = view_with(vec![turn]);
1443 let cfg = DeriveConfig {
1444 base_uri: Some("github:org/repo".into()),
1445 ..Default::default()
1446 };
1447 let path = derive_path(&view, &cfg);
1448 assert_eq!(path.path.base.unwrap().uri, "github:org/repo");
1449 }
1450
1451 #[test]
1452 fn test_base_uri_absent_when_no_source() {
1453 let turn = base_turn("t1", Role::User);
1454 let view = view_with(vec![turn]);
1455 let path = derive_path(&view, &DeriveConfig::default());
1456 assert!(path.path.base.is_none());
1457 }
1458
1459 #[test]
1460 fn test_path_id_from_config_override() {
1461 let view = view_with(vec![]);
1462 let cfg = DeriveConfig {
1463 path_id: Some("my-custom-id".into()),
1464 ..Default::default()
1465 };
1466 let path = derive_path(&view, &cfg);
1467 assert_eq!(path.path.id, "my-custom-id");
1468 }
1469
1470 #[test]
1471 fn test_path_id_default_format() {
1472 let view = view_with(vec![]);
1473 let path = derive_path(&view, &DeriveConfig::default());
1474 assert_eq!(path.path.id, "path-pi-abcdef01");
1475 }
1476
1477 #[test]
1478 fn test_files_changed_in_meta() {
1479 let mut view = view_with(vec![]);
1480 view.files_changed = vec!["a.rs".into(), "b.rs".into()];
1481 let path = derive_path(&view, &DeriveConfig::default());
1482 let meta = path.meta.unwrap();
1483 assert_eq!(
1484 meta.extra["files_changed"],
1485 serde_json::json!(["a.rs", "b.rs"])
1486 );
1487 }
1488
1489 #[test]
1490 fn test_actors_in_meta() {
1491 let u = base_turn("t1", Role::User);
1492 let mut a = base_turn("t2", Role::Assistant);
1493 a.model = Some("claude-opus-4-7".into());
1494 let view = view_with(vec![u, a]);
1495 let path = derive_path(&view, &DeriveConfig::default());
1496 let actors = path.meta.unwrap().actors.unwrap();
1497 assert!(actors.contains_key("human:user"));
1498 assert!(actors.contains_key("agent:claude-opus-4-7"));
1499 let agent = &actors["agent:claude-opus-4-7"];
1500 assert_eq!(agent.provider.as_deref(), Some("pi"));
1501 assert_eq!(agent.model.as_deref(), Some("claude-opus-4-7"));
1502 let human = &actors["human:user"];
1503 assert_eq!(human.name.as_deref(), Some("user"));
1504 }
1505
1506 #[test]
1507 fn test_head_is_last_step_id() {
1508 let turns = vec![
1509 base_turn("t1", Role::User),
1510 base_turn("t2", Role::User),
1511 base_turn("t3", Role::User),
1512 ];
1513 let view = view_with(turns);
1514 let path = derive_path(&view, &DeriveConfig::default());
1515 assert_eq!(path.path.head, "t3");
1516 }
1517
1518 #[test]
1519 fn test_token_usage_in_extras() {
1520 let mut turn = base_turn("t1", Role::Assistant);
1521 turn.token_usage = Some(TokenUsage {
1522 input_tokens: Some(100),
1523 output_tokens: Some(50),
1524 cache_read_tokens: None,
1525 cache_write_tokens: None,
1526 ..Default::default()
1527 });
1528 let view = view_with(vec![turn]);
1529 let path = derive_path(&view, &DeriveConfig::default());
1530 let sc = conv_change(&path.steps[0]);
1531 assert!(sc.extra.contains_key("token_usage"));
1532 assert_eq!(
1533 sc.extra["token_usage"]["input_tokens"],
1534 serde_json::json!(100)
1535 );
1536 }
1537
1538 fn usage(output: u32) -> TokenUsage {
1539 TokenUsage {
1540 input_tokens: Some(6),
1541 output_tokens: Some(output),
1542 cache_read_tokens: Some(14_842),
1543 cache_write_tokens: Some(429_831),
1544 ..Default::default()
1545 }
1546 }
1547
1548 #[test]
1549 fn test_message_group_carries_usage_once_on_last_step() {
1550 let mut turns: Vec<Turn> = (1..=3)
1555 .map(|i| {
1556 let mut t = base_turn(&format!("t{i}"), Role::Assistant);
1557 t.group_id = Some("msg_01".into());
1558 t.token_usage = Some(usage(997));
1559 t
1560 })
1561 .collect();
1562 let mut t4 = base_turn("t4", Role::Assistant);
1563 t4.group_id = Some("msg_02".into());
1564 t4.token_usage = Some(usage(11));
1565 turns.push(t4);
1566
1567 let view = view_with(turns);
1568 let path = derive_path(&view, &DeriveConfig::default());
1569 let changes: Vec<&StructuralChange> = path.steps.iter().map(conv_change).collect();
1570
1571 assert!(!changes[0].extra.contains_key("token_usage"));
1572 assert!(!changes[1].extra.contains_key("token_usage"));
1573 assert_eq!(
1574 changes[2].extra["token_usage"]["output_tokens"],
1575 serde_json::json!(997)
1576 );
1577 assert_eq!(
1578 changes[3].extra["token_usage"]["output_tokens"],
1579 serde_json::json!(11)
1580 );
1581 for c in &changes[..3] {
1582 assert_eq!(c.extra["group_id"], serde_json::json!("msg_01"));
1583 }
1584 assert_eq!(changes[3].extra["group_id"], serde_json::json!("msg_02"));
1585 }
1586
1587 #[test]
1588 fn test_turn_without_group_id_is_its_own_accounting_unit() {
1589 let mut turns = Vec::new();
1592 for i in 1..=2 {
1593 let mut t = base_turn(&format!("t{i}"), Role::Assistant);
1594 t.token_usage = Some(usage(50 + i));
1595 turns.push(t);
1596 }
1597 let view = view_with(turns);
1598 let path = derive_path(&view, &DeriveConfig::default());
1599 for (i, step) in path.steps.iter().enumerate() {
1600 let sc = conv_change(step);
1601 assert_eq!(
1602 sc.extra["token_usage"]["output_tokens"],
1603 serde_json::json!(51 + i as u64)
1604 );
1605 assert!(!sc.extra.contains_key("group_id"));
1606 }
1607 }
1608
1609 #[test]
1610 fn test_message_grouping_is_consecutive_only() {
1611 let mk = |id: &str, msg: &str, out: u32| {
1615 let mut t = base_turn(id, Role::Assistant);
1616 t.group_id = Some(msg.into());
1617 t.token_usage = Some(usage(out));
1618 t
1619 };
1620 let view = view_with(vec![
1621 mk("t1", "msg_01", 100),
1622 mk("t2", "msg_02", 200),
1623 mk("t3", "msg_01", 300),
1624 ]);
1625 let path = derive_path(&view, &DeriveConfig::default());
1626 let changes: Vec<&StructuralChange> = path.steps.iter().map(conv_change).collect();
1627 assert_eq!(
1628 changes[0].extra["token_usage"]["output_tokens"],
1629 serde_json::json!(100)
1630 );
1631 assert_eq!(
1632 changes[1].extra["token_usage"]["output_tokens"],
1633 serde_json::json!(200)
1634 );
1635 assert_eq!(
1636 changes[2].extra["token_usage"]["output_tokens"],
1637 serde_json::json!(300)
1638 );
1639 }
1640
1641 #[test]
1642 fn test_delegations_in_extras() {
1643 let mut turn = base_turn("t1", Role::Assistant);
1644 turn.delegations = vec![DelegatedWork {
1645 agent_id: "sub-1".into(),
1646 prompt: "do a thing".into(),
1647 turns: vec![],
1648 result: None,
1649 }];
1650 let view = view_with(vec![turn]);
1651 let path = derive_path(&view, &DeriveConfig::default());
1652 let sc = conv_change(&path.steps[0]);
1653 assert!(sc.extra.contains_key("delegations"));
1654 assert_eq!(
1655 sc.extra["delegations"][0]["agent_id"],
1656 serde_json::json!("sub-1")
1657 );
1658 }
1659
1660 #[test]
1661 fn test_title_from_config() {
1662 let view = view_with(vec![]);
1663 let cfg = DeriveConfig {
1664 title: Some("My Session".into()),
1665 ..Default::default()
1666 };
1667 let path = derive_path(&view, &cfg);
1668 assert_eq!(path.meta.unwrap().title.as_deref(), Some("My Session"));
1669 }
1670
1671 #[test]
1672 fn test_title_default_when_unset() {
1673 let view = view_with(vec![]);
1674 let path = derive_path(&view, &DeriveConfig::default());
1675 assert_eq!(
1676 path.meta.unwrap().title.as_deref(),
1677 Some("pi session: abcdef01")
1678 );
1679 }
1680
1681 #[test]
1682 fn test_serde_roundtrip() {
1683 let mut t1 = base_turn("t1", Role::User);
1684 t1.text = "hello".into();
1685 t1.environment = Some(EnvironmentSnapshot {
1686 working_dir: Some("/proj".into()),
1687 ..Default::default()
1688 });
1689 let mut t2 = base_turn("t2", Role::Assistant);
1690 t2.parent_id = Some("t1".into());
1691 t2.model = Some("m".into());
1692 t2.tool_uses = vec![fw_tool(
1693 "Write",
1694 "tu1",
1695 serde_json::json!({"file_path": "x.rs"}),
1696 )];
1697
1698 let mut view = view_with(vec![t1, t2]);
1699 view.files_changed = vec!["x.rs".into()];
1700
1701 let path = derive_path(&view, &DeriveConfig::default());
1702 let json = serde_json::to_string(&path).unwrap();
1703 let back: Path = serde_json::from_str(&json).unwrap();
1704 assert_eq!(back.path.id, path.path.id);
1705 assert_eq!(back.path.head, path.path.head);
1706 assert_eq!(back.steps.len(), 2);
1707 assert_eq!(back.steps[1].step.parents, vec!["t1".to_string()]);
1708 assert!(back.steps[1].change.contains_key("x.rs"));
1709 }
1710}