Skip to main content

codex_codes/
events.rs

1//! Exec-style event view over app-server notifications (issue #213).
2//!
3//! Downstream renderers (agent-portal's codex-session-lib was the filing
4//! consumer) want a stable, serializable event stream in the shape of the
5//! CLI's `codex exec` JSONL — without hand-rolling a synthetic struct per
6//! notification and re-serializing typed items back into JSON.
7//!
8//! The serialized contract (settled with that consumer before adoption,
9//! and matching real exec JSONL, which is FLAT — `thread.started` carries
10//! `thread_id` top-level, `turn.completed` lifts `turn_id`/`status`/
11//! `duration_ms`):
12//!
13//! - **Lifecycle events use dotted exec tags** (`thread.started`,
14//!   `turn.completed`, `item.started`, …) with snake_case event-level
15//!   fields lifted flat, plus the full typed payload riding along
16//!   (`thread:`/`turn:`) for consumers that want more than the flat keys.
17//! - **Everything else serializes as a forwarded notification**:
18//!   `{"type": "<slash-form method>", "params": <inner payload>}` — e.g.
19//!   `turn/diff/updated`, `item/agentMessage/delta`. The dot/slash split is
20//!   load-bearing: dots are thread events, slashes are verbatim app-server
21//!   notifications.
22//! - **Item payloads embed the typed [`ThreadItem`]**, which serializes in
23//!   the app-server's camelCase item shape (`"commandExecution"`, not
24//!   exec's `"command_execution"`).
25//! - **Turn errors arrive as `turn.failed`**, never as a bare `error` tag —
26//!   that tag stays free for host-level errors (the portal claims it).
27//! - **`item.updated` is absent by construction**: the exec format has the
28//!   tag, but app-server 0.147 has no item-update notification to map from
29//!   (`item/fileChange/patchUpdated` is the closest, and rides the slash
30//!   passthrough). A consumer synthesizing `item.updated` locally keeps
31//!   doing so.
32
33use crate::messages::Notification;
34use crate::protocol_generated::types::{Thread, ThreadItem, Turn, TurnError, TurnStatus};
35use serde::de::Error as _;
36use serde::{Deserialize, Deserializer, Serialize, Serializer};
37use serde_json::Value;
38
39/// One renderable event. See the module docs for the serialized contract.
40// Thread/ThreadItem payloads dwarf Raw. Like the Notification enum itself,
41// this is a transient per-frame classification consumers unpack promptly;
42// boxing would tax every construction/match site for no retained-memory win.
43#[allow(clippy::large_enum_variant)]
44#[derive(Debug, Clone, PartialEq)]
45pub enum ExecEvent {
46    /// `thread.started` — `thread_id` flat (exec shape), full typed thread
47    /// riding along.
48    ThreadStarted { thread_id: String, thread: Thread },
49    /// `turn.started`
50    TurnStarted { thread_id: String, turn: Turn },
51    /// `turn.completed` — `turn_id`/`status`/`duration_ms` lifted flat
52    /// (exec shape); the full typed turn rides along.
53    TurnCompleted { thread_id: String, turn: Turn },
54    /// `turn.failed` — a turn-scoped `error` notification.
55    TurnFailed {
56        thread_id: String,
57        turn_id: String,
58        error: TurnError,
59    },
60    /// `item.started`
61    ItemStarted {
62        thread_id: String,
63        started_at_ms: i64,
64        item: ThreadItem,
65    },
66    /// `item.completed`
67    ItemCompleted {
68        thread_id: String,
69        completed_at_ms: i64,
70        item: ThreadItem,
71    },
72    /// Any other notification, serialized as the proxy-forwarded shape
73    /// `{"type": "<method>", "params": …}` — slash tags preserved, params
74    /// verbatim. Unknown future methods land here, never disappear.
75    Raw {
76        method: String,
77        params: Option<Value>,
78    },
79}
80
81impl ExecEvent {
82    /// Map one notification onto its exec-style event. Never fails and
83    /// never drops: lifecycle notifications get first-class variants,
84    /// everything else rides [`ExecEvent::Raw`] under its wire method.
85    pub fn from_notification(notification: Notification) -> ExecEvent {
86        match notification {
87            Notification::ThreadStarted(n) => ExecEvent::ThreadStarted {
88                thread_id: n.thread.id.clone(),
89                thread: n.thread,
90            },
91            Notification::TurnStarted(n) => ExecEvent::TurnStarted {
92                thread_id: n.thread_id,
93                turn: n.turn,
94            },
95            Notification::TurnCompleted(n) => ExecEvent::TurnCompleted {
96                thread_id: n.thread_id,
97                turn: n.turn,
98            },
99            Notification::Error(n) => ExecEvent::TurnFailed {
100                thread_id: n.thread_id,
101                turn_id: n.turn_id,
102                error: n.error,
103            },
104            Notification::ItemStarted(n) => ExecEvent::ItemStarted {
105                thread_id: n.thread_id,
106                started_at_ms: n.started_at_ms,
107                item: n.item,
108            },
109            Notification::ItemCompleted(n) => ExecEvent::ItemCompleted {
110                thread_id: n.thread_id,
111                completed_at_ms: n.completed_at_ms,
112                item: n.item,
113            },
114            other => {
115                let method = other.method().to_string();
116                match other.into_envelope() {
117                    Ok((_, params)) => ExecEvent::Raw { method, params },
118                    // Serialization of our own typed structs failing would be
119                    // a bindings bug; surface the method rather than nothing.
120                    Err(_) => ExecEvent::Raw {
121                        method,
122                        params: None,
123                    },
124                }
125            }
126        }
127    }
128
129    /// The serialized `type` tag this event carries.
130    pub fn tag(&self) -> &str {
131        match self {
132            ExecEvent::ThreadStarted { .. } => "thread.started",
133            ExecEvent::TurnStarted { .. } => "turn.started",
134            ExecEvent::TurnCompleted { .. } => "turn.completed",
135            ExecEvent::TurnFailed { .. } => "turn.failed",
136            ExecEvent::ItemStarted { .. } => "item.started",
137            ExecEvent::ItemCompleted { .. } => "item.completed",
138            ExecEvent::Raw { method, .. } => method,
139        }
140    }
141}
142
143/// Wire form of the dotted lifecycle events (everything except `Raw`,
144/// whose tag is dynamic and therefore hand-serialized).
145#[allow(clippy::large_enum_variant)]
146#[derive(Serialize, Deserialize)]
147#[serde(tag = "type")]
148enum LifecycleWire {
149    #[serde(rename = "thread.started")]
150    ThreadStarted { thread_id: String, thread: Thread },
151    #[serde(rename = "turn.started")]
152    TurnStarted { thread_id: String, turn: Turn },
153    #[serde(rename = "turn.completed")]
154    TurnCompleted {
155        thread_id: String,
156        turn_id: String,
157        status: TurnStatus,
158        #[serde(default, skip_serializing_if = "Option::is_none")]
159        duration_ms: Option<i64>,
160        turn: Turn,
161    },
162    #[serde(rename = "turn.failed")]
163    TurnFailed {
164        thread_id: String,
165        turn_id: String,
166        error: TurnError,
167    },
168    #[serde(rename = "item.started")]
169    ItemStarted {
170        thread_id: String,
171        started_at_ms: i64,
172        item: ThreadItem,
173    },
174    #[serde(rename = "item.completed")]
175    ItemCompleted {
176        thread_id: String,
177        completed_at_ms: i64,
178        item: ThreadItem,
179    },
180}
181
182impl Serialize for ExecEvent {
183    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
184        match self {
185            ExecEvent::Raw { method, params } => {
186                use serde::ser::SerializeMap;
187                let mut map = serializer.serialize_map(None)?;
188                map.serialize_entry("type", method)?;
189                if let Some(params) = params {
190                    map.serialize_entry("params", params)?;
191                }
192                map.end()
193            }
194            ExecEvent::TurnCompleted { thread_id, turn } => LifecycleWire::TurnCompleted {
195                thread_id: thread_id.clone(),
196                turn_id: turn.id.clone(),
197                status: turn.status.clone(),
198                duration_ms: turn.duration_ms,
199                turn: turn.clone(),
200            }
201            .serialize(serializer),
202            ExecEvent::ThreadStarted { thread_id, thread } => LifecycleWire::ThreadStarted {
203                thread_id: thread_id.clone(),
204                thread: thread.clone(),
205            }
206            .serialize(serializer),
207            ExecEvent::TurnStarted { thread_id, turn } => LifecycleWire::TurnStarted {
208                thread_id: thread_id.clone(),
209                turn: turn.clone(),
210            }
211            .serialize(serializer),
212            ExecEvent::TurnFailed {
213                thread_id,
214                turn_id,
215                error,
216            } => LifecycleWire::TurnFailed {
217                thread_id: thread_id.clone(),
218                turn_id: turn_id.clone(),
219                error: error.clone(),
220            }
221            .serialize(serializer),
222            ExecEvent::ItemStarted {
223                thread_id,
224                started_at_ms,
225                item,
226            } => LifecycleWire::ItemStarted {
227                thread_id: thread_id.clone(),
228                started_at_ms: *started_at_ms,
229                item: item.clone(),
230            }
231            .serialize(serializer),
232            ExecEvent::ItemCompleted {
233                thread_id,
234                completed_at_ms,
235                item,
236            } => LifecycleWire::ItemCompleted {
237                thread_id: thread_id.clone(),
238                completed_at_ms: *completed_at_ms,
239                item: item.clone(),
240            }
241            .serialize(serializer),
242        }
243    }
244}
245
246impl<'de> Deserialize<'de> for ExecEvent {
247    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
248        let value = Value::deserialize(deserializer)?;
249        let tag = value
250            .get("type")
251            .and_then(|t| t.as_str())
252            .ok_or_else(|| D::Error::missing_field("type"))?;
253        match tag {
254            "thread.started" | "turn.started" | "turn.completed" | "turn.failed"
255            | "item.started" | "item.completed" => {
256                let wire: LifecycleWire =
257                    serde_json::from_value(value).map_err(D::Error::custom)?;
258                Ok(match wire {
259                    LifecycleWire::ThreadStarted { thread_id, thread } => {
260                        ExecEvent::ThreadStarted { thread_id, thread }
261                    }
262                    LifecycleWire::TurnStarted { thread_id, turn } => {
263                        ExecEvent::TurnStarted { thread_id, turn }
264                    }
265                    LifecycleWire::TurnCompleted {
266                        thread_id, turn, ..
267                    } => ExecEvent::TurnCompleted { thread_id, turn },
268                    LifecycleWire::TurnFailed {
269                        thread_id,
270                        turn_id,
271                        error,
272                    } => ExecEvent::TurnFailed {
273                        thread_id,
274                        turn_id,
275                        error,
276                    },
277                    LifecycleWire::ItemStarted {
278                        thread_id,
279                        started_at_ms,
280                        item,
281                    } => ExecEvent::ItemStarted {
282                        thread_id,
283                        started_at_ms,
284                        item,
285                    },
286                    LifecycleWire::ItemCompleted {
287                        thread_id,
288                        completed_at_ms,
289                        item,
290                    } => ExecEvent::ItemCompleted {
291                        thread_id,
292                        completed_at_ms,
293                        item,
294                    },
295                })
296            }
297            method => Ok(ExecEvent::Raw {
298                method: method.to_string(),
299                params: value.get("params").cloned(),
300            }),
301        }
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    /// The consumer contract, pinned: `thread.started` carries `thread_id`
310    /// flat; `turn.completed` lifts `turn_id`/`status`/`duration_ms` —
311    /// the fields renderers (and real exec JSONL) put top-level.
312    #[test]
313    fn lifecycle_events_carry_the_consumer_fields_flat() {
314        let n = Notification::from_envelope(
315            "turn/completed",
316            Some(serde_json::json!({
317                "threadId": "t-1",
318                "turn": {"id": "turn-9", "status": "completed", "durationMs": 42,
319                          "items": [], "threadId": "t-1"}
320            })),
321        )
322        .expect("typed");
323        let v = serde_json::to_value(ExecEvent::from_notification(n)).expect("serialize");
324        assert_eq!(v["type"], "turn.completed");
325        assert_eq!(v["turn_id"], "turn-9");
326        assert_eq!(v["status"], "completed");
327        assert_eq!(v["duration_ms"], 42);
328        assert_eq!(v["thread_id"], "t-1");
329    }
330
331    /// `thread.started` exposes the flat `thread_id` consumers key on.
332    #[test]
333    fn thread_started_is_flat() {
334        let n = Notification::from_envelope(
335            "thread/started",
336            Some(serde_json::json!({
337                "thread": {"id": "t-7", "status": {"type": "idle"}, "items": [],
338                            "cliVersion": "0.147.0", "createdAt": 1, "updatedAt": 1,
339                            "cwd": "/", "ephemeral": false, "originator": "test",
340                            "preset": null, "modelSlug": "m", "turns": []}
341            })),
342        )
343        .expect("typed");
344        let v = serde_json::to_value(ExecEvent::from_notification(n)).expect("serialize");
345        assert_eq!(v["type"], "thread.started");
346        assert_eq!(v["thread_id"], "t-7");
347        assert_eq!(v["thread"]["id"], "t-7");
348    }
349
350    /// Item payloads serialize in the app-server camelCase item shape —
351    /// the settled answer to the `commandExecution` vs `command_execution`
352    /// double-match downstreams carry today.
353    #[test]
354    fn item_events_use_dotted_tags_and_camel_item_types() {
355        let n = Notification::from_envelope(
356            "item/completed",
357            Some(serde_json::json!({
358                "threadId": "t-1",
359                "completedAtMs": 5,
360                "item": {"type": "agentMessage", "id": "i-1", "text": "hi"}
361            })),
362        )
363        .expect("typed");
364        let v = serde_json::to_value(ExecEvent::from_notification(n)).expect("serialize");
365        assert_eq!(v["type"], "item.completed");
366        assert_eq!(v["item"]["type"], "agentMessage");
367    }
368
369    /// Non-lifecycle notifications serialize as the proxy-forwarded shape:
370    /// slash-form method as the tag, params verbatim — the dot/slash split
371    /// consumers key on is preserved exactly, so passthrough renderers
372    /// (`turn/diff/updated` etc.) work unchanged.
373    #[test]
374    fn forwarded_notifications_keep_slash_tags_and_params() {
375        // Typed methods re-serialize through their param structs, so use a
376        // real field and assert it survives under `params`.
377        let n = Notification::from_envelope(
378            "turn/diff/updated",
379            Some(serde_json::json!({"threadId": "t-1", "turnId": "u-1", "diff": "+x"})),
380        )
381        .expect("routes");
382        let v = serde_json::to_value(ExecEvent::from_notification(n)).expect("serialize");
383        assert_eq!(
384            v["type"], "turn/diff/updated",
385            "slash tag survives verbatim"
386        );
387        assert_eq!(
388            v["params"]["diff"], "+x",
389            "typed params ride under 'params'"
390        );
391
392        // Unknown methods carry their params verbatim.
393        let n = Notification::from_envelope("somefuture/thing", Some(serde_json::json!({"x": 1})))
394            .expect("routes to Unknown");
395        let v = serde_json::to_value(ExecEvent::from_notification(n)).expect("serialize");
396        assert_eq!(v["type"], "somefuture/thing");
397        assert_eq!(v["params"]["x"], 1);
398
399        // And the other three passthrough renderers' tags stay slash-form.
400        for method in [
401            "item/fileChange/patchUpdated",
402            "turn/plan/updated",
403            "item/plan/delta",
404            "item/agentMessage/delta",
405        ] {
406            let n =
407                Notification::from_envelope(method, Some(serde_json::json!({}))).expect("routes");
408            let v = serde_json::to_value(ExecEvent::from_notification(n)).expect("serialize");
409            assert_eq!(v["type"], method, "slash tag survives verbatim");
410        }
411    }
412
413    /// A turn error arrives as `turn.failed` with the typed error under
414    /// `error` — the bare `error` tag is never emitted (it belongs to the
415    /// host layer; the portal claims it for its own errors).
416    #[test]
417    fn turn_errors_are_turn_failed_never_bare_error() {
418        let n = Notification::from_envelope(
419            "error",
420            Some(serde_json::json!({
421                "threadId": "t-1", "turnId": "turn-1",
422                "error": {"message": "boom"}
423            })),
424        )
425        .expect("typed");
426        let v = serde_json::to_value(ExecEvent::from_notification(n)).expect("serialize");
427        assert_eq!(v["type"], "turn.failed");
428        assert_eq!(v["error"]["message"], "boom");
429    }
430
431    /// Round trip: serialize → deserialize lands back in the same variant,
432    /// including dynamic-tag Raw.
433    #[test]
434    fn events_round_trip_including_dynamic_raw_tags() {
435        let raw = ExecEvent::Raw {
436            method: "somefuture/thing".into(),
437            params: Some(serde_json::json!({"x": 1})),
438        };
439        let v = serde_json::to_value(&raw).expect("serialize");
440        let back: ExecEvent = serde_json::from_value(v).expect("deserialize");
441        assert_eq!(back, raw);
442    }
443}