Skip to main content

ytil_agents/agent/session_parser/
codex.rs

1use std::path::PathBuf;
2
3use jiff::Timestamp;
4use rootcause::option_ext::OptionExt;
5use rootcause::prelude::ResultExt;
6use serde::Deserialize;
7use serde::de::IgnoredAny;
8
9use crate::agent::Agent;
10use crate::agent::session::SearchTextBuilder;
11use crate::agent::session::Session;
12
13/// Parse one Codex JSONL session file.
14///
15/// # Errors
16/// Returns an error when the JSONL cannot be parsed or required session metadata is missing.
17pub fn parse(content: &str, session_name: &str) -> rootcause::Result<CodexSession> {
18    let mut session_id = None;
19    let mut workspace_dir = None;
20    let mut created_at = None;
21    let mut updated_at = None;
22    let mut first_user_message = None;
23    let mut is_subagent = false;
24    let mut search_text = SearchTextBuilder::default();
25
26    for (line_idx, line) in content.lines().enumerate() {
27        let line = serde_json::from_str::<CodexLine>(line)
28            .context("failed to parse Codex session json line".to_owned())
29            .attach(format!("line_number={}", line_idx.saturating_add(1)))
30            .attach(format!("line={line}"))?;
31
32        if let Some(timestamp) = line.timestamp() {
33            updated_at = Some(timestamp);
34        }
35
36        if let Some(meta) = line.session_meta() {
37            session_id.get_or_insert_with(|| meta.id.clone());
38            workspace_dir.get_or_insert_with(|| PathBuf::from(&meta.cwd));
39            created_at.get_or_insert(meta.timestamp);
40            is_subagent |= meta.is_subagent();
41        }
42
43        if let Some(user_message) = line.user_search_text() {
44            if first_user_message.is_none() {
45                first_user_message = Some(user_message.clone());
46            }
47            search_text.push(&user_message);
48        }
49        if let Some(assistant_message) = line.assistant_search_text() {
50            search_text.push(&assistant_message);
51        }
52    }
53
54    let session_id = session_id
55        .context("no Codex session_meta record found".to_owned())
56        .attach(format!("session_name={session_name}"))?;
57    let workspace_dir = workspace_dir
58        .context("no Codex session_meta record found".to_owned())
59        .attach(format!("session_name={session_name}"))?;
60    let created_at = created_at
61        .context("no Codex session_meta record found".to_owned())
62        .attach(format!("session_name={session_name}"))?;
63
64    let name = first_user_message.unwrap_or_else(|| session_name.to_owned());
65    let search_text = search_text.build(&name);
66
67    Ok(CodexSession {
68        id: session_id,
69        name,
70        search_text,
71        workspace: workspace_dir,
72        created_at,
73        updated_at: updated_at.unwrap_or(created_at),
74        is_subagent,
75    })
76}
77
78#[derive(Clone, Debug, Eq, PartialEq)]
79pub struct CodexSession {
80    pub id: String,
81    pub name: String,
82    pub search_text: String,
83    pub workspace: PathBuf,
84    pub created_at: Timestamp,
85    pub updated_at: Timestamp,
86    pub is_subagent: bool,
87}
88
89impl CodexSession {
90    pub fn into_session(self, path: PathBuf) -> Session {
91        let mut session = Session::new(Agent::Codex, self.id, self.workspace, path, None, self.created_at);
92        session.name = self.name;
93        session.search_text = self.search_text;
94        session.updated_at = self.updated_at;
95        session
96    }
97}
98
99#[derive(Debug, Deserialize)]
100#[serde(tag = "type")]
101enum CodexLine {
102    #[serde(rename = "session_meta")]
103    SessionMeta(CodexSessionMetaLine),
104    #[serde(rename = "event_msg")]
105    EventMsg(CodexEventMsgLine),
106    #[serde(rename = "response_item")]
107    ResponseItem(CodexResponseItemLine),
108    #[serde(alias = "turn_context")]
109    #[serde(alias = "compacted")]
110    Timestamped(CodexTimestampedLine),
111    #[serde(other)]
112    Other,
113}
114
115impl CodexLine {
116    const fn timestamp(&self) -> Option<Timestamp> {
117        match self {
118            Self::SessionMeta(line) => Some(line.timestamp),
119            Self::EventMsg(line) => Some(line.timestamp),
120            Self::ResponseItem(line) => Some(line.timestamp),
121            Self::Timestamped(line) => Some(line.timestamp),
122            Self::Other => None,
123        }
124    }
125
126    const fn session_meta(&self) -> Option<&CodexSessionMetaPayload> {
127        match self {
128            Self::SessionMeta(line) => Some(&line.payload),
129            Self::EventMsg(_) | Self::ResponseItem(_) | Self::Timestamped(_) | Self::Other => None,
130        }
131    }
132
133    fn user_search_text(&self) -> Option<String> {
134        match self {
135            Self::EventMsg(line) => line.user_search_text(),
136            Self::SessionMeta(_) | Self::ResponseItem(_) | Self::Timestamped(_) | Self::Other => None,
137        }
138    }
139
140    fn assistant_search_text(&self) -> Option<String> {
141        match self {
142            Self::ResponseItem(line) => line.assistant_search_text(),
143            Self::SessionMeta(_) | Self::EventMsg(_) | Self::Timestamped(_) | Self::Other => None,
144        }
145    }
146}
147
148#[derive(Debug, Deserialize)]
149struct CodexSessionMetaLine {
150    #[serde(rename = "timestamp")]
151    timestamp: Timestamp,
152    payload: CodexSessionMetaPayload,
153}
154
155#[derive(Debug, Deserialize)]
156struct CodexSessionMetaPayload {
157    id: String,
158    cwd: String,
159    timestamp: Timestamp,
160    source: Option<serde_json::Value>,
161}
162
163impl CodexSessionMetaPayload {
164    fn is_subagent(&self) -> bool {
165        self.source
166            .as_ref()
167            .is_some_and(|source| source.get("subagent").is_some())
168    }
169}
170
171#[derive(Debug, Deserialize)]
172struct CodexEventMsgLine {
173    #[serde(rename = "timestamp")]
174    timestamp: Timestamp,
175    payload: CodexEventPayload,
176}
177
178impl CodexEventMsgLine {
179    fn user_search_text(&self) -> Option<String> {
180        match &self.payload {
181            CodexEventPayload::UserMessage { message } => Some(message.clone()),
182            CodexEventPayload::Other => None,
183        }
184    }
185}
186
187#[derive(Debug, Deserialize)]
188#[serde(tag = "type")]
189enum CodexEventPayload {
190    #[serde(rename = "user_message")]
191    UserMessage { message: String },
192    #[serde(other)]
193    Other,
194}
195
196#[derive(Debug, Deserialize)]
197struct CodexResponseItemLine {
198    timestamp: Timestamp,
199    payload: CodexResponseItemPayload,
200}
201
202impl CodexResponseItemLine {
203    fn assistant_search_text(&self) -> Option<String> {
204        match &self.payload {
205            CodexResponseItemPayload::Message { role, content } if role == "assistant" => {
206                let mut search_text = SearchTextBuilder::default();
207                for snippet in content
208                    .iter()
209                    .filter_map(CodexMessageContentPart::assistant_search_text)
210                {
211                    search_text.push(snippet);
212                }
213                let message = search_text.build("");
214                (!message.is_empty()).then_some(message)
215            }
216            CodexResponseItemPayload::Message { .. }
217            | CodexResponseItemPayload::Reasoning
218            | CodexResponseItemPayload::Other => None,
219        }
220    }
221}
222
223#[derive(Debug, Deserialize)]
224#[serde(tag = "type")]
225enum CodexResponseItemPayload {
226    #[serde(rename = "message")]
227    Message {
228        role: String,
229        #[serde(default)]
230        content: Vec<CodexMessageContentPart>,
231    },
232    #[serde(rename = "reasoning")]
233    Reasoning,
234    #[serde(other)]
235    Other,
236}
237
238#[derive(Debug, Deserialize)]
239#[serde(tag = "type")]
240enum CodexMessageContentPart {
241    #[serde(rename = "output_text")]
242    OutputText { text: String },
243    #[serde(rename = "input_text")]
244    InputText {
245        #[serde(rename = "text")]
246        _text: IgnoredAny,
247    },
248    #[serde(other)]
249    Other,
250}
251
252impl CodexMessageContentPart {
253    fn assistant_search_text(&self) -> Option<&str> {
254        match self {
255            Self::OutputText { text } => Some(text),
256            Self::InputText { .. } | Self::Other => None,
257        }
258    }
259}
260
261#[derive(Debug, Deserialize)]
262struct CodexTimestampedLine {
263    timestamp: Timestamp,
264}
265
266#[cfg(test)]
267mod tests {
268    use tempfile::tempdir;
269    use test_that::prelude::*;
270
271    use super::*;
272
273    #[test]
274    fn test_parse_codex_session_from_session_meta_uses_session_name_fallback() {
275        let tempdir = tempdir().unwrap();
276        let workspace = tempdir.path().join("workspace");
277        std::fs::create_dir_all(&workspace).unwrap();
278
279        let content = format!(
280            "{{\"timestamp\":\"2026-03-20T06:30:20.312Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"019d09f0-0d96-7e23-94cd-1f6aad7cdc09\",\"timestamp\":\"2026-03-20T06:30:20.312Z\",\"cwd\":\"{}\",\"name\":\"Dotfiles\"}}}}\n",
281            workspace.display()
282        );
283
284        let codex_session_result = parse(
285            &content,
286            "rollout-2026-03-20T07-30-20-019d09f0-0d96-7e23-94cd-1f6aad7cdc09",
287        );
288        assert_that!(codex_session_result.as_ref().map(|_| ()), ok(eq(())));
289        let codex_session = codex_session_result.expect("Codex session should parse");
290        let session = codex_session.into_session(workspace.join("session.jsonl"));
291        assert_that!(session.agent, eq(Agent::Codex));
292        assert_that!(
293            session.name,
294            eq("rollout-2026-03-20T07-30-20-019d09f0-0d96-7e23-94cd-1f6aad7cdc09")
295        );
296        assert_that!(
297            session.search_text,
298            eq("rollout-2026-03-20T07-30-20-019d09f0-0d96-7e23-94cd-1f6aad7cdc09")
299        );
300        assert_that!(session.workspace, eq(workspace));
301    }
302
303    #[test]
304    fn test_parse_codex_session_indexes_user_and_assistant_text_and_updated_at() {
305        let content = concat!(
306            "{\"timestamp\":\"2026-03-20T06:30:20.312Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"019d09f0-0d96-7e23-94cd-1f6aad7cdc09\",\"timestamp\":\"2026-03-20T06:30:20.312Z\",\"cwd\":\"/tmp/workspace\"}}\n",
307            "{\"timestamp\":\"2026-03-20T06:31:20.312Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"why can't I jump with rust-analyzer to these types?\"}}\n",
308            "{\"timestamp\":\"2026-03-20T06:32:20.312Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Because that symbol is re-exported.\"},{\"type\":\"input_text\",\"text\":\"ignored\"}]}}\n",
309            "{\"timestamp\":\"2026-03-20T06:33:20.312Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"reasoning\",\"text\":\"hidden\"}}\n"
310        );
311
312        let session_result = parse(content, "fallback-name");
313        assert_that!(session_result.as_ref().map(|_| ()), ok(eq(())));
314        let session = session_result.expect("Codex session should parse");
315        assert_that!(session.name, eq("why can't I jump with rust-analyzer to these types?"));
316        assert_that!(
317            session.search_text,
318            eq("why can't I jump with rust-analyzer to these types? Because that symbol is re-exported.")
319        );
320        assert_that!(
321            session.updated_at,
322            eq("2026-03-20T06:33:20.312Z".parse::<Timestamp>().unwrap())
323        );
324    }
325
326    #[test]
327    fn test_parse_codex_session_ignores_non_assistant_response_text() {
328        let content = concat!(
329            "{\"timestamp\":\"2026-03-20T06:30:20.312Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"019d09f0-0d96-7e23-94cd-1f6aad7cdc09\",\"timestamp\":\"2026-03-20T06:30:20.312Z\",\"cwd\":\"/tmp/workspace\"}}\n",
330            "{\"timestamp\":\"2026-03-20T06:31:20.312Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"first user msg\"}}\n",
331            "{\"timestamp\":\"2026-03-20T06:32:20.312Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"output_text\",\"text\":\"should not index\"}]}}\n"
332        );
333
334        assert_that!(
335            parse(content, "fallback-name"),
336            ok(result_of!(
337                |session: &CodexSession| session.search_text.as_str(),
338                eq("first user msg")
339            ))
340        );
341    }
342
343    #[test]
344    fn test_parse_codex_session_when_source_is_subagent_marks_session() {
345        let content = concat!(
346            "{\"timestamp\":\"2026-03-20T06:30:20.312Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"019d09f0-0d96-7e23-94cd-1f6aad7cdc09\",\"timestamp\":\"2026-03-20T06:30:20.312Z\",\"cwd\":\"/tmp/workspace\",\"source\":{\"subagent\":{\"other\":\"guardian\"}}}}\n",
347            "{\"timestamp\":\"2026-03-20T06:31:20.312Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"The following is the Codex agent history\"}}\n"
348        );
349
350        assert_that!(
351            parse(content, "fallback-name"),
352            ok(result_of!(
353                |session: &CodexSession| session.is_subagent,
354                predicate(|is_subagent: &bool| *is_subagent)
355                    .with_description("is marked as a subagent", "is not marked as a subagent")
356            ))
357        );
358    }
359
360    #[test]
361    fn test_parse_codex_session_with_invalid_scanned_line_returns_error() {
362        let content = "{\"timestamp\":\"not-a-date\",\"type\":\"session_meta\",\"payload\":{\"id\":\"019d09f0-0d96-7e23-94cd-1f6aad7cdc09\",\"timestamp\":\"2026-03-20T06:30:20.312Z\",\"cwd\":\"/tmp/workspace\"}}\n";
363
364        assert_that!(
365            parse(content, "fallback-name").map(|_| ()),
366            err(displays_as(contains_substring(
367                "failed to parse Codex session json line"
368            )))
369        );
370    }
371}