codex_codes/client_sync.rs
1//! Synchronous multi-turn client for the Codex app-server.
2//!
3//! Spawns `codex app-server --listen stdio://` and communicates over
4//! newline-delimited JSON-RPC. The connection stays open for multiple
5//! turns until explicitly shut down.
6//!
7//! This is the blocking counterpart to [`crate::client_async::AsyncClient`].
8//! Prefer the async client for applications that already use tokio.
9//!
10//! # Lifecycle
11//!
12//! 1. Create a client with [`SyncClient::start`] (spawns and initializes the app-server)
13//! 2. Call [`SyncClient::thread_start`] to create a conversation session
14//! 3. Call [`SyncClient::turn_start`] to send user input
15//! 4. Iterate over [`SyncClient::events`] until `turn/completed`
16//! 5. Handle approval requests via [`SyncClient::respond`]
17//! 6. Repeat steps 3-5 for follow-up turns
18//!
19//! # Example
20//!
21//! ```ignore
22//! use codex_codes::{SyncClient, ThreadStartParams, TurnStartParams, UserInput, ServerMessage};
23//!
24//! let mut client = SyncClient::start()?;
25//! let thread = client.thread_start(&ThreadStartParams::default())?;
26//!
27//! client.turn_start(&TurnStartParams {
28//! thread_id: thread.thread_id().to_string(),
29//! input: vec![UserInput::Text { text: "Hello!".into() }],
30//! model: None,
31//! reasoning_effort: None,
32//! sandbox_policy: None,
33//! })?;
34//!
35//! for result in client.events() {
36//! match result? {
37//! ServerMessage::Notification(n) => {
38//! if let codex_codes::Notification::TurnCompleted(_) = n { break; }
39//! }
40//! _ => {}
41//! }
42//! }
43//! ```
44
45use crate::cli::AppServerBuilder;
46use crate::error::{Error, ParseError, Result};
47use crate::jsonrpc::{
48 JsonRpcError, JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, RequestId,
49};
50use crate::messages::{Notification, ServerMessage, ServerRequest};
51use crate::protocol::{
52 ClientInfo, InitializeParams, InitializeResponse, ThreadArchiveParams, ThreadArchiveResponse,
53 ThreadDeleteParams, ThreadDeleteResponse, ThreadForkParams, ThreadForkResponse,
54 ThreadResumeParams, ThreadResumeResponse, ThreadStartParams, ThreadStartResponse,
55 TurnInterruptParams, TurnInterruptResponse, TurnStartParams, TurnStartResponse,
56};
57use log::{debug, warn};
58use serde::de::DeserializeOwned;
59use serde::Serialize;
60use std::collections::VecDeque;
61use std::io::{BufRead, BufReader, BufWriter, Write};
62use std::process::Child;
63
64/// Buffer size for reading stdout (10MB).
65const STDOUT_BUFFER_SIZE: usize = 10 * 1024 * 1024;
66
67/// Synchronous multi-turn client for the Codex app-server.
68///
69/// Communicates with a long-lived `codex app-server` process via
70/// newline-delimited JSON-RPC over stdio. Manages request/response
71/// correlation and buffers incoming notifications that arrive while
72/// waiting for RPC responses.
73///
74/// The client automatically kills the app-server process when dropped.
75pub struct SyncClient {
76 child: Child,
77 writer: BufWriter<std::process::ChildStdin>,
78 reader: BufReader<std::process::ChildStdout>,
79 /// Handle to the background thread draining the child's stderr pipe.
80 /// Kept alive for the lifetime of the client; the thread exits on EOF
81 /// when the child is killed.
82 _stderr_drain: std::thread::JoinHandle<()>,
83 next_id: i64,
84 buffered: VecDeque<ServerMessage>,
85}
86
87impl SyncClient {
88 /// Create a client from an existing child process.
89 ///
90 /// The child's stdin, stdout, and stderr must all be piped. This does not
91 /// perform the app-server `initialize` handshake or a Codex version check.
92 /// Use [`AppServerBuilder::build_command_sync`] to retain the SDK's
93 /// command-line and stdio configuration while customizing how the process
94 /// is spawned.
95 pub fn new(mut child: Child) -> Result<Self> {
96 let stdin = child
97 .stdin
98 .take()
99 .ok_or_else(|| Error::Protocol("Failed to get stdin".to_string()))?;
100 let stdout = child
101 .stdout
102 .take()
103 .ok_or_else(|| Error::Protocol("Failed to get stdout".to_string()))?;
104 let stderr = child
105 .stderr
106 .take()
107 .ok_or_else(|| Error::Protocol("Failed to get stderr".to_string()))?;
108
109 // The app-server emits ~200 KB/s of tracing to stderr. Without an
110 // active reader, the ~64 KB kernel pipe fills almost instantly and
111 // the child blocks. Drain in the background and route lines through
112 // the `log` crate (see [`crate::stderr_drain`]).
113 let stderr_drain = crate::stderr_drain::spawn_sync(stderr);
114
115 Ok(Self {
116 child,
117 writer: BufWriter::new(stdin),
118 reader: BufReader::with_capacity(STDOUT_BUFFER_SIZE, stdout),
119 _stderr_drain: stderr_drain,
120 next_id: 1,
121 buffered: VecDeque::new(),
122 })
123 }
124
125 /// Start an app-server with default settings.
126 ///
127 /// Spawns `codex app-server --listen stdio://`, performs the required
128 /// `initialize` handshake, and returns a connected client ready for
129 /// `thread_start()`.
130 ///
131 /// # Errors
132 ///
133 /// Returns an error if the `codex` CLI is not installed, the version is
134 /// incompatible, the process fails to start, or the initialization
135 /// handshake fails.
136 pub fn start() -> Result<Self> {
137 Self::start_with(AppServerBuilder::new())
138 }
139
140 /// Start an app-server with a custom [`AppServerBuilder`].
141 ///
142 /// Performs the required `initialize` handshake before returning.
143 /// Use this to configure the binary path, working directory, environment,
144 /// or CLI arguments.
145 ///
146 /// # Errors
147 ///
148 /// Returns an error if the process fails to start, stdio pipes
149 /// cannot be established, or the initialization handshake fails.
150 pub fn start_with(builder: AppServerBuilder) -> Result<Self> {
151 let mut client = Self::spawn(builder)?;
152 client.initialize(&InitializeParams {
153 client_info: ClientInfo {
154 name: "codex-codes".to_string(),
155 version: env!("CARGO_PKG_VERSION").to_string(),
156 title: None,
157 },
158 capabilities: None,
159 })?;
160 Ok(client)
161 }
162
163 /// Spawn an app-server without performing the `initialize` handshake.
164 ///
165 /// Use this if you need to send a custom [`InitializeParams`] (e.g., with
166 /// specific capabilities). You **must** call [`SyncClient::initialize`]
167 /// before any other requests.
168 pub fn spawn(builder: AppServerBuilder) -> Result<Self> {
169 crate::version::check_codex_version()?;
170 Self::new(builder.spawn_sync()?)
171 }
172
173 /// Send a JSON-RPC request and wait for the matching response.
174 ///
175 /// Any notifications or server requests that arrive before the response
176 /// are buffered and can be retrieved via [`SyncClient::next_message`].
177 ///
178 /// # Errors
179 ///
180 /// - [`Error::JsonRpc`] if the server returns a JSON-RPC error
181 /// - [`Error::ServerClosed`] if the connection drops before a response arrives
182 /// - [`Error::Json`] if response deserialization fails
183 pub fn request<P: Serialize, R: DeserializeOwned>(
184 &mut self,
185 method: &str,
186 params: &P,
187 ) -> Result<R> {
188 let id = RequestId::Integer(self.next_id);
189 self.next_id += 1;
190
191 let req = JsonRpcRequest {
192 id: id.clone(),
193 method: method.to_string(),
194 params: Some(serde_json::to_value(params).map_err(Error::Json)?),
195 };
196
197 self.send_raw(&req)?;
198
199 loop {
200 let msg = self.read_message()?;
201 match msg {
202 JsonRpcMessage::Response(resp) if resp.id == id => {
203 let result: R = serde_json::from_value(resp.result).map_err(Error::Json)?;
204 return Ok(result);
205 }
206 JsonRpcMessage::Error(err) if err.id == id => {
207 return Err(Error::JsonRpc {
208 code: err.error.code,
209 message: err.error.message,
210 });
211 }
212 JsonRpcMessage::Notification(notif) => {
213 let typed = Notification::from_envelope(¬if.method, notif.params)
214 .map_err(Error::Json)?;
215 self.buffered.push_back(ServerMessage::Notification(typed));
216 }
217 JsonRpcMessage::Request(req) => {
218 let typed = ServerRequest::from_envelope(&req.method, req.params)
219 .map_err(Error::Json)?;
220 self.buffered.push_back(ServerMessage::Request {
221 id: req.id,
222 request: typed,
223 });
224 }
225 JsonRpcMessage::Response(resp) => {
226 warn!(
227 "[CLIENT] Unexpected response for id={}, expected id={}",
228 resp.id, id
229 );
230 }
231 JsonRpcMessage::Error(err) => {
232 warn!(
233 "[CLIENT] Unexpected error for id={}, expected id={}",
234 err.id, id
235 );
236 }
237 }
238 }
239 }
240
241 /// Start a new thread (conversation session).
242 ///
243 /// A thread must be created before any turns can be started. The returned
244 /// [`ThreadStartResponse`] contains the `thread_id` needed for subsequent calls.
245 pub fn thread_start(&mut self, params: &ThreadStartParams) -> Result<ThreadStartResponse> {
246 self.request(crate::protocol::methods::THREAD_START, params)
247 }
248
249 /// Resume a previously persisted thread by id.
250 ///
251 /// Replays the thread's history so turns can continue where they left off.
252 pub fn thread_resume(&mut self, params: &ThreadResumeParams) -> Result<ThreadResumeResponse> {
253 self.request(crate::protocol::methods::THREAD_RESUME, params)
254 }
255
256 /// Fork an existing thread into a new independent thread.
257 pub fn thread_fork(&mut self, params: &ThreadForkParams) -> Result<ThreadForkResponse> {
258 self.request(crate::protocol::methods::THREAD_FORK, params)
259 }
260
261 /// Start a new turn within a thread.
262 ///
263 /// Sends user input to the agent. After calling this, use [`SyncClient::events`]
264 /// or [`SyncClient::next_message`] to consume notifications until `turn/completed`.
265 pub fn turn_start(&mut self, params: &TurnStartParams) -> Result<TurnStartResponse> {
266 self.request(crate::protocol::methods::TURN_START, params)
267 }
268
269 /// Interrupt an active turn.
270 pub fn turn_interrupt(
271 &mut self,
272 params: &TurnInterruptParams,
273 ) -> Result<TurnInterruptResponse> {
274 self.request(crate::protocol::methods::TURN_INTERRUPT, params)
275 }
276
277 /// Archive a thread.
278 pub fn thread_archive(
279 &mut self,
280 params: &ThreadArchiveParams,
281 ) -> Result<ThreadArchiveResponse> {
282 self.request(crate::protocol::methods::THREAD_ARCHIVE, params)
283 }
284
285 /// Delete a thread.
286 pub fn thread_delete(&mut self, params: &ThreadDeleteParams) -> Result<ThreadDeleteResponse> {
287 self.request(crate::protocol::methods::THREAD_DELETE, params)
288 }
289
290 /// Perform the `initialize` handshake with the app-server.
291 ///
292 /// Sends `initialize` with the given params and then sends the
293 /// `initialized` notification. This must be the first request after
294 /// spawning the process.
295 pub fn initialize(&mut self, params: &InitializeParams) -> Result<InitializeResponse> {
296 let resp: InitializeResponse =
297 self.request(crate::protocol::methods::INITIALIZE, params)?;
298 self.send_notification(crate::protocol::methods::INITIALIZED)?;
299 Ok(resp)
300 }
301
302 /// Respond to a server-to-client request (e.g., approval flow).
303 ///
304 /// When the server sends a [`ServerMessage::Request`], it expects a response.
305 /// Use this method with the request's `id` and a result payload. For command
306 /// approval, pass a [`CommandExecutionApprovalResponse`](crate::CommandExecutionApprovalResponse).
307 /// For file change approval, pass a [`FileChangeApprovalResponse`](crate::FileChangeApprovalResponse).
308 pub fn respond<R: Serialize>(&mut self, id: RequestId, result: &R) -> Result<()> {
309 let resp = JsonRpcResponse {
310 id,
311 result: serde_json::to_value(result).map_err(Error::Json)?,
312 };
313 self.send_raw(&resp)
314 }
315
316 /// Respond to a server-to-client request with an error.
317 pub fn respond_error(&mut self, id: RequestId, code: i64, message: &str) -> Result<()> {
318 let err = JsonRpcError {
319 id,
320 error: crate::jsonrpc::JsonRpcErrorData {
321 code,
322 message: message.to_string(),
323 data: None,
324 },
325 };
326 self.send_raw(&err)
327 }
328
329 /// Read the next incoming server message (notification or server request).
330 ///
331 /// Returns buffered messages first (from notifications that arrived during
332 /// a [`SyncClient::request`] call), then reads from the wire.
333 ///
334 /// Returns `Ok(None)` when the app-server closes the connection (EOF).
335 pub fn next_message(&mut self) -> Result<Option<ServerMessage>> {
336 if let Some(msg) = self.buffered.pop_front() {
337 return Ok(Some(msg));
338 }
339
340 loop {
341 let msg = match self.read_message_opt()? {
342 Some(m) => m,
343 None => return Ok(None),
344 };
345
346 match msg {
347 JsonRpcMessage::Notification(notif) => {
348 let JsonRpcNotification { method, params } = notif;
349 let typed =
350 Notification::from_envelope(&method, params.clone()).map_err(|e| {
351 Error::Deserialization(ParseError::from_envelope(method, params, e))
352 })?;
353 return Ok(Some(ServerMessage::Notification(typed)));
354 }
355 JsonRpcMessage::Request(req) => {
356 let JsonRpcRequest { id, method, params } = req;
357 let typed =
358 ServerRequest::from_envelope(&method, params.clone()).map_err(|e| {
359 Error::Deserialization(ParseError::from_envelope(method, params, e))
360 })?;
361 return Ok(Some(ServerMessage::Request { id, request: typed }));
362 }
363 JsonRpcMessage::Response(resp) => {
364 warn!(
365 "[CLIENT] Unexpected response (no pending request): id={}",
366 resp.id
367 );
368 }
369 JsonRpcMessage::Error(err) => {
370 warn!(
371 "[CLIENT] Unexpected error (no pending request): id={} code={}",
372 err.id, err.error.code
373 );
374 }
375 }
376 }
377 }
378
379 /// Return an iterator over [`ServerMessage`]s.
380 ///
381 /// The iterator yields `Result<ServerMessage>` and terminates when the
382 /// connection closes (EOF). This is the idiomatic way to consume a turn's
383 /// notifications in synchronous code.
384 pub fn events(&mut self) -> EventIterator<'_> {
385 EventIterator { client: self }
386 }
387
388 /// Shut down the child process.
389 ///
390 /// Kills the process if it's still running. Called automatically on [`Drop`].
391 pub fn shutdown(&mut self) -> Result<()> {
392 debug!("[CLIENT] Shutting down");
393 match self.child.try_wait() {
394 Ok(Some(_)) => Ok(()),
395 Ok(None) => {
396 self.child.kill().map_err(Error::Io)?;
397 self.child.wait().map_err(Error::Io)?;
398 Ok(())
399 }
400 Err(e) => Err(Error::Io(e)),
401 }
402 }
403
404 // -- internal --
405
406 fn send_notification(&mut self, method: &str) -> Result<()> {
407 let notif = JsonRpcNotification {
408 method: method.to_string(),
409 params: None,
410 };
411 self.send_raw(¬if)
412 }
413
414 fn send_raw<T: Serialize>(&mut self, msg: &T) -> Result<()> {
415 let json = serde_json::to_string(msg).map_err(Error::Json)?;
416 debug!("[CLIENT] Sending: {}", json);
417 self.writer.write_all(json.as_bytes()).map_err(Error::Io)?;
418 self.writer.write_all(b"\n").map_err(Error::Io)?;
419 self.writer.flush().map_err(Error::Io)?;
420 Ok(())
421 }
422
423 fn read_message(&mut self) -> Result<JsonRpcMessage> {
424 self.read_message_opt()?.ok_or(Error::ServerClosed)
425 }
426
427 fn read_message_opt(&mut self) -> Result<Option<JsonRpcMessage>> {
428 loop {
429 let mut line = String::new();
430 match self.reader.read_line(&mut line) {
431 Ok(0) => {
432 debug!("[CLIENT] Stream closed (EOF)");
433 return Ok(None);
434 }
435 Ok(_) => {
436 let trimmed = line.trim();
437 if trimmed.is_empty() {
438 continue;
439 }
440
441 debug!("[CLIENT] Received: {}", trimmed);
442
443 match serde_json::from_str::<JsonRpcMessage>(trimmed) {
444 Ok(msg) => return Ok(Some(msg)),
445 Err(e) => {
446 warn!(
447 "[CLIENT] Failed to deserialize message. \
448 Please report this at https://github.com/meawoppl/rust-code-agent-sdks/issues"
449 );
450 warn!("[CLIENT] Parse error: {}", e);
451 warn!("[CLIENT] Raw: {}", trimmed);
452 return Err(Error::Deserialization(ParseError::from_line(trimmed, e)));
453 }
454 }
455 }
456 Err(e) => {
457 debug!("[CLIENT] Error reading stdout: {}", e);
458 return Err(Error::Io(e));
459 }
460 }
461 }
462 }
463}
464
465impl Drop for SyncClient {
466 fn drop(&mut self) {
467 if let Err(e) = self.shutdown() {
468 debug!("[CLIENT] Error during shutdown: {}", e);
469 }
470 }
471}
472
473/// Iterator over [`ServerMessage`]s from a [`SyncClient`].
474pub struct EventIterator<'a> {
475 client: &'a mut SyncClient,
476}
477
478impl Iterator for EventIterator<'_> {
479 type Item = Result<ServerMessage>;
480
481 fn next(&mut self) -> Option<Self::Item> {
482 match self.client.next_message() {
483 Ok(Some(msg)) => Some(Ok(msg)),
484 Ok(None) => None,
485 Err(e) => Some(Err(e)),
486 }
487 }
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493
494 #[test]
495 fn test_buffer_size() {
496 assert_eq!(STDOUT_BUFFER_SIZE, 10 * 1024 * 1024);
497 }
498}