Skip to main content

toolpath_codex/
derive.rs

1//! Derive Toolpath documents from Codex CLI sessions.
2//!
3//! Thin wrapper around the shared [`toolpath_convo::derive_path`]: convert
4//! the session to a provider-agnostic [`toolpath_convo::ConversationView`]
5//! via [`crate::provider::to_view`] and hand off. All Codex-specific data
6//! (cwd, git, file diffs from `patch_apply_end`, codex meta aggregates) is
7//! captured during `to_view`; this module only sets the title and any
8//! CLI overrides.
9
10use crate::provider::to_view;
11use crate::types::Session;
12use toolpath::v1::Path;
13
14/// Configuration for deriving a Toolpath Path from a Codex session.
15///
16/// Note: there's no `include_thinking` toggle like the other providers
17/// have. Codex's reasoning is almost always encrypted ciphertext from
18/// OpenAI's servers — not useful in a human-readable digest. Plaintext
19/// reasoning summaries (rare) land on `Turn.thinking` automatically
20/// and surface in the derived path without a flag. The raw ciphertext
21/// is preserved under `Turn.extra["codex"]["reasoning_encrypted"]` for
22/// round-trip fidelity but never rendered.
23#[derive(Debug, Clone, Default)]
24pub struct DeriveConfig {
25    /// Override `path.base.uri`. Defaults to the cwd from session_meta.
26    pub project_path: Option<String>,
27}
28
29/// Derive a [`Path`] from a Codex [`Session`].
30pub 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
48/// Derive a [`Path`] from multiple sessions. Used for bulk exports.
49pub 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        // Producer (originator + cli_version) lives in its canonical slot.
120        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        // Nothing else codex-specific is smuggled through meta.extra.
133        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        // The assistant turn that ran `apply_patch` carries a sibling
142        // `file.write` entry keyed by the file path.
143        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}