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. This module
7//! is that adapter: [`ExecEvent::from_notification`] maps the lifecycle
8//! notifications renderers actually draw onto tagged, serde-stable events,
9//! and passes everything else through as [`ExecEvent::Raw`] with its wire
10//! method and params preserved — unknown future notifications degrade to
11//! raw, never disappear.
12
13use crate::messages::Notification;
14use crate::protocol_generated::types::{Thread, ThreadItem, ThreadTokenUsage, Turn, TurnError};
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18/// One renderable event, tagged in `codex exec` JSONL style.
19///
20/// Typed payloads (`ThreadItem`, `Turn`, …) are embedded directly — they
21/// already serialize to their wire shapes, so consumers get stable JSON
22/// without a re-serialization layer.
23// Thread/ThreadItem payloads dwarf Raw. Like the Notification enum itself,
24// this is a transient per-frame classification consumers unpack promptly;
25// boxing would tax every construction/match site for no retained-memory win.
26#[allow(clippy::large_enum_variant)]
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28#[serde(tag = "type")]
29pub enum ExecEvent {
30    #[serde(rename = "thread.started")]
31    ThreadStarted { thread: Thread },
32    #[serde(rename = "turn.started")]
33    TurnStarted { thread_id: String, turn: Turn },
34    #[serde(rename = "turn.completed")]
35    TurnCompleted { thread_id: String, turn: Turn },
36    /// A turn-scoped error (`error` notification) — the closest app-server
37    /// analog to exec's `turn.failed`.
38    #[serde(rename = "turn.failed")]
39    TurnFailed {
40        thread_id: String,
41        turn_id: String,
42        error: TurnError,
43    },
44    #[serde(rename = "item.started")]
45    ItemStarted {
46        thread_id: String,
47        started_at_ms: i64,
48        item: ThreadItem,
49    },
50    #[serde(rename = "item.completed")]
51    ItemCompleted {
52        thread_id: String,
53        completed_at_ms: i64,
54        item: ThreadItem,
55    },
56    /// Streamed agent-message text for the active item.
57    #[serde(rename = "item.agentMessage.delta")]
58    AgentMessageDelta {
59        thread_id: String,
60        item_id: String,
61        delta: String,
62    },
63    /// Per-turn token accounting (`thread/tokenUsage/updated`).
64    #[serde(rename = "thread.tokenUsage")]
65    TokenUsage {
66        thread_id: String,
67        turn_id: String,
68        token_usage: ThreadTokenUsage,
69    },
70    /// Any notification without a first-class mapping above — including
71    /// methods newer than these bindings. The wire method and params are
72    /// preserved verbatim so nothing is droppable-by-default.
73    #[serde(rename = "raw")]
74    Raw {
75        method: String,
76        #[serde(default, skip_serializing_if = "Option::is_none")]
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 round-trips through [`ExecEvent::Raw`].
85    pub fn from_notification(notification: Notification) -> ExecEvent {
86        match notification {
87            Notification::ThreadStarted(n) => ExecEvent::ThreadStarted { thread: n.thread },
88            Notification::TurnStarted(n) => ExecEvent::TurnStarted {
89                thread_id: n.thread_id,
90                turn: n.turn,
91            },
92            Notification::TurnCompleted(n) => ExecEvent::TurnCompleted {
93                thread_id: n.thread_id,
94                turn: n.turn,
95            },
96            Notification::Error(n) => ExecEvent::TurnFailed {
97                thread_id: n.thread_id,
98                turn_id: n.turn_id,
99                error: n.error,
100            },
101            Notification::ItemStarted(n) => ExecEvent::ItemStarted {
102                thread_id: n.thread_id,
103                started_at_ms: n.started_at_ms,
104                item: n.item,
105            },
106            Notification::ItemCompleted(n) => ExecEvent::ItemCompleted {
107                thread_id: n.thread_id,
108                completed_at_ms: n.completed_at_ms,
109                item: n.item,
110            },
111            Notification::AgentMessageDelta(n) => ExecEvent::AgentMessageDelta {
112                thread_id: n.thread_id,
113                item_id: n.item_id,
114                delta: n.delta,
115            },
116            Notification::ThreadTokenUsageUpdated(n) => ExecEvent::TokenUsage {
117                thread_id: n.thread_id,
118                turn_id: n.turn_id,
119                token_usage: n.token_usage,
120            },
121            other => {
122                let method = other.method().to_string();
123                match other.into_envelope() {
124                    Ok((_, params)) => ExecEvent::Raw { method, params },
125                    // Serialization of our own typed structs failing would be
126                    // a bindings bug; surface the method rather than nothing.
127                    Err(_) => ExecEvent::Raw {
128                        method,
129                        params: None,
130                    },
131                }
132            }
133        }
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    /// Lifecycle notifications map to exec-tagged events whose JSON shape
142    /// is stable (`type` tag in exec JSONL style).
143    #[test]
144    fn item_completed_maps_to_exec_tagged_json() {
145        let n = Notification::from_envelope(
146            "item/completed",
147            Some(serde_json::json!({
148                "threadId": "t-1",
149                "completedAtMs": 5,
150                "item": {"type": "agentMessage", "id": "i-1", "text": "hi"}
151            })),
152        )
153        .expect("typed notification");
154        let event = ExecEvent::from_notification(n);
155        let v = serde_json::to_value(&event).expect("serialize");
156        assert_eq!(v["type"], "item.completed");
157        assert_eq!(v["thread_id"], "t-1");
158        assert_eq!(v["item"]["type"], "agentMessage");
159        assert_eq!(v["item"]["text"], "hi");
160    }
161
162    /// Unknown methods pass through with method + params preserved — a
163    /// newer CLI's notification degrades to raw, never disappears.
164    #[test]
165    fn unknown_notification_passes_through_raw() {
166        let n = Notification::from_envelope("somefuture/thing", Some(serde_json::json!({"x": 1})))
167            .expect("unknown routes to Unknown without error");
168        let event = ExecEvent::from_notification(n);
169        match &event {
170            ExecEvent::Raw { method, params } => {
171                assert_eq!(method, "somefuture/thing");
172                assert_eq!(params.as_ref().unwrap()["x"], 1);
173            }
174            other => panic!("expected Raw, got {other:?}"),
175        }
176        let v = serde_json::to_value(&event).expect("serialize");
177        assert_eq!(v["type"], "raw");
178    }
179
180    /// A typed-but-unmapped notification (no first-class exec variant) also
181    /// rides Raw, keeping its wire method — nothing is droppable-by-default.
182    #[test]
183    fn typed_but_unmapped_notification_rides_raw_with_its_method() {
184        let n = Notification::from_envelope(
185            "thread/reverted",
186            Some(serde_json::json!({"threadId": "t-9"})),
187        )
188        .expect("typed");
189        match ExecEvent::from_notification(n) {
190            ExecEvent::Raw { method, params } => {
191                assert_eq!(method, "thread/reverted");
192                assert_eq!(params.unwrap()["threadId"], "t-9");
193            }
194            other => panic!("expected Raw, got {other:?}"),
195        }
196    }
197}