Skip to main content

ytil_agents/agent/session_parser/
codex.rs

1use std::io::BufRead;
2use std::io::Read;
3use std::io::Seek;
4use std::io::SeekFrom;
5use std::path::PathBuf;
6
7use jiff::Timestamp;
8use rootcause::option_ext::OptionExt;
9use rootcause::prelude::ResultExt;
10use rootcause::report;
11use serde::Deserialize;
12
13use crate::agent::Agent;
14use crate::agent::session::SearchTextBuilder;
15use crate::agent::session::Session;
16
17const AGENTS_INSTRUCTIONS_PREFIX: &str = "# AGENTS.md instructions";
18const ENVIRONMENT_CONTEXT_PREFIX: &str = "<environment_context>";
19const REVERSE_SCAN_BUFFER_BYTES: u64 = 8 * 1024;
20
21struct SearchTextSnippet {
22    text: String,
23    normalized: bool,
24}
25
26impl SearchTextSnippet {
27    const fn raw(text: String) -> Self {
28        Self {
29            text,
30            normalized: false,
31        }
32    }
33
34    const fn normalized(text: String) -> Self {
35        Self { text, normalized: true }
36    }
37
38    fn push_to(&self, search_text: &mut SearchTextBuilder) {
39        if self.normalized {
40            search_text.push_normalized(&self.text);
41        } else {
42            search_text.push(&self.text);
43        }
44    }
45}
46
47/// Parse one Codex JSONL session file.
48///
49/// # Errors
50/// Returns an error when the JSONL cannot be parsed or required session metadata is missing.
51pub fn parse(content: &str, session_name: &str) -> rootcause::Result<CodexSession> {
52    let mut parser = CodexSessionParser::default();
53
54    for (line_idx, line) in content.lines().enumerate() {
55        parser.push_line(line, line_idx.saturating_add(1))?;
56    }
57
58    parser.finish(session_name)
59}
60
61/// Parse the metadata and first user prompt from a Codex session reader.
62///
63/// Stops once the list preview is complete or the session is a subagent.
64pub(crate) fn parse_preview(reader: impl BufRead, session_name: &str) -> rootcause::Result<CodexSession> {
65    let mut parser = CodexSessionParser::default();
66    for (line_idx, line) in reader.lines().enumerate() {
67        let line = line
68            .context("failed to read Codex session json line")
69            .attach(format!("line_number={}", line_idx.saturating_add(1)))?;
70        parser.push_line(&line, line_idx.saturating_add(1))?;
71        if parser.is_subagent || parser.is_preview_complete() {
72            break;
73        }
74    }
75
76    parser.finish(session_name)
77}
78
79/// Find the final real user prompt by scanning an NDJSON session reader backwards.
80///
81/// # Errors
82/// Returns an error when the reader cannot seek, read, or report its length.
83pub(crate) fn find_last_user_prompt(mut reader: impl Read + Seek) -> rootcause::Result<Option<String>> {
84    let mut remaining = reader
85        .seek(SeekFrom::End(0))
86        .context("failed to seek to end of Codex session")?;
87    let mut partial_line = Vec::new();
88
89    while remaining > 0 {
90        let chunk_len = usize::try_from(remaining.min(REVERSE_SCAN_BUFFER_BYTES))
91            .context("Codex session reverse scan chunk is too large")?;
92        let chunk_len_u64 = u64::try_from(chunk_len).context("Codex session reverse scan chunk length is invalid")?;
93        remaining = remaining.saturating_sub(chunk_len_u64);
94        reader
95            .seek(SeekFrom::Start(remaining))
96            .context("failed to seek within Codex session")?;
97
98        let mut chunk = vec![0; chunk_len];
99        reader
100            .read_exact(&mut chunk)
101            .context("failed to read Codex session reverse scan chunk")?;
102        chunk.append(&mut partial_line);
103
104        let mut lines = chunk.rsplit(|byte| *byte == b'\n').peekable();
105        while let Some(line) = lines.next() {
106            if lines.peek().is_none() {
107                partial_line = line.to_vec();
108                break;
109            }
110            if let Some(prompt) = user_prompt_from_ndjson_line(line) {
111                return Ok(Some(prompt));
112            }
113        }
114    }
115
116    Ok(user_prompt_from_ndjson_line(&partial_line))
117}
118
119/// Read the first valid Codex session metadata record.
120///
121/// # Errors
122/// Returns an error when metadata cannot be read before a valid `session_meta`
123/// record is found. Later malformed JSONL records are intentionally ignored.
124pub(crate) fn parse_metadata_for_deletion(
125    reader: impl BufRead,
126    session_name: &str,
127) -> rootcause::Result<CodexSessionMetadata> {
128    for (line_idx, line) in reader.lines().enumerate() {
129        let line = line
130            .context("failed to read Codex session json line")
131            .attach(format!("line_number={}", line_idx.saturating_add(1)))?;
132        let line = serde_json::from_str::<CodexLine>(&line)
133            .context("failed to parse Codex session json line")
134            .attach(format!("line_number={}", line_idx.saturating_add(1)))?;
135        if let Some(meta) = line.session_meta() {
136            return Ok(CodexSessionMetadata {
137                id: meta.id.clone(),
138                parent_thread_id: meta.parent_thread_id.clone(),
139                is_subagent: meta.is_subagent(),
140            });
141        }
142    }
143
144    Err(report!("no Codex session_meta record found").attach(format!("session_name={session_name}")))
145}
146
147#[derive(Default)]
148struct CodexSessionParser {
149    session_id: Option<String>,
150    workspace_dir: Option<PathBuf>,
151    created_at: Option<Timestamp>,
152    updated_at: Option<Timestamp>,
153    first_user_message: Option<String>,
154    last_user_message: Option<String>,
155    is_subagent: bool,
156    search_text: SearchTextBuilder,
157}
158
159impl CodexSessionParser {
160    fn push_line(&mut self, line: &str, line_number: usize) -> rootcause::Result<()> {
161        let line = serde_json::from_str::<CodexLine>(line)
162            .context("failed to parse Codex session json line".to_owned())
163            .attach(format!("line_number={line_number}"))
164            .attach(format!("line={line}"))?;
165
166        if let Some(timestamp) = line.timestamp() {
167            self.updated_at = Some(timestamp);
168        }
169
170        if let Some(meta) = line.session_meta() {
171            self.session_id.get_or_insert_with(|| meta.id.clone());
172            self.workspace_dir.get_or_insert_with(|| PathBuf::from(&meta.cwd));
173            self.created_at.get_or_insert(meta.timestamp);
174            self.is_subagent |= meta.is_subagent();
175        }
176
177        if let Some(user_message) = line.user_search_text() {
178            if self.first_user_message.is_none() {
179                self.first_user_message = Some(user_message.text.clone());
180            }
181            self.last_user_message = Some(user_message.text.clone());
182            user_message.push_to(&mut self.search_text);
183        }
184        if let Some(assistant_message) = line.assistant_search_text() {
185            assistant_message.push_to(&mut self.search_text);
186        }
187        Ok(())
188    }
189
190    const fn is_preview_complete(&self) -> bool {
191        self.session_id.is_some()
192            && self.workspace_dir.is_some()
193            && self.created_at.is_some()
194            && self.first_user_message.is_some()
195    }
196
197    fn finish(self, session_name: &str) -> rootcause::Result<CodexSession> {
198        let session_id = self
199            .session_id
200            .context("no Codex session_meta record found".to_owned())
201            .attach(format!("session_name={session_name}"))?;
202        let workspace_dir = self
203            .workspace_dir
204            .context("no Codex session_meta record found".to_owned())
205            .attach(format!("session_name={session_name}"))?;
206        let created_at = self
207            .created_at
208            .context("no Codex session_meta record found".to_owned())
209            .attach(format!("session_name={session_name}"))?;
210
211        let name = self.first_user_message.unwrap_or_else(|| session_name.to_owned());
212        let search_text = self.search_text.build(&name);
213
214        Ok(CodexSession {
215            id: session_id,
216            name,
217            last_user_prompt: self.last_user_message,
218            search_text,
219            workspace: workspace_dir,
220            created_at,
221            updated_at: self.updated_at.unwrap_or(created_at),
222            is_subagent: self.is_subagent,
223        })
224    }
225}
226
227fn user_prompt_from_ndjson_line(line: &[u8]) -> Option<String> {
228    let line = std::str::from_utf8(line).ok()?.trim_end_matches('\r');
229    serde_json::from_str::<CodexLine>(line)
230        .ok()?
231        .user_search_text()
232        .map(|snippet| snippet.text)
233}
234
235#[derive(Clone, Debug, Eq, PartialEq)]
236pub struct CodexSession {
237    pub id: String,
238    pub name: String,
239    pub last_user_prompt: Option<String>,
240    pub search_text: String,
241    pub workspace: PathBuf,
242    pub created_at: Timestamp,
243    pub updated_at: Timestamp,
244    pub is_subagent: bool,
245}
246
247#[derive(Clone, Debug, Eq, PartialEq)]
248pub(crate) struct CodexSessionMetadata {
249    pub(crate) id: String,
250    pub(crate) parent_thread_id: Option<String>,
251    pub(crate) is_subagent: bool,
252}
253
254impl CodexSession {
255    pub fn into_session(self, path: PathBuf) -> Session {
256        let mut session = Session::new(Agent::Codex, self.id, self.workspace, path, None, self.created_at);
257        session.name = self.name;
258        session.last_user_prompt = self.last_user_prompt;
259        session.search_text = self.search_text;
260        session.updated_at = self.updated_at;
261        session
262    }
263}
264
265#[derive(Debug, Deserialize)]
266#[serde(tag = "type")]
267enum CodexLine {
268    #[serde(rename = "session_meta")]
269    SessionMeta(CodexSessionMetaLine),
270    #[serde(rename = "event_msg")]
271    EventMsg(CodexEventMsgLine),
272    #[serde(rename = "response_item")]
273    ResponseItem(CodexResponseItemLine),
274    #[serde(alias = "turn_context")]
275    #[serde(alias = "compacted")]
276    Timestamped(CodexTimestampedLine),
277    #[serde(other)]
278    Other,
279}
280
281impl CodexLine {
282    const fn timestamp(&self) -> Option<Timestamp> {
283        match self {
284            Self::SessionMeta(line) => Some(line.timestamp),
285            Self::EventMsg(line) => Some(line.timestamp),
286            Self::ResponseItem(line) => Some(line.timestamp),
287            Self::Timestamped(line) => Some(line.timestamp),
288            Self::Other => None,
289        }
290    }
291
292    const fn session_meta(&self) -> Option<&CodexSessionMetaPayload> {
293        match self {
294            Self::SessionMeta(line) => Some(&line.payload),
295            Self::EventMsg(_) | Self::ResponseItem(_) | Self::Timestamped(_) | Self::Other => None,
296        }
297    }
298
299    fn user_search_text(&self) -> Option<SearchTextSnippet> {
300        match self {
301            Self::EventMsg(line) => line.user_search_text().map(SearchTextSnippet::raw),
302            Self::ResponseItem(line) => line.user_search_text(),
303            Self::SessionMeta(_) | Self::Timestamped(_) | Self::Other => None,
304        }
305    }
306
307    fn assistant_search_text(&self) -> Option<SearchTextSnippet> {
308        match self {
309            Self::ResponseItem(line) => line.assistant_search_text(),
310            Self::SessionMeta(_) | Self::EventMsg(_) | Self::Timestamped(_) | Self::Other => None,
311        }
312    }
313}
314
315#[derive(Debug, Deserialize)]
316struct CodexSessionMetaLine {
317    #[serde(rename = "timestamp")]
318    timestamp: Timestamp,
319    payload: CodexSessionMetaPayload,
320}
321
322#[derive(Debug, Deserialize)]
323struct CodexSessionMetaPayload {
324    id: String,
325    parent_thread_id: Option<String>,
326    cwd: String,
327    timestamp: Timestamp,
328    source: Option<serde_json::Value>,
329}
330
331impl CodexSessionMetaPayload {
332    fn is_subagent(&self) -> bool {
333        self.source
334            .as_ref()
335            .is_some_and(|source| source.get("subagent").is_some())
336    }
337}
338
339#[derive(Debug, Deserialize)]
340struct CodexEventMsgLine {
341    #[serde(rename = "timestamp")]
342    timestamp: Timestamp,
343    payload: CodexEventPayload,
344}
345
346impl CodexEventMsgLine {
347    fn user_search_text(&self) -> Option<String> {
348        match &self.payload {
349            CodexEventPayload::UserMessage { message } => Some(message.clone()),
350            CodexEventPayload::Other => None,
351        }
352    }
353}
354
355#[derive(Debug, Deserialize)]
356#[serde(tag = "type")]
357enum CodexEventPayload {
358    #[serde(rename = "user_message")]
359    UserMessage { message: String },
360    #[serde(other)]
361    Other,
362}
363
364#[derive(Debug, Deserialize)]
365struct CodexResponseItemLine {
366    timestamp: Timestamp,
367    payload: CodexResponseItemPayload,
368}
369
370impl CodexResponseItemLine {
371    fn user_search_text(&self) -> Option<SearchTextSnippet> {
372        match &self.payload {
373            CodexResponseItemPayload::Message { role, content }
374                if role == "user" && !is_injected_agents_context(content) =>
375            {
376                search_text_from_content(content, CodexMessageContentPart::user_search_text)
377                    .map(SearchTextSnippet::normalized)
378            }
379            CodexResponseItemPayload::Message { .. }
380            | CodexResponseItemPayload::Reasoning
381            | CodexResponseItemPayload::Other => None,
382        }
383    }
384
385    fn assistant_search_text(&self) -> Option<SearchTextSnippet> {
386        match &self.payload {
387            CodexResponseItemPayload::Message { role, content } if role == "assistant" => {
388                search_text_from_content(content, CodexMessageContentPart::assistant_search_text)
389                    .map(SearchTextSnippet::normalized)
390            }
391            CodexResponseItemPayload::Message { .. }
392            | CodexResponseItemPayload::Reasoning
393            | CodexResponseItemPayload::Other => None,
394        }
395    }
396}
397
398#[derive(Debug, Deserialize)]
399#[serde(tag = "type")]
400enum CodexResponseItemPayload {
401    #[serde(rename = "message")]
402    Message {
403        role: String,
404        #[serde(default)]
405        content: Vec<CodexMessageContentPart>,
406    },
407    #[serde(rename = "reasoning")]
408    Reasoning,
409    #[serde(other)]
410    Other,
411}
412
413#[derive(Debug, Deserialize)]
414#[serde(tag = "type")]
415enum CodexMessageContentPart {
416    #[serde(rename = "output_text")]
417    OutputText { text: String },
418    #[serde(rename = "input_text")]
419    InputText { text: serde_json::Value },
420    #[serde(other)]
421    Other,
422}
423
424impl CodexMessageContentPart {
425    fn input_text(&self) -> Option<&str> {
426        match self {
427            Self::InputText { text } => text.as_str(),
428            Self::OutputText { .. } | Self::Other => None,
429        }
430    }
431
432    fn assistant_search_text(&self) -> Option<&str> {
433        match self {
434            Self::OutputText { text } => Some(text),
435            Self::InputText { .. } | Self::Other => None,
436        }
437    }
438
439    fn user_search_text(&self) -> Option<&str> {
440        self.input_text()
441    }
442}
443
444fn is_injected_agents_context(content: &[CodexMessageContentPart]) -> bool {
445    let mut input_texts = content.iter().filter_map(CodexMessageContentPart::input_text);
446    input_texts
447        .next()
448        .is_some_and(|text| text.starts_with(AGENTS_INSTRUCTIONS_PREFIX))
449        && input_texts
450            .next()
451            .is_some_and(|text| text.starts_with(ENVIRONMENT_CONTEXT_PREFIX))
452}
453
454fn search_text_from_content(
455    content: &[CodexMessageContentPart],
456    extract: impl Fn(&CodexMessageContentPart) -> Option<&str>,
457) -> Option<String> {
458    let mut search_text = SearchTextBuilder::default();
459    for snippet in content.iter().filter_map(extract) {
460        search_text.push(snippet);
461    }
462    let message = search_text.build("");
463    (!message.is_empty()).then_some(message)
464}
465
466#[derive(Debug, Deserialize)]
467struct CodexTimestampedLine {
468    timestamp: Timestamp,
469}
470
471#[cfg(test)]
472mod tests {
473    use std::io::Cursor;
474
475    use tempfile::tempdir;
476    use test_that::prelude::*;
477
478    use super::*;
479
480    #[test]
481    fn test_parse_codex_session_from_session_meta_uses_session_name_fallback() {
482        let tempdir = tempdir().unwrap();
483        let workspace = tempdir.path().join("workspace");
484        std::fs::create_dir_all(&workspace).unwrap();
485
486        let content = format!(
487            "{{\"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",
488            workspace.display()
489        );
490
491        let codex_session_result = parse(
492            &content,
493            "rollout-2026-03-20T07-30-20-019d09f0-0d96-7e23-94cd-1f6aad7cdc09",
494        );
495        assert_that!(codex_session_result.as_ref().map(|_| ()), ok(eq(())));
496        let codex_session = codex_session_result.expect("Codex session should parse");
497        let session = codex_session.into_session(workspace.join("session.jsonl"));
498        assert_that!(session.agent, eq(Agent::Codex));
499        assert_that!(
500            session.name,
501            eq("rollout-2026-03-20T07-30-20-019d09f0-0d96-7e23-94cd-1f6aad7cdc09")
502        );
503        assert_that!(
504            session.search_text,
505            eq("rollout-2026-03-20T07-30-20-019d09f0-0d96-7e23-94cd-1f6aad7cdc09")
506        );
507        assert_that!(session.workspace, eq(workspace));
508    }
509
510    #[test]
511    fn test_parse_codex_session_indexes_user_and_assistant_text_and_updated_at() {
512        let content = concat!(
513            "{\"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",
514            "{\"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",
515            "{\"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",
516            "{\"timestamp\":\"2026-03-20T06:33:20.312Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"where is the re-export defined?\"}}\n",
517            "{\"timestamp\":\"2026-03-20T06:34:20.312Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"reasoning\",\"text\":\"hidden\"}}\n"
518        );
519
520        let session_result = parse(content, "fallback-name");
521        assert_that!(session_result.as_ref().map(|_| ()), ok(eq(())));
522        let session = session_result.expect("Codex session should parse");
523        assert_that!(session.name, eq("why can't I jump with rust-analyzer to these types?"));
524        assert_that!(
525            session.search_text,
526            eq(
527                "why can't I jump with rust-analyzer to these types? Because that symbol is re-exported. where is the re-export defined?"
528            )
529        );
530        assert_that!(
531            session.last_user_prompt,
532            eq(Some("where is the re-export defined?".to_owned()))
533        );
534        assert_that!(
535            session.updated_at,
536            eq("2026-03-20T06:34:20.312Z".parse::<Timestamp>().unwrap())
537        );
538    }
539
540    #[test]
541    fn test_parse_codex_session_when_user_prompt_is_response_item_uses_it_as_name() {
542        let content = concat!(
543            "{\"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",
544            "{\"timestamp\":\"2026-03-20T06:31:20.312Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"developer\",\"content\":[{\"type\":\"input_text\",\"text\":\"developer instructions\"}]}}\n",
545            "{\"timestamp\":\"2026-03-20T06:32:20.312Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"# AGENTS.md instructions for /tmp/workspace\\n\\n<INSTRUCTIONS>\\nGenerated context\"},{\"type\":\"input_text\",\"text\":\"<environment_context>generated context</environment_context>\"}]}}\n",
546            "{\"timestamp\":\"2026-03-20T06:32:21.312Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"first user prompt\"}]}}\n"
547        );
548
549        assert_that!(
550            parse(content, "fallback-name"),
551            ok(result_of!(
552                |session: &CodexSession| session.name.as_str(),
553                eq("first user prompt")
554            ))
555        );
556        assert_that!(
557            parse(content, "fallback-name"),
558            ok(result_of!(
559                |session: &CodexSession| session.search_text.as_str(),
560                eq("first user prompt")
561            ))
562        );
563    }
564
565    #[test]
566    fn test_parse_codex_session_when_prompt_starts_with_agents_prefix_keeps_it() {
567        let content = concat!(
568            "{\"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",
569            "{\"timestamp\":\"2026-03-20T06:31:20.312Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"# AGENTS.md instructions need a review\"}]}}\n"
570        );
571
572        assert_that!(
573            parse(content, "fallback-name"),
574            ok(result_of!(
575                |session: &CodexSession| session.name.as_str(),
576                eq("# AGENTS.md instructions need a review")
577            ))
578        );
579    }
580
581    #[test]
582    fn test_parse_codex_session_when_user_input_text_is_not_string_uses_fallback_name() {
583        let content = concat!(
584            "{\"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",
585            "{\"timestamp\":\"2026-03-20T06:31:20.312Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":{\"structured\":\"content\"}}]}}\n"
586        );
587
588        assert_that!(
589            parse(content, "fallback-name"),
590            ok(result_of!(
591                |session: &CodexSession| session.name.as_str(),
592                eq("fallback-name")
593            ))
594        );
595    }
596
597    #[test]
598    fn test_parse_codex_session_when_source_is_subagent_marks_session() {
599        let content = concat!(
600            "{\"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",
601            "{\"timestamp\":\"2026-03-20T06:31:20.312Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"The following is the Codex agent history\"}}\n"
602        );
603
604        assert_that!(
605            parse(content, "fallback-name"),
606            ok(result_of!(
607                |session: &CodexSession| session.is_subagent,
608                predicate(|is_subagent: &bool| *is_subagent)
609                    .with_description("is marked as a subagent", "is not marked as a subagent")
610            ))
611        );
612    }
613
614    #[test]
615    fn test_parse_preview_when_source_is_subagent_stops_before_invalid_tail() {
616        let content = concat!(
617            "{\"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",
618            "not json\n"
619        );
620
621        assert_that!(
622            parse_preview(Cursor::new(content), "fallback-name"),
623            ok(result_of!(
624                |session: &CodexSession| session.is_subagent,
625                predicate(|is_subagent: &bool| *is_subagent)
626                    .with_description("is marked as a subagent", "is not marked as a subagent")
627            ))
628        );
629    }
630
631    #[test]
632    fn test_parse_preview_when_parent_has_multiple_user_prompts_stops_after_the_first_prompt() {
633        let content = concat!(
634            "{\"timestamp\":\"2026-03-20T06:30:20.312Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"session-id\",\"timestamp\":\"2026-03-20T06:30:20.312Z\",\"cwd\":\"/tmp/workspace\"}}\n",
635            "{\"timestamp\":\"2026-03-20T06:31:20.312Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"first prompt\"}}\n",
636            "{\"timestamp\":\"2026-03-20T06:32:20.312Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"last prompt\"}}\n"
637        );
638
639        assert_that!(
640            parse_preview(Cursor::new(content), "fallback-name"),
641            ok(result_of!(
642                |session: &CodexSession| session.last_user_prompt.as_deref(),
643                eq(Some("first prompt"))
644            ))
645        );
646    }
647
648    #[test]
649    fn test_find_last_user_prompt_when_trailing_record_exceeds_buffer_returns_latest_user_prompt() {
650        let large_assistant_message = "x".repeat(usize::try_from(REVERSE_SCAN_BUFFER_BYTES).unwrap_or(0));
651        let content = concat!(
652            "{\"timestamp\":\"2026-03-20T06:31:20.312Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"first prompt\"}}\n",
653            "{\"timestamp\":\"2026-03-20T06:32:20.312Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"last prompt\"}}\n",
654            "{\"timestamp\":\"2026-03-20T06:33:20.312Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"__LARGE_ASSISTANT_MESSAGE__\"}]}}\n",
655            "not json\n"
656        )
657        .replace("__LARGE_ASSISTANT_MESSAGE__", &large_assistant_message);
658
659        assert_that!(
660            find_last_user_prompt(Cursor::new(content.as_bytes())),
661            ok(eq(Some("last prompt".to_owned())))
662        );
663    }
664
665    #[test]
666    fn test_parse_codex_session_with_invalid_scanned_line_returns_error() {
667        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";
668
669        assert_that!(
670            parse(content, "fallback-name").map(|_| ()),
671            err(displays_as(contains_substring(
672                "failed to parse Codex session json line"
673            )))
674        );
675    }
676}