codex_codes/client_async.rs
1//! Asynchronous 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//! # Lifecycle
8//!
9//! 1. Create a client with [`AsyncClient::start`] (spawns and initializes the app-server)
10//! 2. Call [`AsyncClient::thread_start`] to create a conversation session
11//! 3. Call [`AsyncClient::turn_start`] to send user input
12//! 4. Consume [`AsyncClient::next_message`] to stream notifications
13//! 5. Handle approval requests via [`AsyncClient::respond`]
14//! 6. Repeat steps 3-5 for follow-up turns
15//! 7. The client kills the app-server on [`Drop`]
16//!
17//! # Example
18//!
19//! ```ignore
20//! use codex_codes::{AsyncClient, ThreadStartParams, TurnStartParams, UserInput, ServerMessage};
21//!
22//! let mut client = AsyncClient::start().await?;
23//! let thread = client.thread_start(&ThreadStartParams::default()).await?;
24//!
25//! client.turn_start(&TurnStartParams {
26//! thread_id: thread.thread_id().to_string(),
27//! input: vec![UserInput::Text { text: "Hello!".into() }],
28//! model: None,
29//! reasoning_effort: None,
30//! sandbox_policy: None,
31//! }).await?;
32//!
33//! while let Some(msg) = client.next_message().await? {
34//! match msg {
35//! ServerMessage::Notification(n) => {
36//! if let codex_codes::Notification::TurnCompleted(_) = n { break; }
37//! }
38//! ServerMessage::Request { id, .. } => {
39//! client.respond(id, &serde_json::json!({"decision": "accept"})).await?;
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 crate::protocol_generated::types::{
58 CancelLoginAccountParams, CancelLoginAccountResponse, GetAccountParams,
59 GetAccountRateLimitsResponse, GetAccountResponse, GetAccountTokenUsageResponse,
60 LoginAccountParams, LoginAccountResponse, LogoutAccountResponse,
61};
62use log::{debug, error, warn};
63use serde::de::DeserializeOwned;
64use serde::Serialize;
65use std::collections::VecDeque;
66use std::sync::atomic::{AtomicI64, Ordering};
67use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
68use tokio::process::Child;
69
70/// Buffer size for reading stdout (10MB).
71const STDOUT_BUFFER_SIZE: usize = 10 * 1024 * 1024;
72
73/// Asynchronous multi-turn client for the Codex app-server.
74///
75/// Communicates with a long-lived `codex app-server` process via
76/// newline-delimited JSON-RPC over stdio. Manages request/response
77/// correlation and buffers incoming notifications that arrive while
78/// waiting for RPC responses.
79///
80/// The client automatically kills the app-server process when dropped.
81pub struct AsyncClient {
82 child: Child,
83 writer: BufWriter<tokio::process::ChildStdin>,
84 reader: BufReader<tokio::process::ChildStdout>,
85 /// Handle to the background task draining the child's stderr pipe.
86 /// Kept alive for the lifetime of the client; the task exits on EOF
87 /// when the child is killed.
88 _stderr_drain: tokio::task::JoinHandle<()>,
89 next_id: AtomicI64,
90 /// Buffered incoming messages (notifications/server requests) that arrived
91 /// while waiting for a response to a client request.
92 buffered: VecDeque<ServerMessage>,
93}
94
95impl AsyncClient {
96 /// Create a client from an existing Tokio child process.
97 ///
98 /// The child's stdin, stdout, and stderr must all be piped. This does not
99 /// perform the app-server `initialize` handshake or a Codex version check.
100 /// Use [`AppServerBuilder::build_command`] to retain the SDK's command-line
101 /// and stdio configuration while customizing how the process is spawned.
102 pub fn new(mut child: Child) -> Result<Self> {
103 let stdin = child
104 .stdin
105 .take()
106 .ok_or_else(|| Error::Protocol("Failed to get stdin".to_string()))?;
107 let stdout = child
108 .stdout
109 .take()
110 .ok_or_else(|| Error::Protocol("Failed to get stdout".to_string()))?;
111 let stderr = child
112 .stderr
113 .take()
114 .ok_or_else(|| Error::Protocol("Failed to get stderr".to_string()))?;
115
116 // The app-server emits ~200 KB/s of tracing to stderr. Without an
117 // active reader, the ~64 KB kernel pipe fills almost instantly and
118 // the child blocks. Drain in the background and route lines through
119 // the `log` crate (see [`crate::stderr_drain`]).
120 let stderr_drain = crate::stderr_drain::spawn_async(stderr);
121
122 Ok(Self {
123 child,
124 writer: BufWriter::new(stdin),
125 reader: BufReader::with_capacity(STDOUT_BUFFER_SIZE, stdout),
126 _stderr_drain: stderr_drain,
127 next_id: AtomicI64::new(1),
128 buffered: VecDeque::new(),
129 })
130 }
131
132 /// Start an app-server with default settings.
133 ///
134 /// Spawns `codex app-server --listen stdio://`, performs the required
135 /// `initialize` handshake, and returns a connected client ready for
136 /// `thread_start()`.
137 ///
138 /// # Errors
139 ///
140 /// Returns an error if the `codex` CLI is not installed, the version is
141 /// incompatible, the process fails to start, or the initialization
142 /// handshake fails.
143 pub async fn start() -> Result<Self> {
144 Self::start_with(AppServerBuilder::new()).await
145 }
146
147 /// Start an app-server with a custom [`AppServerBuilder`].
148 ///
149 /// Performs the required `initialize` handshake before returning.
150 /// Use this to configure the binary path, working directory, environment,
151 /// or CLI arguments.
152 ///
153 /// # Errors
154 ///
155 /// Returns an error if the process fails to start, stdio pipes
156 /// cannot be established, or the initialization handshake fails.
157 pub async fn start_with(builder: AppServerBuilder) -> Result<Self> {
158 let mut client = Self::spawn(builder).await?;
159 client
160 .initialize(&InitializeParams {
161 client_info: ClientInfo {
162 name: "codex-codes".to_string(),
163 version: env!("CARGO_PKG_VERSION").to_string(),
164 title: None,
165 },
166 capabilities: None,
167 })
168 .await?;
169 Ok(client)
170 }
171
172 /// Spawn an app-server without performing the `initialize` handshake.
173 ///
174 /// Use this if you need to send a custom [`InitializeParams`] (e.g., with
175 /// specific capabilities). You **must** call [`AsyncClient::initialize`]
176 /// before any other requests.
177 pub async fn spawn(builder: AppServerBuilder) -> Result<Self> {
178 crate::version::check_codex_version_async().await?;
179 Self::new(builder.spawn().await?)
180 }
181
182 /// Send a JSON-RPC request and wait for the matching response.
183 ///
184 /// Any notifications or server requests that arrive before the response
185 /// are buffered and can be retrieved via [`AsyncClient::next_message`].
186 ///
187 /// # Errors
188 ///
189 /// - [`Error::JsonRpc`] if the server returns a JSON-RPC error
190 /// - [`Error::ServerClosed`] if the connection drops before a response arrives
191 /// - [`Error::Json`] if response deserialization fails
192 pub async fn request<P: Serialize, R: DeserializeOwned>(
193 &mut self,
194 method: &str,
195 params: &P,
196 ) -> Result<R> {
197 let id = RequestId::Integer(self.next_id.fetch_add(1, Ordering::Relaxed));
198
199 let req = JsonRpcRequest {
200 id: id.clone(),
201 method: method.to_string(),
202 params: Some(serde_json::to_value(params).map_err(Error::Json)?),
203 };
204
205 self.send_raw(&req).await?;
206
207 // Read lines until we get a response matching our id
208 loop {
209 let msg = self.read_message().await?;
210 match msg {
211 JsonRpcMessage::Response(resp) if resp.id == id => {
212 let result: R = serde_json::from_value(resp.result).map_err(Error::Json)?;
213 return Ok(result);
214 }
215 JsonRpcMessage::Error(err) if err.id == id => {
216 return Err(Error::JsonRpc {
217 code: err.error.code,
218 message: err.error.message,
219 });
220 }
221 // Buffer notifications and server requests
222 JsonRpcMessage::Notification(notif) => {
223 let typed = Notification::from_envelope(¬if.method, notif.params)
224 .map_err(Error::Json)?;
225 self.buffered.push_back(ServerMessage::Notification(typed));
226 }
227 JsonRpcMessage::Request(req) => {
228 let typed = ServerRequest::from_envelope(&req.method, req.params)
229 .map_err(Error::Json)?;
230 self.buffered.push_back(ServerMessage::Request {
231 id: req.id,
232 request: typed,
233 });
234 }
235 // Response/error for a different id — unexpected
236 JsonRpcMessage::Response(resp) => {
237 warn!(
238 "[CLIENT] Unexpected response for id={}, expected id={}",
239 resp.id, id
240 );
241 }
242 JsonRpcMessage::Error(err) => {
243 warn!(
244 "[CLIENT] Unexpected error for id={}, expected id={}",
245 err.id, id
246 );
247 }
248 }
249 }
250 }
251
252 /// Start a new thread (conversation session).
253 ///
254 /// A thread must be created before any turns can be started. The returned
255 /// [`ThreadStartResponse`] contains the `thread_id` needed for subsequent calls.
256 pub async fn thread_start(
257 &mut self,
258 params: &ThreadStartParams,
259 ) -> Result<ThreadStartResponse> {
260 self.request(crate::protocol::methods::THREAD_START, params)
261 .await
262 }
263
264 /// Resume a previously persisted thread by id.
265 ///
266 /// Replays the thread's history so turns can continue where they left off.
267 pub async fn thread_resume(
268 &mut self,
269 params: &ThreadResumeParams,
270 ) -> Result<ThreadResumeResponse> {
271 self.request(crate::protocol::methods::THREAD_RESUME, params)
272 .await
273 }
274
275 /// Fork an existing thread into a new independent thread.
276 pub async fn thread_fork(&mut self, params: &ThreadForkParams) -> Result<ThreadForkResponse> {
277 self.request(crate::protocol::methods::THREAD_FORK, params)
278 .await
279 }
280
281 /// Start a new turn within a thread.
282 ///
283 /// Sends user input to the agent. After calling this, use [`AsyncClient::next_message`]
284 /// to stream notifications until `turn/completed` arrives.
285 pub async fn turn_start(&mut self, params: &TurnStartParams) -> Result<TurnStartResponse> {
286 self.request(crate::protocol::methods::TURN_START, params)
287 .await
288 }
289
290 /// Interrupt an active turn.
291 pub async fn turn_interrupt(
292 &mut self,
293 params: &TurnInterruptParams,
294 ) -> Result<TurnInterruptResponse> {
295 self.request(crate::protocol::methods::TURN_INTERRUPT, params)
296 .await
297 }
298
299 /// Archive a thread.
300 pub async fn thread_archive(
301 &mut self,
302 params: &ThreadArchiveParams,
303 ) -> Result<ThreadArchiveResponse> {
304 self.request(crate::protocol::methods::THREAD_ARCHIVE, params)
305 .await
306 }
307
308 /// Delete a thread.
309 pub async fn thread_delete(
310 &mut self,
311 params: &ThreadDeleteParams,
312 ) -> Result<ThreadDeleteResponse> {
313 self.request(crate::protocol::methods::THREAD_DELETE, params)
314 .await
315 }
316
317 /// Perform the `initialize` handshake with the app-server.
318 ///
319 /// Sends `initialize` with the given params and then sends the
320 /// `initialized` notification. This must be the first request after
321 /// spawning the process.
322 pub async fn initialize(&mut self, params: &InitializeParams) -> Result<InitializeResponse> {
323 let resp: InitializeResponse = self
324 .request(crate::protocol::methods::INITIALIZE, params)
325 .await?;
326 self.send_notification(crate::protocol::methods::INITIALIZED)
327 .await?;
328 Ok(resp)
329 }
330
331 /// Respond to a server-to-client request (e.g., approval flow).
332 ///
333 /// When the server sends a [`ServerMessage::Request`], it expects a response.
334 /// Use this method with the request's `id` and a result payload. For command
335 /// approval, pass a [`CommandExecutionApprovalResponse`](crate::CommandExecutionApprovalResponse).
336 /// For file change approval, pass a [`FileChangeApprovalResponse`](crate::FileChangeApprovalResponse).
337 pub async fn respond<R: Serialize>(&mut self, id: RequestId, result: &R) -> Result<()> {
338 let resp = JsonRpcResponse {
339 id,
340 result: serde_json::to_value(result).map_err(Error::Json)?,
341 };
342 self.send_raw(&resp).await
343 }
344
345 /// Respond to a server-to-client request with an error.
346 pub async fn respond_error(&mut self, id: RequestId, code: i64, message: &str) -> Result<()> {
347 let err = JsonRpcError {
348 id,
349 error: crate::jsonrpc::JsonRpcErrorData {
350 code,
351 message: message.to_string(),
352 data: None,
353 },
354 };
355 self.send_raw(&err).await
356 }
357
358 /// Read the next incoming server message (notification or server request).
359 ///
360 /// Returns buffered messages first (from notifications that arrived during
361 /// an [`AsyncClient::request`] call), then reads from the wire.
362 ///
363 /// Returns `Ok(None)` when the app-server closes the connection (EOF).
364 ///
365 /// # Typical notification methods
366 ///
367 /// | Method | Meaning |
368 /// |--------|---------|
369 /// | `turn/started` | Agent began processing |
370 /// | `item/agentMessage/delta` | Streaming text chunk |
371 /// | `item/commandExecution/outputDelta` | Command output chunk |
372 /// | `item/started` / `item/completed` | Item lifecycle |
373 /// | `turn/completed` | Agent finished the turn |
374 /// | `error` | Server-side error |
375 pub async fn next_message(&mut self) -> Result<Option<ServerMessage>> {
376 // Drain buffered messages first
377 if let Some(msg) = self.buffered.pop_front() {
378 return Ok(Some(msg));
379 }
380
381 // Read from the wire
382 loop {
383 let msg = match self.read_message_opt().await? {
384 Some(m) => m,
385 None => return Ok(None),
386 };
387
388 match msg {
389 JsonRpcMessage::Notification(notif) => {
390 let JsonRpcNotification { method, params } = notif;
391 let typed =
392 Notification::from_envelope(&method, params.clone()).map_err(|e| {
393 Error::Deserialization(ParseError::from_envelope(method, params, e))
394 })?;
395 return Ok(Some(ServerMessage::Notification(typed)));
396 }
397 JsonRpcMessage::Request(req) => {
398 let JsonRpcRequest { id, method, params } = req;
399 let typed =
400 ServerRequest::from_envelope(&method, params.clone()).map_err(|e| {
401 Error::Deserialization(ParseError::from_envelope(method, params, e))
402 })?;
403 return Ok(Some(ServerMessage::Request { id, request: typed }));
404 }
405 // Unexpected responses without a pending request
406 JsonRpcMessage::Response(resp) => {
407 warn!(
408 "[CLIENT] Unexpected response (no pending request): id={}",
409 resp.id
410 );
411 }
412 JsonRpcMessage::Error(err) => {
413 warn!(
414 "[CLIENT] Unexpected error (no pending request): id={} code={}",
415 err.id, err.error.code
416 );
417 }
418 }
419 }
420 }
421
422 /// Return an async event stream over [`ServerMessage`]s.
423 ///
424 /// Wraps [`AsyncClient::next_message`] in a stream-like API. Call
425 /// [`EventStream::next`] in a loop, or [`EventStream::collect`] to
426 /// gather all messages until EOF.
427 pub fn events(&mut self) -> EventStream<'_> {
428 EventStream { client: self }
429 }
430
431 /// Get the process ID.
432 pub fn pid(&self) -> Option<u32> {
433 self.child.id()
434 }
435
436 // ── Account / auth methods ─────────────────────────────────────────
437
438 /// `account/read` — the active account (plan, email, auth mode), or
439 /// `account: null` when logged out.
440 pub async fn account_read(&mut self, params: &GetAccountParams) -> Result<GetAccountResponse> {
441 self.request(crate::protocol::methods::ACCOUNT_READ, params)
442 .await
443 }
444
445 /// `account/login/start` — begin a login. The params select the mode
446 /// (`apiKey` completes immediately; `chatgpt` returns an auth URL to
447 /// open; `chatgptDeviceCode` returns a user code + verification URL).
448 /// Browser/device modes complete asynchronously: watch for the
449 /// `account/login/completed` notification, or cancel with
450 /// [`account_login_cancel`](Self::account_login_cancel).
451 pub async fn account_login_start(
452 &mut self,
453 params: &LoginAccountParams,
454 ) -> Result<LoginAccountResponse> {
455 self.request(crate::protocol::methods::ACCOUNT_LOGIN_START, params)
456 .await
457 }
458
459 /// `account/login/cancel` — abort an in-flight browser/device login.
460 pub async fn account_login_cancel(
461 &mut self,
462 params: &CancelLoginAccountParams,
463 ) -> Result<CancelLoginAccountResponse> {
464 self.request(crate::protocol::methods::ACCOUNT_LOGIN_CANCEL, params)
465 .await
466 }
467
468 /// `account/logout` — remove the stored credential.
469 pub async fn account_logout(&mut self) -> Result<LogoutAccountResponse> {
470 self.request(
471 crate::protocol::methods::ACCOUNT_LOGOUT,
472 &serde_json::json!({}),
473 )
474 .await
475 }
476
477 /// `account/rateLimits/read` — current rate-limit windows.
478 pub async fn account_rate_limits_read(&mut self) -> Result<GetAccountRateLimitsResponse> {
479 self.request(
480 crate::protocol::methods::ACCOUNT_RATELIMITS_READ,
481 &serde_json::json!({}),
482 )
483 .await
484 }
485
486 /// `account/usage/read` — token-usage summary for the account.
487 pub async fn account_usage_read(&mut self) -> Result<GetAccountTokenUsageResponse> {
488 self.request(
489 crate::protocol::methods::ACCOUNT_USAGE_READ,
490 &serde_json::json!({}),
491 )
492 .await
493 }
494
495 /// Check if the child process is still running.
496 pub fn is_alive(&mut self) -> bool {
497 self.child.try_wait().ok().flatten().is_none()
498 }
499
500 /// Shut down the app-server process.
501 ///
502 /// Consumes the client. If you don't call this explicitly, the
503 /// [`Drop`] implementation will kill the process automatically.
504 pub async fn shutdown(mut self) -> Result<()> {
505 debug!("[CLIENT] Shutting down");
506 self.child.kill().await.map_err(Error::Io)?;
507 Ok(())
508 }
509
510 // -- internal --
511
512 async fn send_notification(&mut self, method: &str) -> Result<()> {
513 let notif = JsonRpcNotification {
514 method: method.to_string(),
515 params: None,
516 };
517 self.send_raw(¬if).await
518 }
519
520 async fn send_raw<T: Serialize>(&mut self, msg: &T) -> Result<()> {
521 let json = serde_json::to_string(msg).map_err(Error::Json)?;
522 debug!("[CLIENT] Sending: {}", json);
523 self.writer
524 .write_all(json.as_bytes())
525 .await
526 .map_err(Error::Io)?;
527 self.writer.write_all(b"\n").await.map_err(Error::Io)?;
528 self.writer.flush().await.map_err(Error::Io)?;
529 Ok(())
530 }
531
532 async fn read_message(&mut self) -> Result<JsonRpcMessage> {
533 self.read_message_opt().await?.ok_or(Error::ServerClosed)
534 }
535
536 async fn read_message_opt(&mut self) -> Result<Option<JsonRpcMessage>> {
537 let mut line = String::new();
538
539 loop {
540 line.clear();
541 let bytes_read = self.reader.read_line(&mut line).await.map_err(Error::Io)?;
542
543 if bytes_read == 0 {
544 debug!("[CLIENT] Stream closed (EOF)");
545 return Ok(None);
546 }
547
548 let trimmed = line.trim();
549 if trimmed.is_empty() {
550 continue;
551 }
552
553 debug!("[CLIENT] Received: {}", trimmed);
554
555 match serde_json::from_str::<JsonRpcMessage>(trimmed) {
556 Ok(msg) => return Ok(Some(msg)),
557 Err(e) => {
558 warn!(
559 "[CLIENT] Failed to deserialize message. \
560 Please report this at https://github.com/meawoppl/rust-code-agent-sdks/issues"
561 );
562 warn!("[CLIENT] Parse error: {}", e);
563 warn!("[CLIENT] Raw: {}", trimmed);
564 return Err(Error::Deserialization(ParseError::from_line(trimmed, e)));
565 }
566 }
567 }
568 }
569}
570
571impl Drop for AsyncClient {
572 fn drop(&mut self) {
573 if self.is_alive() {
574 if let Err(e) = self.child.start_kill() {
575 error!("Failed to kill app-server process on drop: {}", e);
576 }
577 }
578 }
579}
580
581/// Async stream of [`ServerMessage`]s from an [`AsyncClient`].
582pub struct EventStream<'a> {
583 client: &'a mut AsyncClient,
584}
585
586impl EventStream<'_> {
587 /// Get the next server message.
588 pub async fn next(&mut self) -> Option<Result<ServerMessage>> {
589 match self.client.next_message().await {
590 Ok(Some(msg)) => Some(Ok(msg)),
591 Ok(None) => None,
592 Err(e) => Some(Err(e)),
593 }
594 }
595
596 /// Collect all remaining messages.
597 pub async fn collect(mut self) -> Result<Vec<ServerMessage>> {
598 let mut msgs = Vec::new();
599 while let Some(result) = self.next().await {
600 msgs.push(result?);
601 }
602 Ok(msgs)
603 }
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609
610 #[test]
611 fn test_buffer_size() {
612 assert_eq!(STDOUT_BUFFER_SIZE, 10 * 1024 * 1024);
613 }
614}