1use crate::provider::to_view;
11use crate::types::Session;
12use toolpath::v1::Path;
13
14#[derive(Debug, Clone, Default)]
24pub struct DeriveConfig {
25 pub project_path: Option<String>,
27}
28
29pub fn derive_path(session: &Session, config: &DeriveConfig) -> Path {
31 let view = to_view(session);
32 let prefix: String = view.id.chars().take(8).collect();
33 let base_uri = config.project_path.as_ref().map(|p| {
34 if p.starts_with('/') {
35 format!("file://{}", p)
36 } else {
37 p.clone()
38 }
39 });
40 let cfg = toolpath_convo::DeriveConfig {
41 base_uri,
42 title: Some(format!("Codex session: {}", prefix)),
43 ..Default::default()
44 };
45 toolpath_convo::derive_path(&view, &cfg)
46}
47
48pub fn derive_project(sessions: &[Session], config: &DeriveConfig) -> Vec<Path> {
50 sessions.iter().map(|s| derive_path(s, config)).collect()
51}
52
53#[cfg(test)]
54mod tests {
55 use super::*;
56 use crate::CodexConvo;
57 use std::fs;
58 use tempfile::TempDir;
59 use toolpath::v1::Graph;
60
61 fn fixture_session(body: &str) -> (TempDir, CodexConvo, String) {
62 let temp = TempDir::new().unwrap();
63 let codex = temp.path().join(".codex");
64 let day = codex.join("sessions/2026/04/20");
65 fs::create_dir_all(&day).unwrap();
66 let name = "rollout-2026-04-20T10-00-00-019dabc6-8fef-7681-a054-b5bb75fcb97d";
67 fs::write(day.join(format!("{}.jsonl", name)), body).unwrap();
68 let resolver = crate::PathResolver::new().with_codex_dir(&codex);
69 (temp, CodexConvo::with_resolver(resolver), name.into())
70 }
71
72 fn minimal_body() -> String {
73 [
74 r#"{"timestamp":"2026-04-20T16:44:37.772Z","type":"session_meta","payload":{"id":"019dabc6-8fef-7681-a054-b5bb75fcb97d","timestamp":"2026-04-20T16:43:30.171Z","cwd":"/tmp/proj","originator":"codex-tui","cli_version":"0.118.0","source":"cli","git":{"commit_hash":"abc","branch":"main","repository_url":"git@example:x/y.git"}}}"#,
75 r#"{"timestamp":"2026-04-20T16:44:37.773Z","type":"turn_context","payload":{"turn_id":"t1","cwd":"/tmp/proj","model":"gpt-5.4"}}"#,
76 r#"{"timestamp":"2026-04-20T16:44:37.800Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"build me a thing"}]}}"#,
77 r#"{"timestamp":"2026-04-20T16:44:38.100Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"creating"}],"phase":"commentary"}}"#,
78 r#"{"timestamp":"2026-04-20T16:44:38.500Z","type":"response_item","payload":{"type":"custom_tool_call","call_id":"c2","name":"apply_patch","input":"*** Begin Patch\n*** Add File: /tmp/proj/a.rs\n+fn main() {}\n*** End Patch"}}"#,
79 r#"{"timestamp":"2026-04-20T16:44:38.700Z","type":"event_msg","payload":{"type":"patch_apply_end","call_id":"c2","success":true,"changes":{"/tmp/proj/a.rs":{"type":"add","content":"fn main() {}\n"}}}}"#,
80 r#"{"timestamp":"2026-04-20T16:44:38.900Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"done"}],"phase":"final","end_turn":true}}"#,
81 ]
82 .join("\n")
83 }
84
85 #[test]
86 fn derive_path_basic() {
87 let (_t, mgr, id) = fixture_session(&minimal_body());
88 let session = mgr.read_session(&id).unwrap();
89 let path = derive_path(&session, &DeriveConfig::default());
90
91 assert!(path.path.id.starts_with("path-codex-"));
92 assert_eq!(path.path.base.as_ref().unwrap().uri, "file:///tmp/proj");
93 assert_eq!(
94 path.path.base.as_ref().unwrap().ref_str.as_deref(),
95 Some("abc")
96 );
97 assert_eq!(
98 path.path.base.as_ref().unwrap().branch.as_deref(),
99 Some("main")
100 );
101 }
102
103 #[test]
104 fn derive_path_actors_populated() {
105 let (_t, mgr, id) = fixture_session(&minimal_body());
106 let session = mgr.read_session(&id).unwrap();
107 let path = derive_path(&session, &DeriveConfig::default());
108 let actors = path.meta.as_ref().unwrap().actors.as_ref().unwrap();
109 assert!(actors.contains_key("human:user"));
110 assert!(actors.contains_key("agent:gpt-5.4"));
111 }
112
113 #[test]
114 fn derive_path_producer_in_canonical_slot() {
115 let (_t, mgr, id) = fixture_session(&minimal_body());
116 let session = mgr.read_session(&id).unwrap();
117 let path = derive_path(&session, &DeriveConfig::default());
118 let meta_extra = &path.meta.as_ref().unwrap().extra;
119 let producer = meta_extra
121 .get("producer")
122 .and_then(|v| v.as_object())
123 .expect("meta.extra.producer object");
124 assert_eq!(
125 producer.get("name").and_then(|v| v.as_str()),
126 Some("codex-tui")
127 );
128 assert_eq!(
129 producer.get("version").and_then(|v| v.as_str()),
130 Some("0.118.0")
131 );
132 assert!(!meta_extra.contains_key("codex"));
134 }
135
136 #[test]
137 fn derive_path_apply_patch_emits_file_write_sibling() {
138 let (_t, mgr, id) = fixture_session(&minimal_body());
139 let session = mgr.read_session(&id).unwrap();
140 let path = derive_path(&session, &DeriveConfig::default());
141 let file_step = path
144 .steps
145 .iter()
146 .find(|s| s.change.contains_key("/tmp/proj/a.rs"))
147 .expect("no step carries the file artifact");
148 let change = &file_step.change["/tmp/proj/a.rs"];
149 assert!(change.raw.is_some(), "raw perspective must be populated");
150 assert!(
151 change.raw.as_ref().unwrap().contains("+fn main() {}"),
152 "raw must be a unified diff"
153 );
154 let structural = change.structural.as_ref().unwrap();
155 assert_eq!(structural.change_type, "file.write");
156 assert_eq!(structural.extra["operation"], "add");
157 }
158
159 #[test]
160 fn derive_path_validates_as_single_path_graph() {
161 let (_t, mgr, id) = fixture_session(&minimal_body());
162 let session = mgr.read_session(&id).unwrap();
163 let path = derive_path(&session, &DeriveConfig::default());
164 let doc = Graph::from_path(path);
165 let json = doc.to_json().unwrap();
166 let parsed = Graph::from_json(&json).unwrap();
167 let p = parsed.single_path().expect("single-path graph");
168 let anc = toolpath::v1::query::ancestors(&p.steps, &p.path.head);
169 assert_eq!(anc.len(), p.steps.len(), "all steps on head ancestry");
170 }
171
172 #[test]
173 fn derive_project_per_session() {
174 let (_t, mgr, id) = fixture_session(&minimal_body());
175 let s1 = mgr.read_session(&id).unwrap();
176 let paths = derive_project(std::slice::from_ref(&s1), &DeriveConfig::default());
177 assert_eq!(paths.len(), 1);
178 }
179}