ytil_agents/agent/session_parser/
codex.rs1use 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
13pub 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}