toolpath_copilot/
derive.rs1use crate::provider::to_view;
10use crate::types::Session;
11use toolpath::v1::Path;
12
13#[derive(Debug, Clone, Default)]
15pub struct DeriveConfig {
16 pub project_path: Option<String>,
18}
19
20pub fn derive_path(session: &Session, config: &DeriveConfig) -> Path {
22 let view = to_view(session);
23 let prefix: String = view.id.chars().take(8).collect();
24 let base_uri = config.project_path.as_ref().map(|p| {
25 if p.starts_with('/') {
26 format!("file://{}", p)
27 } else {
28 p.clone()
29 }
30 });
31 let cfg = toolpath_convo::DeriveConfig {
32 base_uri,
33 title: Some(format!("Copilot session: {}", prefix)),
34 ..Default::default()
35 };
36 toolpath_convo::derive_path(&view, &cfg)
37}
38
39pub fn derive_project(sessions: &[Session], config: &DeriveConfig) -> Vec<Path> {
41 sessions.iter().map(|s| derive_path(s, config)).collect()
42}
43
44#[cfg(test)]
45mod tests {
46 use super::*;
47 use crate::reader::EventReader;
48 use std::fs;
49 use tempfile::TempDir;
50 use toolpath::v1::Graph;
51
52 fn fixture_session(body: &str) -> (TempDir, Session) {
53 let temp = TempDir::new().unwrap();
54 let dir = temp.path().join("session-state").join("sess-019dabc6");
55 fs::create_dir_all(&dir).unwrap();
56 fs::write(dir.join("events.jsonl"), body).unwrap();
57 let session = EventReader::read_session_dir(&dir).unwrap();
58 (temp, session)
59 }
60
61 fn minimal_body() -> String {
62 [
63 r#"{"type":"session.start","timestamp":"2026-06-30T10:00:00.000Z","data":{"copilotVersion":"1.0.66","model":"gpt-5-copilot","context":{"cwd":"/tmp/proj"}}}"#,
64 r#"{"type":"user.message","timestamp":"2026-06-30T10:00:01.000Z","data":{"content":"build me a thing"}}"#,
65 r#"{"type":"assistant.turn_start","timestamp":"2026-06-30T10:00:02.000Z","data":{}}"#,
66 r#"{"type":"assistant.message","timestamp":"2026-06-30T10:00:03.000Z","data":{"content":"creating"}}"#,
67 r#"{"type":"tool.execution_start","timestamp":"2026-06-30T10:00:04.000Z","data":{"toolCallId":"c2","toolName":"create_file","arguments":{"path":"a.rs","content":"fn main() {}\n"}}}"#,
68 r#"{"type":"tool.execution_complete","timestamp":"2026-06-30T10:00:05.000Z","data":{"toolCallId":"c2","success":true}}"#,
69 r#"{"type":"assistant.message","timestamp":"2026-06-30T10:00:06.000Z","data":{"content":"done"}}"#,
70 r#"{"type":"assistant.turn_end","timestamp":"2026-06-30T10:00:07.000Z","data":{}}"#,
71 ]
72 .join("\n")
73 }
74
75 #[test]
76 fn derive_path_basic() {
77 let (_t, session) = fixture_session(&minimal_body());
78 let path = derive_path(&session, &DeriveConfig::default());
79 assert!(path.path.id.starts_with("path-copilot-"));
80 assert_eq!(path.path.base.as_ref().unwrap().uri, "file:///tmp/proj");
81 }
82
83 #[test]
84 fn derive_path_actors_populated() {
85 let (_t, session) = fixture_session(&minimal_body());
86 let path = derive_path(&session, &DeriveConfig::default());
87 let actors = path.meta.as_ref().unwrap().actors.as_ref().unwrap();
88 assert!(actors.contains_key("human:user"));
89 assert!(actors.contains_key("agent:gpt-5-copilot"));
90 }
91
92 #[test]
93 fn derive_path_producer_in_canonical_slot() {
94 let (_t, session) = fixture_session(&minimal_body());
95 let path = derive_path(&session, &DeriveConfig::default());
96 let meta_extra = &path.meta.as_ref().unwrap().extra;
97 let producer = meta_extra
98 .get("producer")
99 .and_then(|v| v.as_object())
100 .expect("meta.extra.producer object");
101 assert_eq!(
102 producer.get("name").and_then(|v| v.as_str()),
103 Some("copilot-cli")
104 );
105 assert_eq!(
106 producer.get("version").and_then(|v| v.as_str()),
107 Some("1.0.66")
108 );
109 }
110
111 #[test]
112 fn derive_path_file_write_emits_sibling() {
113 let (_t, session) = fixture_session(&minimal_body());
114 let path = derive_path(&session, &DeriveConfig::default());
115 let file_step = path
116 .steps
117 .iter()
118 .find(|s| s.change.contains_key("a.rs"))
119 .expect("no step carries the file artifact");
120 let change = &file_step.change["a.rs"];
121 assert!(change.raw.is_some(), "raw perspective must be populated");
122 assert!(change.raw.as_ref().unwrap().contains("+fn main() {}"));
123 let structural = change.structural.as_ref().unwrap();
124 assert_eq!(structural.change_type, "file.write");
125 assert_eq!(structural.extra["operation"], "add");
126 }
127
128 #[test]
129 fn derive_path_validates_as_single_path_graph() {
130 let (_t, session) = fixture_session(&minimal_body());
131 let path = derive_path(&session, &DeriveConfig::default());
132 let doc = Graph::from_path(path);
133 let json = doc.to_json().unwrap();
134 let parsed = Graph::from_json(&json).unwrap();
135 let p = parsed.single_path().expect("single-path graph");
136 let anc = toolpath::v1::query::ancestors(&p.steps, &p.path.head);
137 assert_eq!(anc.len(), p.steps.len(), "all steps on head ancestry");
138 }
139
140 #[test]
141 fn derive_path_base_carries_workspace_git_context() {
142 let temp = TempDir::new().unwrap();
143 let dir = temp.path().join("session-state").join("sess-ws");
144 fs::create_dir_all(&dir).unwrap();
145 fs::write(dir.join("events.jsonl"), minimal_body()).unwrap();
146 fs::write(
147 dir.join("workspace.yaml"),
148 "git_root: /tmp/proj\nrepository: git@github.com:o/r.git\nbranch: main\ncommit: deadbeef\n",
149 )
150 .unwrap();
151 let session = EventReader::read_session_dir(&dir).unwrap();
152 let path = derive_path(&session, &DeriveConfig::default());
153 let base = path.path.base.as_ref().unwrap();
154 assert_eq!(base.uri, "file:///tmp/proj");
155 assert_eq!(base.branch.as_deref(), Some("main"));
156 assert_eq!(base.ref_str.as_deref(), Some("deadbeef"));
157 }
158
159 #[test]
160 fn derive_path_kind_is_agent_coding_session() {
161 let (_t, session) = fixture_session(&minimal_body());
162 let path = derive_path(&session, &DeriveConfig::default());
163 assert_eq!(
164 path.meta.as_ref().unwrap().kind.as_deref(),
165 Some(toolpath::v1::PATH_KIND_AGENT_CODING_SESSION)
166 );
167 }
168}