Skip to main content

codex_runtime/runtime/client/
mod.rs

1use std::sync::atomic::{AtomicBool, Ordering};
2use std::sync::Arc;
3
4use thiserror::Error;
5
6use crate::runtime::api::{tool_use_hooks, PromptRunError, PromptRunParams, PromptRunResult};
7use crate::runtime::core::{Runtime, RuntimeConfig};
8#[cfg(test)]
9use crate::runtime::errors::RpcError;
10use crate::runtime::errors::RuntimeError;
11use crate::runtime::transport::StdioProcessSpec;
12
13mod compat_guard;
14mod config;
15mod profile;
16mod session;
17
18pub use compat_guard::{CompatibilityGuard, SemVerTriplet};
19pub use config::ClientConfig;
20pub use profile::{RunProfile, SessionConfig};
21pub use session::Session;
22
23use compat_guard::validate_runtime_compatibility;
24use profile::{profile_to_prompt_params_with_hooks, session_thread_start_params};
25
26#[derive(Clone)]
27pub struct Client {
28    runtime: Runtime,
29    config: ClientConfig,
30    tool_use_loop_started: Arc<AtomicBool>,
31}
32
33impl Client {
34    /// Connect using default config (default CLI).
35    /// Side effects: spawns `<cli_bin> app-server`.
36    /// Allocation: runtime buffers + internal channels.
37    pub async fn connect_default() -> Result<Self, ClientError> {
38        Self::connect(ClientConfig::new()).await
39    }
40
41    /// Connect using explicit client config.
42    /// Side effects: spawns `<cli_bin> app-server` and validates initialize compatibility guard.
43    /// Allocation: runtime buffers + internal channels.
44    pub async fn connect(config: ClientConfig) -> Result<Self, ClientError> {
45        let mut process = StdioProcessSpec::new(config.cli_bin.clone());
46        process.args = vec!["app-server".to_owned()];
47
48        let runtime = Runtime::spawn_local(
49            RuntimeConfig::new(process)
50                .with_hooks(config.hooks.clone())
51                .with_initialize_capabilities(config.initialize_capabilities),
52        )
53        .await?;
54        if let Err(compatibility) =
55            validate_runtime_compatibility(&runtime, &config.compatibility_guard)
56        {
57            if let Err(shutdown) = runtime.shutdown().await {
58                return Err(ClientError::CompatibilityValidationWithShutdown {
59                    compatibility: Box::new(compatibility),
60                    shutdown,
61                });
62            }
63            return Err(compatibility);
64        }
65
66        // When pre-tool-use hooks are registered, start a background approval loop that
67        // intercepts commandExecution/fileChange approval requests and runs the hooks.
68        // The loop takes exclusive ownership of server_request_rx for the runtime's lifetime.
69        let tool_use_loop_started = Arc::new(AtomicBool::new(false));
70        let client = Self {
71            runtime,
72            config,
73            tool_use_loop_started,
74        };
75        client.ensure_tool_use_hook_loop(client.config.hooks.has_pre_tool_use_hooks());
76        Ok(client)
77    }
78
79    /// Run one prompt using default policies (approval=never, sandbox=read-only).
80    /// Side effects: sends thread/turn RPC calls to app-server.
81    pub async fn run(
82        &self,
83        cwd: impl Into<String>,
84        prompt: impl Into<String>,
85    ) -> Result<PromptRunResult, PromptRunError> {
86        self.runtime.run_prompt_simple(cwd, prompt).await
87    }
88
89    /// Run one prompt with explicit model/policy/attachment options.
90    /// Side effects: sends thread/turn RPC calls to app-server.
91    pub async fn run_with(
92        &self,
93        params: PromptRunParams,
94    ) -> Result<PromptRunResult, PromptRunError> {
95        self.runtime.run_prompt(params).await
96    }
97
98    /// Run one prompt with one reusable profile (model/effort/policy/attachments/timeout).
99    /// Side effects: sends thread/turn RPC calls to app-server.
100    /// Allocation: moves profile-owned Strings/vectors + one prompt String. Complexity: O(n), n = attachment count + field sizes.
101    pub async fn run_with_profile(
102        &self,
103        cwd: impl Into<String>,
104        prompt: impl Into<String>,
105        profile: RunProfile,
106    ) -> Result<PromptRunResult, PromptRunError> {
107        let (params, hooks) = profile_to_prompt_params_with_hooks(cwd.into(), prompt, profile);
108        self.ensure_tool_use_hook_loop(hooks.has_pre_tool_use_hooks());
109        self.runtime
110            .run_prompt_with_hooks(params, Some(&hooks))
111            .await
112    }
113
114    /// Start a prepared session and return a reusable handle.
115    /// Side effects: sends thread/start RPC call to app-server.
116    /// Allocation: clones model/cwd/sandbox into thread-start payload. Complexity: O(n), n = total field sizes.
117    pub async fn start_session(&self, config: SessionConfig) -> Result<Session, PromptRunError> {
118        self.ensure_tool_use_hook_loop(config.hooks.has_pre_tool_use_hooks());
119        let thread = self
120            .runtime
121            .thread_start_with_hooks(session_thread_start_params(&config), Some(&config.hooks))
122            .await?;
123
124        Ok(Session::new(
125            self.runtime.clone(),
126            thread.thread_id,
127            config,
128            Arc::clone(&self.tool_use_loop_started),
129        ))
130    }
131
132    /// Resume an existing session id with prepared defaults.
133    /// Side effects: sends thread/resume RPC call to app-server.
134    /// Allocation: clones model/cwd/sandbox into thread-resume payload. Complexity: O(n), n = total field sizes.
135    pub async fn resume_session(
136        &self,
137        thread_id: &str,
138        config: SessionConfig,
139    ) -> Result<Session, PromptRunError> {
140        self.ensure_tool_use_hook_loop(config.hooks.has_pre_tool_use_hooks());
141        let thread = self
142            .runtime
143            .thread_resume_with_hooks(
144                thread_id,
145                session_thread_start_params(&config),
146                Some(&config.hooks),
147            )
148            .await?;
149
150        Ok(Session::new(
151            self.runtime.clone(),
152            thread.thread_id,
153            config,
154            Arc::clone(&self.tool_use_loop_started),
155        ))
156    }
157
158    /// Borrow underlying runtime for full low-level control.
159    /// Allocation: none. Complexity: O(1).
160    pub fn runtime(&self) -> &Runtime {
161        &self.runtime
162    }
163
164    /// Return connect-time client config snapshot.
165    /// Allocation: none. Complexity: O(1).
166    pub fn config(&self) -> &ClientConfig {
167        &self.config
168    }
169
170    /// Shutdown child process and background tasks.
171    /// Side effects: closes channels and terminates child process.
172    pub async fn shutdown(&self) -> Result<(), RuntimeError> {
173        self.runtime.shutdown().await
174    }
175
176    fn ensure_tool_use_hook_loop(&self, needs_loop: bool) {
177        if !needs_loop {
178            return;
179        }
180        if self
181            .tool_use_loop_started
182            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
183            .is_ok()
184        {
185            tokio::spawn(tool_use_hooks::run_tool_use_approval_loop(
186                self.runtime.clone(),
187            ));
188        }
189    }
190}
191
192#[derive(Clone, Debug, Error, PartialEq, Eq)]
193pub enum ClientError {
194    #[error("failed to read current directory: {0}")]
195    CurrentDir(String),
196
197    #[error("initialize response missing userAgent")]
198    MissingInitializeUserAgent,
199
200    #[error("initialize response has unsupported userAgent format: {0}")]
201    InvalidInitializeUserAgent(String),
202
203    #[error("incompatible codex runtime version: detected={detected} required>={required} userAgent={user_agent}")]
204    IncompatibleCodexVersion {
205        detected: String,
206        required: String,
207        user_agent: String,
208    },
209
210    #[error(
211        "compatibility validation failed: {compatibility}; runtime shutdown failed: {shutdown}"
212    )]
213    CompatibilityValidationWithShutdown {
214        compatibility: Box<ClientError>,
215        shutdown: RuntimeError,
216    },
217
218    #[error("runtime error: {0}")]
219    Runtime(#[from] RuntimeError),
220}
221
222#[cfg(test)]
223fn parse_initialize_user_agent(value: &str) -> Option<(String, SemVerTriplet)> {
224    compat_guard::parse_initialize_user_agent(value)
225}
226
227#[cfg(test)]
228fn session_prompt_params(config: &SessionConfig, prompt: impl Into<String>) -> PromptRunParams {
229    profile::session_prompt_params(config, prompt)
230}
231
232#[cfg(test)]
233fn profile_to_prompt_params(
234    cwd: String,
235    prompt: impl Into<String>,
236    profile: RunProfile,
237) -> PromptRunParams {
238    profile::profile_to_prompt_params(cwd, prompt, profile)
239}
240
241#[cfg(test)]
242fn ensure_session_open_for_prompt(closed: bool) -> Result<(), PromptRunError> {
243    session::ensure_session_open_for_prompt(closed)
244}
245
246#[cfg(test)]
247fn ensure_session_open_for_rpc(closed: bool) -> Result<(), RpcError> {
248    session::ensure_session_open_for_rpc(closed)
249}
250
251#[cfg(test)]
252mod tests;