Skip to main content

codex_runtime/runtime/client/
mod.rs

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