agm_core/agent/session_parser/
codex.rs1use 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}