Skip to main content

toolpath_codex/
types.rs

1//! On-disk schema for Codex CLI rollout JSONL files.
2//!
3//! Mirrors the upstream Rust types in
4//! `openai/codex:codex-rs/protocol/src/{protocol,models}.rs` closely
5//! enough for fidelity, but uses `Value` fallbacks on enum variants we
6//! don't exhaustively enumerate so unknown future payloads survive
7//! round-trip. Each top-level line is a `RolloutLine`:
8//!
9//! ```json
10//! {"timestamp":"2026-04-20T16:44:37.772Z","type":"session_meta","payload":{...}}
11//! ```
12//!
13//! The `type` field discriminates at the outer level. `payload.type`
14//! discriminates inside `response_item` and `event_msg` payloads.
15
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19use std::collections::HashMap;
20use std::path::PathBuf;
21
22// ── Rollout line (top-level wrapper) ────────────────────────────────
23
24/// One JSONL line from a rollout file — a tagged payload with a
25/// timestamp. The struct preserves unknown fields and unknown `type`
26/// tags verbatim so round-trip re-serialization stays faithful.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct RolloutLine {
29    pub timestamp: String,
30
31    /// The outer discriminator: `session_meta`, `turn_context`,
32    /// `response_item`, `event_msg`, `session_state`, `compacted`, or
33    /// an unknown future value.
34    #[serde(rename = "type")]
35    pub kind: String,
36
37    /// Variant-specific payload. Type-dispatched view via
38    /// [`RolloutLine::item`].
39    pub payload: Value,
40
41    /// Forward-compat catch-all for unknown top-level fields.
42    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
43    pub extra: HashMap<String, Value>,
44}
45
46impl RolloutLine {
47    /// Interpret `kind` + `payload` into a typed view. Unknown variants
48    /// become `RolloutItem::Unknown`, preserving the raw payload.
49    pub fn item(&self) -> RolloutItem {
50        match self.kind.as_str() {
51            "session_meta" => match serde_json::from_value(self.payload.clone()) {
52                Ok(v) => RolloutItem::SessionMeta(Box::new(v)),
53                Err(_) => RolloutItem::Unknown {
54                    kind: self.kind.clone(),
55                    payload: self.payload.clone(),
56                },
57            },
58            "turn_context" => match serde_json::from_value(self.payload.clone()) {
59                Ok(v) => RolloutItem::TurnContext(Box::new(v)),
60                Err(_) => RolloutItem::Unknown {
61                    kind: self.kind.clone(),
62                    payload: self.payload.clone(),
63                },
64            },
65            "response_item" => RolloutItem::ResponseItem(ResponseItem::from_value(&self.payload)),
66            "event_msg" => RolloutItem::EventMsg(EventMsg::from_value(&self.payload)),
67            "session_state" => RolloutItem::SessionState(self.payload.clone()),
68            "compacted" => RolloutItem::Compacted(self.payload.clone()),
69            _ => RolloutItem::Unknown {
70                kind: self.kind.clone(),
71                payload: self.payload.clone(),
72            },
73        }
74    }
75
76    /// Parse the outer timestamp into a `DateTime<Utc>` if well-formed.
77    pub fn parsed_timestamp(&self) -> Option<DateTime<Utc>> {
78        self.timestamp.parse::<DateTime<Utc>>().ok()
79    }
80}
81
82/// Typed view of a [`RolloutLine::payload`] by `kind`.
83#[derive(Debug, Clone)]
84pub enum RolloutItem {
85    SessionMeta(Box<SessionMeta>),
86    TurnContext(Box<TurnContext>),
87    ResponseItem(ResponseItem),
88    EventMsg(EventMsg),
89    SessionState(Value),
90    Compacted(Value),
91    Unknown { kind: String, payload: Value },
92}
93
94// ── Session metadata ────────────────────────────────────────────────
95
96/// First line of every rollout file; `session_meta` payload.
97///
98/// Matches `SessionMeta` (+ git denormalization) from
99/// `codex-rs/protocol/src/protocol.rs`.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct SessionMeta {
102    /// UUIDv7 session id.
103    pub id: String,
104
105    /// ISO-8601 timestamp for session creation (distinct from the
106    /// outer line timestamp, which is the write time).
107    pub timestamp: String,
108
109    /// Working directory at session creation.
110    pub cwd: PathBuf,
111
112    /// Who launched Codex (`codex-tui`, `codex-exec`, IDE plugins).
113    pub originator: String,
114
115    pub cli_version: String,
116
117    /// Entry point: `cli`, `vscode`, etc.
118    pub source: String,
119
120    /// Parent session if this was forked (multi-agent).
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub forked_from_id: Option<String>,
123
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub agent_nickname: Option<String>,
126
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub agent_role: Option<String>,
129
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub agent_path: Option<String>,
132
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub model_provider: Option<String>,
135
136    /// Embedded system prompt for the session. Typically large (~20 KB).
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub base_instructions: Option<BaseInstructions>,
139
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub dynamic_tools: Option<Value>,
142
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub memory_mode: Option<String>,
145
146    /// Git state at session start. Populated when cwd is inside a repo.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub git: Option<GitInfo>,
149
150    /// Forward-compat catch-all.
151    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
152    pub extra: HashMap<String, Value>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct BaseInstructions {
157    pub text: String,
158    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
159    pub extra: HashMap<String, Value>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct GitInfo {
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub commit_hash: Option<String>,
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub branch: Option<String>,
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub repository_url: Option<String>,
170    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
171    pub extra: HashMap<String, Value>,
172}
173
174// ── Turn context ────────────────────────────────────────────────────
175
176/// Per-turn context snapshot.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct TurnContext {
179    pub turn_id: String,
180    pub cwd: PathBuf,
181
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub current_date: Option<String>,
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub timezone: Option<String>,
186
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub approval_policy: Option<String>,
189
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub sandbox_policy: Option<SandboxPolicy>,
192
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub model: Option<String>,
195
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub personality: Option<String>,
198
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub collaboration_mode: Option<Value>,
201
202    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
203    pub extra: HashMap<String, Value>,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct SandboxPolicy {
208    /// `read-only` | `workspace-write` | `danger-full-access`
209    #[serde(rename = "type")]
210    pub kind: String,
211    #[serde(default, skip_serializing_if = "Vec::is_empty")]
212    pub writable_roots: Vec<PathBuf>,
213    #[serde(default)]
214    pub network_access: bool,
215    #[serde(default)]
216    pub exclude_tmpdir_env_var: bool,
217    #[serde(default)]
218    pub exclude_slash_tmp: bool,
219    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
220    pub extra: HashMap<String, Value>,
221}
222
223// ── Response items (model output) ───────────────────────────────────
224
225/// Items emitted by the model — messages, reasoning, and tool calls.
226///
227/// Codex's upstream enum has more variants (`LocalShellCall`,
228/// `WebSearchCall`, `ImageGenerationCall`, etc.). We strongly-type
229/// the ones we see in the wild and leave the rest as raw `Value` via
230/// [`ResponseItem::Other`]. This keeps the crate future-proof: a new
231/// variant we don't handle still round-trips losslessly.
232#[derive(Debug, Clone)]
233pub enum ResponseItem {
234    Message(Message),
235    Reasoning(Reasoning),
236    FunctionCall(FunctionCall),
237    FunctionCallOutput(FunctionCallOutput),
238    CustomToolCall(CustomToolCall),
239    CustomToolCallOutput(CustomToolCallOutput),
240    Other { kind: String, payload: Value },
241}
242
243impl ResponseItem {
244    /// Dispatch on the inner `type` field.
245    pub fn from_value(payload: &Value) -> Self {
246        let kind = payload
247            .get("type")
248            .and_then(|t| t.as_str())
249            .unwrap_or("")
250            .to_string();
251        let attempt = match kind.as_str() {
252            "message" => serde_json::from_value::<Message>(payload.clone())
253                .ok()
254                .map(ResponseItem::Message),
255            "reasoning" => serde_json::from_value::<Reasoning>(payload.clone())
256                .ok()
257                .map(ResponseItem::Reasoning),
258            "function_call" => serde_json::from_value::<FunctionCall>(payload.clone())
259                .ok()
260                .map(ResponseItem::FunctionCall),
261            "function_call_output" => serde_json::from_value::<FunctionCallOutput>(payload.clone())
262                .ok()
263                .map(ResponseItem::FunctionCallOutput),
264            "custom_tool_call" => serde_json::from_value::<CustomToolCall>(payload.clone())
265                .ok()
266                .map(ResponseItem::CustomToolCall),
267            "custom_tool_call_output" => {
268                serde_json::from_value::<CustomToolCallOutput>(payload.clone())
269                    .ok()
270                    .map(ResponseItem::CustomToolCallOutput)
271            }
272            _ => None,
273        };
274        attempt.unwrap_or(ResponseItem::Other {
275            kind,
276            payload: payload.clone(),
277        })
278    }
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct Message {
283    /// `"developer"`, `"user"`, or `"assistant"`.
284    pub role: String,
285
286    pub content: Vec<ContentPart>,
287
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub id: Option<String>,
290
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub end_turn: Option<bool>,
293
294    /// `"commentary"`, `"final"`, etc. on assistant messages.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub phase: Option<String>,
297
298    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
299    pub extra: HashMap<String, Value>,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize)]
303#[serde(tag = "type", rename_all = "snake_case")]
304pub enum ContentPart {
305    InputText {
306        text: String,
307        #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
308        extra: HashMap<String, Value>,
309    },
310    OutputText {
311        text: String,
312        #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
313        extra: HashMap<String, Value>,
314    },
315    /// Forward-compat for new content types (images, etc.).
316    #[serde(other)]
317    Unknown,
318}
319
320impl ContentPart {
321    pub fn text(&self) -> Option<&str> {
322        match self {
323            ContentPart::InputText { text, .. } | ContentPart::OutputText { text, .. } => {
324                Some(text)
325            }
326            ContentPart::Unknown => None,
327        }
328    }
329}
330
331impl Message {
332    /// Flattened visible text of all parts, separated by newlines.
333    pub fn text(&self) -> String {
334        self.content
335            .iter()
336            .filter_map(|p| p.text())
337            .collect::<Vec<_>>()
338            .join("\n")
339    }
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct Reasoning {
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub id: Option<String>,
346
347    /// Public summary entries (often empty in production logs).
348    #[serde(default)]
349    pub summary: Vec<Value>,
350
351    /// Public reasoning content (often null).
352    #[serde(default)]
353    pub content: Option<Value>,
354
355    /// Opaque encrypted reasoning blob. Preserved verbatim for
356    /// round-trip fidelity.
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub encrypted_content: Option<String>,
359
360    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
361    pub extra: HashMap<String, Value>,
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct FunctionCall {
366    pub name: String,
367
368    /// Raw JSON string as written by the model. Intentionally not
369    /// eagerly parsed — some models emit malformed JSON and we want
370    /// byte-equivalent round-trip.
371    pub arguments: String,
372
373    pub call_id: String,
374
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub id: Option<String>,
377
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub namespace: Option<String>,
380
381    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
382    pub extra: HashMap<String, Value>,
383}
384
385impl FunctionCall {
386    /// Try to parse `arguments` as JSON; returns `Value::Null` on failure.
387    pub fn arguments_as_json(&self) -> Value {
388        serde_json::from_str(&self.arguments).unwrap_or(Value::Null)
389    }
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct FunctionCallOutput {
394    pub call_id: String,
395
396    /// Textual tool output (often a multi-line summary).
397    pub output: String,
398
399    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
400    pub extra: HashMap<String, Value>,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct CustomToolCall {
405    pub name: String,
406
407    /// Free-form input string (e.g. V4A patch body for `apply_patch`).
408    /// Not guaranteed to be JSON.
409    pub input: String,
410
411    pub call_id: String,
412
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub status: Option<String>,
415
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub id: Option<String>,
418
419    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
420    pub extra: HashMap<String, Value>,
421}
422
423#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct CustomToolCallOutput {
425    pub call_id: String,
426
427    pub output: String,
428
429    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
430    pub extra: HashMap<String, Value>,
431}
432
433// ── Event messages (CLI-side events) ────────────────────────────────
434
435/// Non-model events: task lifecycle, exec results, patch application,
436/// token accounting, and a long tail of less common variants. The
437/// crate strongly-types the common ones and captures the rest as raw
438/// `Value`.
439#[derive(Debug, Clone)]
440pub enum EventMsg {
441    TaskStarted(Value),
442    TaskComplete(Value),
443    UserMessage(Value),
444    AgentMessage(Value),
445    TokenCount(Box<TokenCountEvent>),
446    ExecCommandEnd(Box<ExecCommandEnd>),
447    PatchApplyEnd(Box<PatchApplyEnd>),
448    Other { kind: String, payload: Value },
449}
450
451impl EventMsg {
452    pub fn from_value(payload: &Value) -> Self {
453        let kind = payload
454            .get("type")
455            .and_then(|t| t.as_str())
456            .unwrap_or("")
457            .to_string();
458        let attempt = match kind.as_str() {
459            "task_started" => Some(EventMsg::TaskStarted(payload.clone())),
460            "task_complete" => Some(EventMsg::TaskComplete(payload.clone())),
461            "user_message" => Some(EventMsg::UserMessage(payload.clone())),
462            "agent_message" => Some(EventMsg::AgentMessage(payload.clone())),
463            "token_count" => serde_json::from_value::<TokenCountEvent>(payload.clone())
464                .ok()
465                .map(|v| EventMsg::TokenCount(Box::new(v))),
466            "exec_command_end" => serde_json::from_value::<ExecCommandEnd>(payload.clone())
467                .ok()
468                .map(|v| EventMsg::ExecCommandEnd(Box::new(v))),
469            "patch_apply_end" => serde_json::from_value::<PatchApplyEnd>(payload.clone())
470                .ok()
471                .map(|v| EventMsg::PatchApplyEnd(Box::new(v))),
472            _ => None,
473        };
474        attempt.unwrap_or(EventMsg::Other {
475            kind,
476            payload: payload.clone(),
477        })
478    }
479
480    /// The upstream `payload.type` tag value, regardless of variant.
481    pub fn kind(&self) -> &str {
482        match self {
483            EventMsg::TaskStarted(_) => "task_started",
484            EventMsg::TaskComplete(_) => "task_complete",
485            EventMsg::UserMessage(_) => "user_message",
486            EventMsg::AgentMessage(_) => "agent_message",
487            EventMsg::TokenCount(_) => "token_count",
488            EventMsg::ExecCommandEnd(_) => "exec_command_end",
489            EventMsg::PatchApplyEnd(_) => "patch_apply_end",
490            EventMsg::Other { kind, .. } => kind.as_str(),
491        }
492    }
493}
494
495#[derive(Debug, Clone, Serialize, Deserialize)]
496pub struct TokenCountEvent {
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub info: Option<TokenCountInfo>,
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub rate_limits: Option<Value>,
501    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
502    pub extra: HashMap<String, Value>,
503}
504
505#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct TokenCountInfo {
507    #[serde(default, skip_serializing_if = "Option::is_none")]
508    pub total_token_usage: Option<TokenUsage>,
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub last_token_usage: Option<TokenUsage>,
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    pub model_context_window: Option<u32>,
513    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
514    pub extra: HashMap<String, Value>,
515}
516
517#[derive(Debug, Clone, Default, Serialize, Deserialize)]
518pub struct TokenUsage {
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub input_tokens: Option<u32>,
521    #[serde(default, skip_serializing_if = "Option::is_none")]
522    pub cached_input_tokens: Option<u32>,
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    pub output_tokens: Option<u32>,
525    #[serde(default, skip_serializing_if = "Option::is_none")]
526    pub reasoning_output_tokens: Option<u32>,
527    #[serde(default, skip_serializing_if = "Option::is_none")]
528    pub total_tokens: Option<u32>,
529    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
530    pub extra: HashMap<String, Value>,
531}
532
533#[derive(Debug, Clone, Serialize, Deserialize)]
534pub struct ExecCommandEnd {
535    pub call_id: String,
536
537    #[serde(default, skip_serializing_if = "Option::is_none")]
538    pub process_id: Option<String>,
539
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub turn_id: Option<String>,
542
543    pub command: Vec<String>,
544
545    #[serde(default, skip_serializing_if = "Option::is_none")]
546    pub cwd: Option<PathBuf>,
547
548    #[serde(default)]
549    pub parsed_cmd: Vec<Value>,
550
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub source: Option<String>,
553
554    #[serde(default)]
555    pub stdout: String,
556
557    #[serde(default)]
558    pub stderr: String,
559
560    #[serde(default)]
561    pub aggregated_output: String,
562
563    #[serde(default, skip_serializing_if = "Option::is_none")]
564    pub exit_code: Option<i32>,
565
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub duration: Option<Value>,
568
569    #[serde(default, skip_serializing_if = "String::is_empty")]
570    pub formatted_output: String,
571
572    #[serde(default, skip_serializing_if = "Option::is_none")]
573    pub status: Option<String>,
574
575    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
576    pub extra: HashMap<String, Value>,
577}
578
579#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct PatchApplyEnd {
581    pub call_id: String,
582
583    #[serde(default, skip_serializing_if = "Option::is_none")]
584    pub turn_id: Option<String>,
585
586    #[serde(default)]
587    pub stdout: String,
588
589    #[serde(default)]
590    pub stderr: String,
591
592    #[serde(default)]
593    pub success: bool,
594
595    /// Per-file manifest. Keyed by absolute file path.
596    #[serde(default)]
597    pub changes: HashMap<String, PatchChange>,
598
599    #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
600    pub extra: HashMap<String, Value>,
601}
602
603/// One file's change within a [`PatchApplyEnd`]. Three `type` values
604/// documented upstream: `add`, `update`, `delete`. We strongly-type
605/// the common two (`add` has `content`, `update` has `unified_diff`
606/// and optional `move_path`) and leave room for more.
607#[derive(Debug, Clone, Serialize, Deserialize)]
608#[serde(tag = "type", rename_all = "snake_case")]
609pub enum PatchChange {
610    Add {
611        content: String,
612        #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
613        extra: HashMap<String, Value>,
614    },
615    Update {
616        unified_diff: String,
617        #[serde(default, skip_serializing_if = "Option::is_none")]
618        move_path: Option<String>,
619        #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
620        extra: HashMap<String, Value>,
621    },
622    Delete {
623        #[serde(default, skip_serializing_if = "Option::is_none")]
624        original_content: Option<String>,
625        #[serde(flatten, skip_serializing_if = "HashMap::is_empty", default)]
626        extra: HashMap<String, Value>,
627    },
628    /// Catch-all for future change types.
629    #[serde(other)]
630    Unknown,
631}
632
633// ── Logical session wrapping ────────────────────────────────────────
634
635/// A parsed session: the sequence of lines plus derived first-line
636/// metadata and a reference to the file on disk.
637#[derive(Debug, Clone)]
638pub struct Session {
639    pub id: String,
640    pub file_path: PathBuf,
641    pub lines: Vec<RolloutLine>,
642}
643
644impl Session {
645    /// The first `session_meta` item if one exists (Codex always
646    /// writes it as line 1, but we guard against truncation).
647    pub fn meta(&self) -> Option<SessionMeta> {
648        self.lines.iter().find_map(|l| match l.item() {
649            RolloutItem::SessionMeta(m) => Some(*m),
650            _ => None,
651        })
652    }
653
654    /// Iterate over typed items.
655    pub fn items(&self) -> impl Iterator<Item = RolloutItem> + '_ {
656        self.lines.iter().map(|l| l.item())
657    }
658
659    pub fn started_at(&self) -> Option<DateTime<Utc>> {
660        self.lines.iter().filter_map(|l| l.parsed_timestamp()).min()
661    }
662
663    pub fn last_activity(&self) -> Option<DateTime<Utc>> {
664        self.lines.iter().filter_map(|l| l.parsed_timestamp()).max()
665    }
666
667    /// First user-message text in the session, if any.
668    ///
669    /// Prefers `event_msg.user_message` — Codex emits that for genuine
670    /// user input as seen by the TUI. `response_item.message` with
671    /// `role: "user"` is less reliable: Codex routinely injects
672    /// synthetic user messages (`<environment_context>`, `AGENTS.md`
673    /// contents) ahead of the real prompt. Falls back to the first
674    /// `response_item` user message only when no `user_message` event
675    /// is present.
676    pub fn first_user_text(&self) -> Option<String> {
677        for line in &self.lines {
678            if line.kind == "event_msg"
679                && line.payload.get("type").and_then(|v| v.as_str()) == Some("user_message")
680                && let Some(msg) = line.payload.get("message").and_then(|v| v.as_str())
681                && !msg.is_empty()
682            {
683                return Some(msg.to_string());
684            }
685        }
686        self.items().find_map(|item| match item {
687            RolloutItem::ResponseItem(ResponseItem::Message(m)) if m.role == "user" => {
688                let t = m.text();
689                if t.is_empty() { None } else { Some(t) }
690            }
691            _ => None,
692        })
693    }
694}
695
696/// Lightweight session metadata (no full parse).
697#[derive(Debug, Clone, Serialize, Deserialize)]
698pub struct SessionMetadata {
699    pub id: String,
700    pub file_path: PathBuf,
701    pub started_at: Option<DateTime<Utc>>,
702    pub last_activity: Option<DateTime<Utc>>,
703    pub cwd: Option<PathBuf>,
704    pub cli_version: Option<String>,
705    pub first_user_message: Option<String>,
706    pub git_branch: Option<String>,
707    pub git_commit: Option<String>,
708    /// Total line count in the rollout file (approximates `message_count`).
709    pub line_count: usize,
710}
711
712// ── Tests ───────────────────────────────────────────────────────────
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    const SAMPLE_META: &str = r#"{"timestamp":"2026-04-20T16:44:37.772Z","type":"session_meta","payload":{"id":"019dabc6-8fef-7681-a054-b5bb75fcb97d","timestamp":"2026-04-20T16:43:30.171Z","cwd":"/tmp/proj","originator":"codex-tui","cli_version":"0.118.0","source":"cli","model_provider":"openai","git":{"commit_hash":"abc","branch":"main","repository_url":"git@example:x/y.git"}}}"#;
719
720    #[test]
721    fn rollout_line_parses_session_meta() {
722        let line: RolloutLine = serde_json::from_str(SAMPLE_META).unwrap();
723        assert_eq!(line.kind, "session_meta");
724        match line.item() {
725            RolloutItem::SessionMeta(m) => {
726                assert_eq!(m.id, "019dabc6-8fef-7681-a054-b5bb75fcb97d");
727                assert_eq!(m.cwd.to_str().unwrap(), "/tmp/proj");
728                assert_eq!(m.git.as_ref().unwrap().branch.as_deref(), Some("main"));
729            }
730            _ => panic!("expected SessionMeta"),
731        }
732    }
733
734    #[test]
735    fn rollout_line_preserves_unknown_kind() {
736        let raw = r#"{"timestamp":"2026-04-20T16:44:37.772Z","type":"new_future_type","payload":{"foo":"bar"}}"#;
737        let line: RolloutLine = serde_json::from_str(raw).unwrap();
738        match line.item() {
739            RolloutItem::Unknown { kind, payload } => {
740                assert_eq!(kind, "new_future_type");
741                assert_eq!(payload["foo"], "bar");
742            }
743            _ => panic!("expected Unknown"),
744        }
745    }
746
747    #[test]
748    fn response_item_message_variants() {
749        let raw = r#"{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}],"phase":"commentary"}"#;
750        let v: Value = serde_json::from_str(raw).unwrap();
751        match ResponseItem::from_value(&v) {
752            ResponseItem::Message(m) => {
753                assert_eq!(m.role, "assistant");
754                assert_eq!(m.text(), "hello");
755                assert_eq!(m.phase.as_deref(), Some("commentary"));
756            }
757            _ => panic!("expected Message"),
758        }
759    }
760
761    #[test]
762    fn response_item_reasoning_keeps_encrypted_content() {
763        let raw =
764            r#"{"type":"reasoning","summary":[],"content":null,"encrypted_content":"gAAA..."}"#;
765        let v: Value = serde_json::from_str(raw).unwrap();
766        match ResponseItem::from_value(&v) {
767            ResponseItem::Reasoning(r) => {
768                assert_eq!(r.encrypted_content.as_deref(), Some("gAAA..."));
769                assert!(r.summary.is_empty());
770            }
771            _ => panic!("expected Reasoning"),
772        }
773    }
774
775    #[test]
776    fn response_item_function_call_keeps_raw_arguments() {
777        let raw = r#"{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}","call_id":"call_1"}"#;
778        let v: Value = serde_json::from_str(raw).unwrap();
779        match ResponseItem::from_value(&v) {
780            ResponseItem::FunctionCall(c) => {
781                assert_eq!(c.name, "exec_command");
782                assert_eq!(c.call_id, "call_1");
783                assert_eq!(c.arguments_as_json()["cmd"], "pwd");
784            }
785            _ => panic!("expected FunctionCall"),
786        }
787    }
788
789    #[test]
790    fn response_item_unknown_survives() {
791        let raw = r#"{"type":"web_search_call","query":"rust","call_id":"c"}"#;
792        let v: Value = serde_json::from_str(raw).unwrap();
793        match ResponseItem::from_value(&v) {
794            ResponseItem::Other { kind, payload } => {
795                assert_eq!(kind, "web_search_call");
796                assert_eq!(payload["query"], "rust");
797            }
798            _ => panic!("expected Other"),
799        }
800    }
801
802    #[test]
803    fn event_msg_variants_dispatch() {
804        let token = serde_json::json!({
805            "type":"token_count",
806            "info":{"total_token_usage":{"input_tokens":100,"output_tokens":20,"total_tokens":120}}
807        });
808        match EventMsg::from_value(&token) {
809            EventMsg::TokenCount(tc) => {
810                let usage = tc
811                    .info
812                    .as_ref()
813                    .unwrap()
814                    .total_token_usage
815                    .as_ref()
816                    .unwrap();
817                assert_eq!(usage.input_tokens, Some(100));
818                assert_eq!(usage.output_tokens, Some(20));
819            }
820            _ => panic!("expected TokenCount"),
821        }
822    }
823
824    #[test]
825    fn patch_apply_end_change_variants() {
826        let add = r#"{"type":"add","content":"hello\n"}"#;
827        let pc: PatchChange = serde_json::from_str(add).unwrap();
828        assert!(matches!(pc, PatchChange::Add { .. }));
829
830        let upd = r#"{"type":"update","unified_diff":"@@\n-x\n+y"}"#;
831        let pc: PatchChange = serde_json::from_str(upd).unwrap();
832        assert!(matches!(pc, PatchChange::Update { .. }));
833    }
834
835    #[test]
836    fn patch_apply_end_unknown_change_type() {
837        let raw = r#"{"type":"rename","from":"a","to":"b"}"#;
838        let pc: PatchChange = serde_json::from_str(raw).unwrap();
839        assert!(matches!(pc, PatchChange::Unknown));
840    }
841
842    #[test]
843    fn session_meta_roundtrip() {
844        let line: RolloutLine = serde_json::from_str(SAMPLE_META).unwrap();
845        let back = serde_json::to_string(&line).unwrap();
846        let orig: Value = serde_json::from_str(SAMPLE_META).unwrap();
847        let back_v: Value = serde_json::from_str(&back).unwrap();
848        // Field-by-field equality (order-independent)
849        assert_eq!(orig, back_v);
850    }
851
852    #[test]
853    fn rollout_line_preserves_unknown_toplevel_field() {
854        let raw = r#"{"timestamp":"t","type":"session_meta","payload":{},"new_field":42}"#;
855        let line: RolloutLine = serde_json::from_str(raw).unwrap();
856        assert_eq!(line.extra.get("new_field"), Some(&serde_json::json!(42)));
857        let back = serde_json::to_string(&line).unwrap();
858        assert!(back.contains("new_field"));
859    }
860
861    #[test]
862    fn content_part_unknown_survives() {
863        let raw = r#"{"type":"image_url","url":"data:..."}"#;
864        let cp: ContentPart = serde_json::from_str(raw).unwrap();
865        assert!(matches!(cp, ContentPart::Unknown));
866    }
867
868    #[test]
869    fn message_multi_part_text() {
870        let m = Message {
871            role: "user".into(),
872            content: vec![
873                ContentPart::InputText {
874                    text: "one".into(),
875                    extra: Default::default(),
876                },
877                ContentPart::InputText {
878                    text: "two".into(),
879                    extra: Default::default(),
880                },
881            ],
882            id: None,
883            end_turn: None,
884            phase: None,
885            extra: Default::default(),
886        };
887        assert_eq!(m.text(), "one\ntwo");
888    }
889
890    fn session_from_lines(lines: &[&str]) -> Session {
891        let parsed: Vec<RolloutLine> = lines
892            .iter()
893            .map(|l| serde_json::from_str(l).unwrap())
894            .collect();
895        Session {
896            id: "s".to_string(),
897            file_path: PathBuf::from("/tmp/session.jsonl"),
898            lines: parsed,
899        }
900    }
901
902    #[test]
903    fn first_user_text_prefers_user_message_event() {
904        // Codex injects a synthetic `<environment_context>` user message
905        // ahead of the real prompt; the TUI-facing event_msg.user_message
906        // carries the real thing. first_user_text must return the latter.
907        let s = session_from_lines(&[
908            r#"{"timestamp":"t","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"<environment_context>\n<cwd>/x</cwd>\n</environment_context>"}]}}"#,
909            r#"{"timestamp":"t","type":"event_msg","payload":{"type":"user_message","message":"build me a thing"}}"#,
910            r#"{"timestamp":"t","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"build me a thing"}]}}"#,
911        ]);
912        assert_eq!(s.first_user_text().as_deref(), Some("build me a thing"));
913    }
914
915    #[test]
916    fn first_user_text_falls_back_when_no_user_message_event() {
917        // Sessions from CLI versions that don't emit user_message fall
918        // back to the first response_item user turn.
919        let s = session_from_lines(&[
920            r#"{"timestamp":"t","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}}"#,
921        ]);
922        assert_eq!(s.first_user_text().as_deref(), Some("hello"));
923    }
924
925    #[test]
926    fn first_user_text_ignores_empty_user_message_event() {
927        // An empty event_msg.user_message is not informative — fall back.
928        let s = session_from_lines(&[
929            r#"{"timestamp":"t","type":"event_msg","payload":{"type":"user_message","message":""}}"#,
930            r#"{"timestamp":"t","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"real prompt"}]}}"#,
931        ]);
932        assert_eq!(s.first_user_text().as_deref(), Some("real prompt"));
933    }
934}