Skip to main content

agm_core/agent/session_parser/
codex.rs

1use std::path::PathBuf;
2
3use chrono::DateTime;
4use chrono::Utc;
5use rootcause::option_ext::OptionExt as _;
6use rootcause::prelude::ResultExt as _;
7use serde::Deserialize;
8
9use crate::agent::Agent;
10use crate::agent::session::Session;
11
12pub fn parse(content: &str, session_name: &str) -> rootcause::Result<Session> {
13    let mut session = None;
14    let mut first_user_message = None;
15
16    for (line_idx, line) in content.lines().enumerate() {
17        let line = serde_json::from_str::<CodexLine>(line)
18            .context("failed to parse Codex session json line".to_owned())
19            .attach(format!("line_number={}", line_idx.saturating_add(1)))
20            .attach(format!("line={line}"))?;
21
22        if first_user_message.is_none() {
23            first_user_message = line.first_user_message();
24        }
25
26        if session.is_none() {
27            let Some(meta) = line.into_session_meta() else {
28                continue;
29            };
30            let created_at = meta.payload.timestamp;
31
32            session = Some(Session::new(
33                Agent::Codex,
34                meta.payload.id,
35                PathBuf::from(meta.payload.cwd),
36                first_user_message.clone(),
37                created_at,
38            ));
39        }
40
41        if session.is_some() && first_user_message.is_some() {
42            break;
43        }
44    }
45
46    let mut session = session
47        .context("no Codex session_meta record found".to_owned())
48        .attach(format!("session_name={session_name}"))?;
49    session.name = first_user_message.as_deref().unwrap_or(session_name).to_string();
50
51    for line in content.lines().rev() {
52        let line = serde_json::from_str::<CodexLine>(line)
53            .context("failed to parse Codex session json line".to_owned())
54            .attach(format!("line={line}"))?;
55        let Some(timestamp) = line.timestamp() else {
56            continue;
57        };
58        session.updated_at = timestamp;
59        return Ok(session);
60    }
61
62    session.updated_at = session.created_at;
63    Ok(session)
64}
65
66#[derive(Debug, Deserialize)]
67#[serde(tag = "type")]
68enum CodexLine {
69    #[serde(rename = "session_meta")]
70    SessionMeta(CodexSessionMetaLine),
71    #[serde(rename = "event_msg")]
72    EventMsg(CodexEventMsgLine),
73    #[serde(rename = "response_item")]
74    #[serde(alias = "turn_context")]
75    #[serde(alias = "compacted")]
76    Timestamped(CodexTimestampedLine),
77    #[serde(other)]
78    Other,
79}
80
81impl CodexLine {
82    fn timestamp(&self) -> Option<DateTime<Utc>> {
83        match self {
84            Self::SessionMeta(line) => Some(line.timestamp),
85            Self::EventMsg(line) => Some(line.timestamp),
86            Self::Timestamped(line) => Some(line.timestamp),
87            Self::Other => None,
88        }
89    }
90
91    fn first_user_message(&self) -> Option<String> {
92        match self {
93            Self::EventMsg(line) => line.first_user_message(),
94            Self::SessionMeta(_) | Self::Timestamped(_) | Self::Other => None,
95        }
96    }
97
98    fn into_session_meta(self) -> Option<CodexSessionMetaLine> {
99        match self {
100            Self::SessionMeta(line) => Some(line),
101            Self::EventMsg(_) | Self::Timestamped(_) | Self::Other => None,
102        }
103    }
104}
105
106#[derive(Debug, Deserialize)]
107struct CodexSessionMetaLine {
108    #[serde(rename = "timestamp")]
109    timestamp: DateTime<Utc>,
110    payload: CodexSessionMetaPayload,
111}
112
113#[derive(Debug, Deserialize)]
114struct CodexSessionMetaPayload {
115    id: String,
116    cwd: String,
117    timestamp: DateTime<Utc>,
118}
119
120#[derive(Debug, Deserialize)]
121struct CodexEventMsgLine {
122    #[serde(rename = "timestamp")]
123    timestamp: DateTime<Utc>,
124    payload: CodexEventPayload,
125}
126
127impl CodexEventMsgLine {
128    fn first_user_message(&self) -> Option<String> {
129        match &self.payload {
130            CodexEventPayload::UserMessage { message } => Some(message.clone()),
131            CodexEventPayload::Other => None,
132        }
133    }
134}
135
136#[derive(Debug, Deserialize)]
137#[serde(tag = "type")]
138enum CodexEventPayload {
139    #[serde(rename = "user_message")]
140    UserMessage { message: String },
141    #[serde(other)]
142    Other,
143}
144
145#[derive(Debug, Deserialize)]
146struct CodexTimestampedLine {
147    timestamp: DateTime<Utc>,
148}
149
150#[cfg(test)]
151mod tests {
152    use tempfile::tempdir;
153
154    use super::*;
155
156    #[test]
157    fn test_parse_codex_session_from_session_meta_uses_session_name_fallback() {
158        let tempdir = tempdir().unwrap();
159        let workspace = tempdir.path().join("workspace");
160        std::fs::create_dir_all(&workspace).unwrap();
161
162        let content = format!(
163            "{{\"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",
164            workspace.display()
165        );
166
167        assert2::assert!(let Ok(session) = parse(
168            &content,
169            "rollout-2026-03-20T07-30-20-019d09f0-0d96-7e23-94cd-1f6aad7cdc09",
170        ));
171        pretty_assertions::assert_eq!(session.agent, Agent::Codex);
172        pretty_assertions::assert_eq!(
173            session.name,
174            "rollout-2026-03-20T07-30-20-019d09f0-0d96-7e23-94cd-1f6aad7cdc09"
175        );
176        pretty_assertions::assert_eq!(session.workspace, workspace);
177    }
178
179    #[test]
180    fn test_parse_codex_session_with_first_user_message_sets_name_and_updated_at() {
181        let content = concat!(
182            "{\"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",
183            "{\"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"
184        );
185        assert2::assert!(let Ok(session) = parse(content, "fallback-name"));
186        pretty_assertions::assert_eq!(session.name, "why can't I jump with rust-analyzer to these types?");
187        pretty_assertions::assert_eq!(
188            session.updated_at,
189            chrono::DateTime::parse_from_rfc3339("2026-03-20T06:31:20.312Z")
190                .unwrap()
191                .to_utc()
192        );
193    }
194
195    #[test]
196    fn test_parse_codex_session_skips_middle_lines_after_top_loop_stops() {
197        let content = concat!(
198            "{\"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",
199            "{\"timestamp\":\"2026-03-20T06:31:20.312Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"first user msg\"}}\n",
200            "{\"bad\":\"json\"\n",
201            "{\"timestamp\":\"2026-03-20T06:32:20.312Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\"}}\n"
202        );
203
204        assert2::assert!(let Ok(session) = parse(content, "fallback-name"));
205        pretty_assertions::assert_eq!(session.name, "first user msg");
206        pretty_assertions::assert_eq!(
207            session.updated_at,
208            chrono::DateTime::parse_from_rfc3339("2026-03-20T06:32:20.312Z")
209                .unwrap()
210                .to_utc()
211        );
212    }
213
214    #[test]
215    fn test_parse_codex_session_with_invalid_scanned_line_returns_error() {
216        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";
217
218        assert2::assert!(let Err(err) = parse(content, "fallback-name"));
219        assert!(err.to_string().contains("failed to parse Codex session json line"));
220    }
221}