Skip to main content

supercode_harness/schema/
codex.rs

1//! Typed schema for Codex rollout JSONL (`~/.codex/sessions/.../rollout-*.jsonl`).
2//!
3//! Each line is an envelope `{timestamp, type, payload}`. The `type` selects
4//! the envelope kind; for `response_item` the inner `payload.type` selects the
5//! conversation item. Discriminants we don't model land in `Unknown` variants
6//! so the audit can enumerate them; unmodeled fields land in `extra` maps.
7
8use serde::Deserialize;
9
10use super::ExtraFields;
11
12/// One line of a Codex rollout file.
13#[derive(Debug, Clone, Deserialize)]
14pub struct CodexLine {
15    /// Envelope timestamp.
16    #[serde(default)]
17    pub timestamp: Option<String>,
18    /// The envelope, discriminated by its `type` field.
19    #[serde(flatten)]
20    pub record: CodexRecord,
21}
22
23/// A Codex envelope.
24#[derive(Debug, Clone, Deserialize)]
25#[serde(tag = "type", rename_all = "snake_case")]
26pub enum CodexRecord {
27    /// Session header (id, cwd, base instructions, …).
28    SessionMeta {
29        /// The header payload.
30        payload: SessionMeta,
31    },
32    /// Per-turn context (carries the model, sandbox/approval policy, …).
33    TurnContext {
34        /// The turn-context payload (loosely modeled).
35        payload: TurnContext,
36    },
37    /// A canonical conversation item.
38    ResponseItem {
39        /// The conversation payload, discriminated by its own `type`.
40        payload: ResponseItem,
41    },
42    /// A UI event echo (token counts, task lifecycle, streamed renderings).
43    /// These duplicate `response_item` content and are not part of the
44    /// canonical conversation, but we name the payload type for completeness.
45    EventMsg {
46        /// The event payload.
47        payload: EventMsg,
48    },
49    /// A whole-history compaction record (replaces earlier turns).
50    Compacted {
51        /// The compaction payload.
52        #[serde(default)]
53        payload: serde_json::Value,
54    },
55    /// Any envelope type we do not model yet.
56    #[serde(other)]
57    Unknown,
58}
59
60impl CodexRecord {
61    /// The static discriminant name, or `None` for [`CodexRecord::Unknown`].
62    pub fn tag(&self) -> Option<&'static str> {
63        Some(match self {
64            CodexRecord::SessionMeta { .. } => "session_meta",
65            CodexRecord::TurnContext { .. } => "turn_context",
66            CodexRecord::ResponseItem { .. } => "response_item",
67            CodexRecord::EventMsg { .. } => "event_msg",
68            CodexRecord::Compacted { .. } => "compacted",
69            CodexRecord::Unknown => return None,
70        })
71    }
72}
73
74/// Codex `session_meta` payload.
75#[derive(Debug, Clone, Deserialize)]
76pub struct SessionMeta {
77    /// Session id.
78    #[serde(default)]
79    pub id: Option<String>,
80    /// Working directory.
81    #[serde(default)]
82    pub cwd: Option<String>,
83    /// Base/system instructions (string or `{text}`).
84    #[serde(default)]
85    pub base_instructions: Option<serde_json::Value>,
86    /// Model provider, e.g. `openai`.
87    #[serde(default)]
88    pub model_provider: Option<String>,
89    /// Anything not modeled above.
90    #[serde(flatten)]
91    pub extra: ExtraFields,
92}
93
94/// Codex `turn_context` payload (loosely modeled — many policy fields).
95#[derive(Debug, Clone, Deserialize)]
96pub struct TurnContext {
97    /// The model for this turn.
98    #[serde(default)]
99    pub model: Option<String>,
100    /// Working directory for this turn.
101    #[serde(default)]
102    pub cwd: Option<String>,
103    /// Everything else (approval policy, sandbox policy, effort, …).
104    #[serde(flatten)]
105    pub extra: ExtraFields,
106}
107
108/// A canonical conversation item (`response_item` payload).
109#[derive(Debug, Clone, Deserialize)]
110#[serde(tag = "type", rename_all = "snake_case")]
111pub enum ResponseItem {
112    /// A chat message turn.
113    Message {
114        /// `user` / `assistant` / `developer` / `system`.
115        role: String,
116        /// Content blocks.
117        #[serde(default)]
118        content: Vec<super::ContentBlock>,
119        /// Anything else (e.g. `phase`).
120        #[serde(flatten)]
121        extra: ExtraFields,
122    },
123    /// A built-in function (tool) call.
124    FunctionCall {
125        /// Tool name.
126        name: String,
127        /// JSON-encoded argument string.
128        #[serde(default)]
129        arguments: String,
130        /// Provider call id.
131        call_id: String,
132        /// Anything else.
133        #[serde(flatten)]
134        extra: ExtraFields,
135    },
136    /// The output of a function call.
137    FunctionCallOutput {
138        /// The call id this answers.
139        call_id: String,
140        /// Output (string or structured).
141        #[serde(default)]
142        output: serde_json::Value,
143        /// Anything else.
144        #[serde(flatten)]
145        extra: ExtraFields,
146    },
147    /// A custom / MCP tool call. **Not yet normalized by the loader.**
148    CustomToolCall {
149        /// Tool name.
150        #[serde(default)]
151        name: Option<String>,
152        /// JSON-encoded arguments.
153        #[serde(default)]
154        input: serde_json::Value,
155        /// Call id.
156        #[serde(default)]
157        call_id: Option<String>,
158        /// Anything else.
159        #[serde(flatten)]
160        extra: ExtraFields,
161    },
162    /// A custom / MCP tool result. **Not yet normalized by the loader.**
163    CustomToolCallOutput {
164        /// The call id this answers.
165        #[serde(default)]
166        call_id: Option<String>,
167        /// Output.
168        #[serde(default)]
169        output: serde_json::Value,
170        /// Anything else.
171        #[serde(flatten)]
172        extra: ExtraFields,
173    },
174    /// Model reasoning. Not folded 1:1 into a `ChatMessage` (there's no
175    /// canonical "reasoning" role), but `summary` text, the raw `content`
176    /// chain-of-thought text when genuinely non-null, and a correctly-gated
177    /// `encrypted_content` flag (only when that field is non-null — a
178    /// present-but-`null` key, which every real rollout carries, must NOT
179    /// set the flag) ARE captured by
180    /// `crate::session::Session::from_codex_str` onto the next assistant
181    /// message's metadata (`reasoning`/`reasoning_content`/
182    /// `reasoning_encrypted`), or flushed as their own message when no
183    /// following assistant turn exists to attach to — see
184    /// `crate::audit::Coverage::Retained` (D5/N1/N2/N3, PARITY-12). The
185    /// opaque `encrypted_content` blob itself is not replayed cross-model.
186    Reasoning {
187        /// Anything (summary, encrypted_content, …).
188        #[serde(flatten)]
189        extra: ExtraFields,
190    },
191    /// A web-search server tool call. **Not yet normalized.**
192    WebSearchCall {
193        /// Anything.
194        #[serde(flatten)]
195        extra: ExtraFields,
196    },
197    /// A tool-search call. **Not yet normalized.**
198    ToolSearchCall {
199        /// Anything.
200        #[serde(flatten)]
201        extra: ExtraFields,
202    },
203    /// A tool-search result. **Not yet normalized.**
204    ToolSearchOutput {
205        /// Anything.
206        #[serde(flatten)]
207        extra: ExtraFields,
208    },
209    /// An image-generation call. **Not yet normalized.**
210    ImageGenerationCall {
211        /// Anything.
212        #[serde(flatten)]
213        extra: ExtraFields,
214    },
215    /// Any response-item type we do not model yet.
216    #[serde(other)]
217    Unknown,
218}
219
220impl ResponseItem {
221    /// The static discriminant name, or `None` for [`ResponseItem::Unknown`].
222    pub fn tag(&self) -> Option<&'static str> {
223        Some(match self {
224            ResponseItem::Message { .. } => "message",
225            ResponseItem::FunctionCall { .. } => "function_call",
226            ResponseItem::FunctionCallOutput { .. } => "function_call_output",
227            ResponseItem::CustomToolCall { .. } => "custom_tool_call",
228            ResponseItem::CustomToolCallOutput { .. } => "custom_tool_call_output",
229            ResponseItem::Reasoning { .. } => "reasoning",
230            ResponseItem::WebSearchCall { .. } => "web_search_call",
231            ResponseItem::ToolSearchCall { .. } => "tool_search_call",
232            ResponseItem::ToolSearchOutput { .. } => "tool_search_output",
233            ResponseItem::ImageGenerationCall { .. } => "image_generation_call",
234            ResponseItem::Unknown => return None,
235        })
236    }
237
238    /// Whether the loader currently normalizes this item into a [`crate::ChatMessage`].
239    pub fn is_normalized(&self) -> bool {
240        matches!(
241            self,
242            ResponseItem::Message { .. }
243                | ResponseItem::FunctionCall { .. }
244                | ResponseItem::FunctionCallOutput { .. }
245                | ResponseItem::CustomToolCall { .. }
246                | ResponseItem::CustomToolCallOutput { .. }
247                | ResponseItem::ToolSearchCall { .. }
248                | ResponseItem::ToolSearchOutput { .. }
249                | ResponseItem::WebSearchCall { .. }
250                | ResponseItem::ImageGenerationCall { .. }
251        )
252    }
253}
254
255/// A UI event echo. We only need its discriminant for the audit.
256#[derive(Debug, Clone, Deserialize)]
257pub struct EventMsg {
258    /// The event subtype (`token_count`, `task_started`, …).
259    #[serde(rename = "type", default)]
260    pub kind: Option<String>,
261    /// Anything else.
262    #[serde(flatten)]
263    pub extra: ExtraFields,
264}