Skip to main content

agent_teams/backend/
codex_protocol.rs

1//! Lightweight JSON-RPC types for the Codex `app-server` stdio protocol.
2//!
3//! Codex communicates over stdin/stdout using newline-delimited JSON messages.
4//! The protocol is JSON-RPC-like but does **not** include the `"jsonrpc": "2.0"` field.
5//!
6//! Protocol flow:
7//! 1. Client sends `initialize` request → server responds with `{userAgent}`
8//! 2. Client sends `initialized` notification (no `id`)
9//! 3. Client sends `thread/start` → server responds with `{thread: {id, ...}, model, ...}`
10//! 4. Client sends `turn/start` with `{threadId, input}` → server responds + streams events
11//! 5. Events: `turn/started`, `item/started`, `item/agentMessage/delta`, `item/completed`, `turn/completed`
12
13use serde::{Deserialize, Serialize};
14
15// ---------------------------------------------------------------------------
16// JSON-RPC wire types (Codex variant -- no `jsonrpc` field)
17// ---------------------------------------------------------------------------
18
19/// A request (client -> server). Has an `id` for correlation.
20#[derive(Debug, Serialize)]
21pub struct JsonRpcRequest {
22    /// Correlation ID (number or string).
23    pub id: serde_json::Value,
24    /// Method name.
25    pub method: String,
26    /// Parameters (required for most methods).
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub params: Option<serde_json::Value>,
29}
30
31impl JsonRpcRequest {
32    /// Build a new request with a numeric `id`.
33    pub fn new(id: u64, method: &str, params: Option<serde_json::Value>) -> Self {
34        Self {
35            id: serde_json::Value::Number(id.into()),
36            method: method.into(),
37            params,
38        }
39    }
40}
41
42/// A client-to-server notification (no `id`, no response expected).
43#[derive(Debug, Serialize)]
44pub struct JsonRpcClientNotification {
45    /// Method name.
46    pub method: String,
47}
48
49impl JsonRpcClientNotification {
50    pub fn new(method: &str) -> Self {
51        Self {
52            method: method.into(),
53        }
54    }
55}
56
57/// A response (server -> client). Has an `id` matching the request.
58#[derive(Debug, Deserialize)]
59pub struct JsonRpcResponse {
60    /// Correlation ID that matches the request.
61    pub id: serde_json::Value,
62    /// Successful result (mutually exclusive with `error`).
63    #[serde(default)]
64    pub result: Option<serde_json::Value>,
65    /// Error payload (mutually exclusive with `result`).
66    #[serde(default)]
67    pub error: Option<JsonRpcError>,
68}
69
70/// JSON-RPC error object.
71#[derive(Debug, Deserialize)]
72pub struct JsonRpcError {
73    /// Numeric error code.
74    pub code: i64,
75    /// Human-readable error message.
76    pub message: String,
77    /// Optional structured error data.
78    #[serde(default)]
79    pub data: Option<serde_json::Value>,
80}
81
82impl std::fmt::Display for JsonRpcError {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        write!(f, "JSON-RPC error {}: {}", self.code, self.message)
85    }
86}
87
88/// A server-to-client notification (no `id` field).
89#[derive(Debug, Deserialize)]
90pub struct JsonRpcNotification {
91    /// Notification method.
92    pub method: String,
93    /// Optional parameters.
94    #[serde(default)]
95    pub params: Option<serde_json::Value>,
96}
97
98// ---------------------------------------------------------------------------
99// Envelope: unifies Response and Notification for line-parsing
100// ---------------------------------------------------------------------------
101
102/// A line from Codex stdout can be either a response (has `id`) or a notification (no `id`).
103#[derive(Debug, Deserialize)]
104#[serde(untagged)]
105pub enum JsonRpcMessage {
106    /// A response to a previously sent request.
107    Response(JsonRpcResponse),
108    /// An unsolicited notification from the server.
109    Notification(JsonRpcNotification),
110}
111
112// ---------------------------------------------------------------------------
113// Method constants (client -> server requests)
114// ---------------------------------------------------------------------------
115
116/// Initialize handshake.
117pub const METHOD_INITIALIZE: &str = "initialize";
118/// Client notification sent after initialize response.
119pub const METHOD_INITIALIZED: &str = "initialized";
120/// Start a new thread (conversation).
121pub const METHOD_THREAD_START: &str = "thread/start";
122/// Start a new turn within a thread.
123pub const METHOD_TURN_START: &str = "turn/start";
124/// Interrupt the current turn.
125pub const METHOD_TURN_INTERRUPT: &str = "turn/interrupt";
126
127// ---------------------------------------------------------------------------
128// Server notification events
129// ---------------------------------------------------------------------------
130
131/// Thread has been started/loaded.
132pub const EVENT_THREAD_STARTED: &str = "thread/started";
133/// A turn has started processing.
134pub const EVENT_TURN_STARTED: &str = "turn/started";
135/// A turn has completed.
136pub const EVENT_TURN_COMPLETED: &str = "turn/completed";
137/// An output item has started.
138pub const EVENT_ITEM_STARTED: &str = "item/started";
139/// A streaming text delta from the agent.
140pub const EVENT_AGENT_MESSAGE_DELTA: &str = "item/agentMessage/delta";
141/// An output item has completed.
142pub const EVENT_ITEM_COMPLETED: &str = "item/completed";
143/// Command execution output delta.
144pub const EVENT_COMMAND_OUTPUT_DELTA: &str = "item/commandExecution/outputDelta";
145/// Error notification.
146pub const EVENT_ERROR: &str = "error";
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn serialize_request() {
154        let req = JsonRpcRequest::new(
155            1,
156            METHOD_INITIALIZE,
157            Some(serde_json::json!({"clientInfo": {"name": "test", "version": "0.1.0"}})),
158        );
159        let json = serde_json::to_string(&req).unwrap();
160        assert!(json.contains(r#""id":1"#));
161        assert!(json.contains(r#""method":"initialize""#));
162        // No jsonrpc field in Codex protocol
163        assert!(!json.contains("jsonrpc"));
164    }
165
166    #[test]
167    fn serialize_client_notification() {
168        let notif = JsonRpcClientNotification::new(METHOD_INITIALIZED);
169        let json = serde_json::to_string(&notif).unwrap();
170        assert!(json.contains(r#""method":"initialized""#));
171        assert!(!json.contains("id"));
172    }
173
174    #[test]
175    fn deserialize_response_ok() {
176        let json = r#"{"id":1,"result":{"userAgent":"agent-teams/0.87.0"}}"#;
177        let resp: JsonRpcResponse = serde_json::from_str(json).unwrap();
178        assert!(resp.error.is_none());
179        assert_eq!(
180            resp.result.unwrap()["userAgent"].as_str().unwrap(),
181            "agent-teams/0.87.0"
182        );
183    }
184
185    #[test]
186    fn deserialize_response_err() {
187        let json = r#"{"id":2,"error":{"code":-32600,"message":"bad request"}}"#;
188        let resp: JsonRpcResponse = serde_json::from_str(json).unwrap();
189        assert!(resp.result.is_none());
190        let err = resp.error.unwrap();
191        assert_eq!(err.code, -32600);
192    }
193
194    #[test]
195    fn deserialize_notification() {
196        let json =
197            r#"{"method":"item/agentMessage/delta","params":{"delta":"hello","threadId":"t1","turnId":"0","itemId":"i1"}}"#;
198        let notif: JsonRpcNotification = serde_json::from_str(json).unwrap();
199        assert_eq!(notif.method, EVENT_AGENT_MESSAGE_DELTA);
200        assert_eq!(notif.params.unwrap()["delta"].as_str().unwrap(), "hello");
201    }
202
203    #[test]
204    fn deserialize_envelope_response() {
205        let json = r#"{"id":1,"result":null}"#;
206        let msg: JsonRpcMessage = serde_json::from_str(json).unwrap();
207        assert!(matches!(msg, JsonRpcMessage::Response(_)));
208    }
209
210    #[test]
211    fn deserialize_envelope_notification() {
212        let json = r#"{"method":"turn/completed","params":{"threadId":"t1","turn":{"id":"0","items":[],"status":"completed"}}}"#;
213        let msg: JsonRpcMessage = serde_json::from_str(json).unwrap();
214        assert!(matches!(msg, JsonRpcMessage::Notification(_)));
215    }
216
217    #[test]
218    fn deserialize_thread_start_response() {
219        let json = r#"{"id":2,"result":{"thread":{"id":"abc-123","preview":"","modelProvider":"openai","createdAt":1700000000,"path":"/tmp","source":"cli","turns":[],"cwd":"/tmp","cliVersion":"0.87.0"},"model":"gpt-4","modelProvider":"openai","cwd":"/tmp","approvalPolicy":"never","sandbox":{"type":"workspaceWrite"}}}"#;
220        let resp: JsonRpcResponse = serde_json::from_str(json).unwrap();
221        let result = resp.result.unwrap();
222        let thread_id = result["thread"]["id"].as_str().unwrap();
223        assert_eq!(thread_id, "abc-123");
224    }
225
226    #[test]
227    fn deserialize_turn_start_response() {
228        let json = r#"{"id":3,"result":{"turn":{"id":"0","items":[],"status":"inProgress","error":null}}}"#;
229        let resp: JsonRpcResponse = serde_json::from_str(json).unwrap();
230        let result = resp.result.unwrap();
231        let turn_id = result["turn"]["id"].as_str().unwrap();
232        assert_eq!(turn_id, "0");
233    }
234}