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
47pub 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
61pub(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
79pub(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
119pub(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}