Merge 6b5ba1fd92 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-01-13 21:12:15 -08:00
committed by GitHub
41 changed files with 2325 additions and 516 deletions
@@ -567,6 +567,7 @@ server_notification_definitions! {
ReasoningTextDelta => "item/reasoning/textDelta" (v2::ReasoningTextDeltaNotification),
ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification),
DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification),
ConfigWarning => "configWarning" (v2::ConfigWarningNotification),
/// Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox.
WindowsWorldWritableWarning => "windows/worldWritableWarning" (v2::WindowsWorldWritableWarningNotification),
@@ -2107,6 +2107,16 @@ pub struct DeprecationNoticeNotification {
pub details: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ConfigWarningNotification {
/// Concise summary of the warning.
pub summary: String,
/// Optional extra guidance or error details.
pub details: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
+38 -4
View File
@@ -1,6 +1,7 @@
#![deny(clippy::print_stdout, clippy::print_stderr)]
use codex_common::CliConfigOverrides;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config_loader::LoaderOverrides;
use std::io::ErrorKind;
@@ -10,7 +11,9 @@ use std::path::PathBuf;
use crate::message_processor::MessageProcessor;
use crate::outgoing_message::OutgoingMessage;
use crate::outgoing_message::OutgoingMessageSender;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::JSONRPCMessage;
use codex_core::check_execpolicy_for_warnings;
use codex_feedback::CodexFeedback;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
@@ -82,14 +85,38 @@ pub async fn run_main(
)
})?;
let loader_overrides_for_config_api = loader_overrides.clone();
let config = ConfigBuilder::default()
let mut config_warnings = Vec::new();
let config = match ConfigBuilder::default()
.cli_overrides(cli_kv_overrides.clone())
.loader_overrides(loader_overrides)
.build()
.await
.map_err(|e| {
std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}"))
})?;
{
Ok(config) => config,
Err(err) => {
let message = ConfigWarningNotification {
summary: "Invalid configuration; using defaults.".to_string(),
details: Some(err.to_string()),
};
config_warnings.push(message);
Config::load_default_with_cli_overrides(cli_kv_overrides.clone()).map_err(|e| {
std::io::Error::new(
ErrorKind::InvalidData,
format!("error loading default config after config error: {e}"),
)
})?
}
};
if let Ok(Some(err)) =
check_execpolicy_for_warnings(&config.features, &config.config_layer_stack).await
{
let message = ConfigWarningNotification {
summary: "Error parsing rules; custom rules not applied.".to_string(),
details: Some(err.to_string()),
};
config_warnings.push(message);
}
let feedback = CodexFeedback::new();
@@ -127,6 +154,12 @@ pub async fn run_main(
.with(otel_logger_layer)
.with(otel_tracing_layer)
.try_init();
for warning in &config_warnings {
match &warning.details {
Some(details) => error!("{} {}", warning.summary, details),
None => error!("{}", warning.summary),
}
}
// Task: process incoming messages.
let processor_handle = tokio::spawn({
@@ -140,6 +173,7 @@ pub async fn run_main(
cli_overrides,
loader_overrides,
feedback.clone(),
config_warnings,
);
async move {
while let Some(msg) = incoming_rx.recv().await {
@@ -10,6 +10,7 @@ use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::InitializeResponse;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCErrorError;
@@ -17,6 +18,7 @@ use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCRequest;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_core::AuthManager;
use codex_core::ThreadManager;
use codex_core::config::Config;
@@ -34,6 +36,7 @@ pub(crate) struct MessageProcessor {
codex_message_processor: CodexMessageProcessor,
config_api: ConfigApi,
initialized: bool,
config_warnings: Vec<ConfigWarningNotification>,
}
impl MessageProcessor {
@@ -46,6 +49,7 @@ impl MessageProcessor {
cli_overrides: Vec<(String, TomlValue)>,
loader_overrides: LoaderOverrides,
feedback: CodexFeedback,
config_warnings: Vec<ConfigWarningNotification>,
) -> Self {
let outgoing = Arc::new(outgoing);
let auth_manager = AuthManager::shared(
@@ -74,6 +78,7 @@ impl MessageProcessor {
codex_message_processor,
config_api,
initialized: false,
config_warnings,
}
}
@@ -155,6 +160,16 @@ impl MessageProcessor {
self.initialized = true;
if !self.config_warnings.is_empty() {
for notification in self.config_warnings.drain(..) {
self.outgoing
.send_server_notification(ServerNotification::ConfigWarning(
notification,
))
.await;
}
}
return;
}
}
+2 -1
View File
@@ -4,12 +4,13 @@ use codex_app_server_protocol::Model;
use codex_app_server_protocol::ReasoningEffortOption;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::models_manager::manager::RefreshStrategy;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ReasoningEffortPreset;
pub async fn supported_models(thread_manager: Arc<ThreadManager>, config: &Config) -> Vec<Model> {
thread_manager
.list_models(config)
.list_models(config, RefreshStrategy::OnlineIfUncached)
.await
.into_iter()
.filter(|preset| preset.show_in_picker)
@@ -162,6 +162,7 @@ mod tests {
use codex_app_server_protocol::AccountRateLimitsUpdatedNotification;
use codex_app_server_protocol::AccountUpdatedNotification;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::LoginChatGptCompleteNotification;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RateLimitWindow;
@@ -279,4 +280,26 @@ mod tests {
"ensure the notification serializes correctly"
);
}
#[test]
fn verify_config_warning_notification_serialization() {
let notification = ServerNotification::ConfigWarning(ConfigWarningNotification {
summary: "Config error: using defaults".to_string(),
details: Some("error loading config: bad config".to_string()),
});
let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification);
assert_eq!(
json!( {
"method": "configWarning",
"params": {
"summary": "Config error: using defaults",
"details": "error loading config: bad config",
},
}),
serde_json::to_value(jsonrpc_notification)
.expect("ensure the notification serializes correctly"),
"ensure the notification serializes correctly"
);
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ pub use sandbox_mode_cli_arg::SandboxModeCliArg;
#[cfg(feature = "cli")]
pub mod format_env_display;
#[cfg(any(feature = "cli", test))]
#[cfg(feature = "cli")]
mod config_override;
#[cfg(feature = "cli")]
File diff suppressed because one or more lines are too long
+26 -12
View File
@@ -251,17 +251,22 @@ impl Codex {
let exec_policy = ExecPolicyManager::load(&config.features, &config.config_layer_stack)
.await
.map_err(|err| CodexErr::Fatal(format!("failed to load execpolicy: {err}")))?;
.map_err(|err| CodexErr::Fatal(format!("failed to load rules: {err}")))?;
let config = Arc::new(config);
if config.features.enabled(Feature::RemoteModels)
&& let Err(err) = models_manager
.refresh_available_models_with_cache(&config)
.await
{
error!("failed to refresh available models: {err:?}");
}
let model = models_manager.get_model(&config.model, &config).await;
let _ = models_manager
.list_models(
&config,
crate::models_manager::manager::RefreshStrategy::OnlineIfUncached,
)
.await;
let model = models_manager
.get_default_model(
&config.model,
&config,
crate::models_manager::manager::RefreshStrategy::OnlineIfUncached,
)
.await;
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
model: model.clone(),
@@ -965,7 +970,7 @@ impl Session {
let model_info = self
.services
.models_manager
.construct_model_info(session_configuration.model.as_str(), &per_turn_config)
.get_model_info(session_configuration.model.as_str(), &per_turn_config)
.await;
let mut turn_context: TurnContext = Self::make_turn_context(
Some(Arc::clone(&self.services.auth_manager)),
@@ -988,6 +993,14 @@ impl Session {
.await
}
async fn get_config(&self) -> std::sync::Arc<Config> {
let state = self.state.lock().await;
state
.session_configuration
.original_config_do_not_use
.clone()
}
pub(crate) async fn new_default_turn_with_sub_id(&self, sub_id: String) -> Arc<TurnContext> {
let session_configuration = {
let state = self.state.lock().await;
@@ -2374,7 +2387,7 @@ async fn spawn_review_thread(
let review_model_info = sess
.services
.models_manager
.construct_model_info(&model, &config)
.get_model_info(&model, &config)
.await;
// For reviews, disable web_search and view_image regardless of global settings.
let mut review_features = sess.features.clone();
@@ -2904,9 +2917,10 @@ async fn try_run_turn(
}
ResponseEvent::ModelsEtag(etag) => {
// Update internal state with latest models etag
let config = sess.get_config().await;
sess.services
.models_manager
.refresh_if_new_etag(etag, sess.features.enabled(Feature::RemoteModels))
.refresh_if_new_etag(etag, &config)
.await;
}
ResponseEvent::Completed {
+22
View File
@@ -460,6 +460,28 @@ impl Config {
.await
}
/// Load a default configuration when user config files are invalid.
pub fn load_default_with_cli_overrides(
cli_overrides: Vec<(String, TomlValue)>,
) -> std::io::Result<Self> {
let codex_home = find_codex_home()?;
let mut merged = toml::Value::try_from(ConfigToml::default()).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("failed to serialize default config: {e}"),
)
})?;
let cli_layer = crate::config_loader::build_cli_overrides_layer(&cli_overrides);
crate::config_loader::merge_toml_values(&mut merged, &cli_layer);
let config_toml = deserialize_config_toml_with_base(merged, &codex_home)?;
Self::load_config_with_layer_stack(
config_toml,
ConfigOverrides::default(),
codex_home,
ConfigLayerStack::default(),
)
}
/// This is a secondary way of creating [Config], which is appropriate when
/// the harness is meant to be used with a specific configuration that
/// ignores user settings. For example, the `codex exec` subcommand is
+1
View File
@@ -31,6 +31,7 @@ pub use config_requirements::McpServerRequirement;
pub use config_requirements::RequirementSource;
pub use config_requirements::SandboxModeRequirement;
pub use merge::merge_toml_values;
pub(crate) use overrides::build_cli_overrides_layer;
pub use state::ConfigLayerEntry;
pub use state::ConfigLayerStack;
pub use state::ConfigLayerStackOrdering;
+2 -2
View File
@@ -1,10 +1,10 @@
use toml::Value as TomlValue;
pub(super) fn default_empty_table() -> TomlValue {
pub(crate) fn default_empty_table() -> TomlValue {
TomlValue::Table(Default::default())
}
pub(super) fn build_cli_overrides_layer(cli_overrides: &[(String, TomlValue)]) -> TomlValue {
pub(crate) fn build_cli_overrides_layer(cli_overrides: &[(String, TomlValue)]) -> TomlValue {
let mut root = default_empty_table();
for (path, value) in cli_overrides {
apply_toml_override(&mut root, path, value.clone());
+30 -14
View File
@@ -46,19 +46,19 @@ fn is_policy_match(rule_match: &RuleMatch) -> bool {
#[derive(Debug, Error)]
pub enum ExecPolicyError {
#[error("failed to read execpolicy files from {dir}: {source}")]
#[error("failed to read rules files from {dir}: {source}")]
ReadDir {
dir: PathBuf,
source: std::io::Error,
},
#[error("failed to read execpolicy file {path}: {source}")]
#[error("failed to read rules file {path}: {source}")]
ReadFile {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to parse execpolicy file {path}: {source}")]
#[error("failed to parse rules file {path}: {source}")]
ParsePolicy {
path: PathBuf,
source: codex_execpolicy::Error,
@@ -67,19 +67,19 @@ pub enum ExecPolicyError {
#[derive(Debug, Error)]
pub enum ExecPolicyUpdateError {
#[error("failed to update execpolicy file {path}: {source}")]
#[error("failed to update rules file {path}: {source}")]
AppendRule { path: PathBuf, source: AmendError },
#[error("failed to join blocking execpolicy update task: {source}")]
#[error("failed to join blocking rules update task: {source}")]
JoinBlockingTask { source: tokio::task::JoinError },
#[error("failed to update in-memory execpolicy: {source}")]
#[error("failed to update in-memory rules: {source}")]
AddRule {
#[from]
source: ExecPolicyRuleError,
},
#[error("cannot append execpolicy rule because execpolicy feature is disabled")]
#[error("cannot append rule because rules feature is disabled")]
FeatureDisabled,
}
@@ -98,7 +98,11 @@ impl ExecPolicyManager {
features: &Features,
config_stack: &ConfigLayerStack,
) -> Result<Self, ExecPolicyError> {
let policy = load_exec_policy_for_features(features, config_stack).await?;
let (policy, warning) =
load_exec_policy_for_features_with_warning(features, config_stack).await?;
if let Some(err) = warning.as_ref() {
tracing::warn!("failed to parse rules: {err}");
}
Ok(Self::new(Arc::new(policy)))
}
@@ -195,14 +199,26 @@ impl Default for ExecPolicyManager {
}
}
async fn load_exec_policy_for_features(
pub async fn check_execpolicy_for_warnings(
features: &Features,
config_stack: &ConfigLayerStack,
) -> Result<Policy, ExecPolicyError> {
) -> Result<Option<ExecPolicyError>, ExecPolicyError> {
let (_, warning) = load_exec_policy_for_features_with_warning(features, config_stack).await?;
Ok(warning)
}
async fn load_exec_policy_for_features_with_warning(
features: &Features,
config_stack: &ConfigLayerStack,
) -> Result<(Policy, Option<ExecPolicyError>), ExecPolicyError> {
if !features.enabled(Feature::ExecPolicy) {
Ok(Policy::empty())
} else {
load_exec_policy(config_stack).await
return Ok((Policy::empty(), None));
}
match load_exec_policy(config_stack).await {
Ok(policy) => Ok((policy, None)),
Err(err @ ExecPolicyError::ParsePolicy { .. }) => Ok((Policy::empty(), Some(err))),
Err(err) => Err(err),
}
}
@@ -239,7 +255,7 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy,
}
let policy = parser.build();
tracing::debug!("loaded execpolicy from {} files", policy_paths.len());
tracing::debug!("loaded rules from {} files", policy_paths.len());
Ok(policy)
}
+1
View File
@@ -114,6 +114,7 @@ pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
pub use command_safety::is_dangerous_command;
pub use command_safety::is_safe_command;
pub use exec_policy::ExecPolicyError;
pub use exec_policy::check_execpolicy_for_warnings;
pub use exec_policy::load_exec_policy;
pub use safety::get_platform_sandbox;
pub use safety::is_windows_elevated_sandbox_enabled;
+98 -25
View File
@@ -5,9 +5,105 @@ use serde::Deserialize;
use serde::Serialize;
use std::io;
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use tokio::fs;
use tracing::error;
/// Manages loading and saving of models cache to disk.
#[derive(Debug)]
pub(crate) struct ModelsCacheManager {
cache_path: PathBuf,
cache_ttl: Duration,
}
impl ModelsCacheManager {
/// Create a new cache manager with the given path and TTL.
pub(crate) fn new(cache_path: PathBuf, cache_ttl: Duration) -> Self {
Self {
cache_path,
cache_ttl,
}
}
/// Attempt to load a fresh cache entry. Returns `None` if the cache doesn't exist or is stale.
pub(crate) async fn load_fresh(&self) -> Option<ModelsCache> {
let cache = match self.load().await {
Ok(cache) => cache?,
Err(err) => {
error!("failed to load models cache: {err}");
return None;
}
};
if !cache.is_fresh(self.cache_ttl) {
return None;
}
Some(cache)
}
/// Persist the cache to disk, creating parent directories as needed.
pub(crate) async fn persist_cache(&self, models: &[ModelInfo], etag: Option<String>) {
let cache = ModelsCache {
fetched_at: Utc::now(),
etag,
models: models.to_vec(),
};
if let Err(err) = self.save_internal(&cache).await {
error!("failed to write models cache: {err}");
}
}
/// Renew the cache TTL by updating the fetched_at timestamp to now.
pub(crate) async fn renew_cache_ttl(&self) -> io::Result<()> {
let mut cache = match self.load().await? {
Some(cache) => cache,
None => return Err(io::Error::new(ErrorKind::NotFound, "cache not found")),
};
cache.fetched_at = Utc::now();
self.save_internal(&cache).await
}
async fn load(&self) -> io::Result<Option<ModelsCache>> {
match fs::read(&self.cache_path).await {
Ok(contents) => {
let cache = serde_json::from_slice(&contents)
.map_err(|err| io::Error::new(ErrorKind::InvalidData, err.to_string()))?;
Ok(Some(cache))
}
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
Err(err) => Err(err),
}
}
async fn save_internal(&self, cache: &ModelsCache) -> io::Result<()> {
if let Some(parent) = self.cache_path.parent() {
fs::create_dir_all(parent).await?;
}
let json = serde_json::to_vec_pretty(cache)
.map_err(|err| io::Error::new(ErrorKind::InvalidData, err.to_string()))?;
fs::write(&self.cache_path, json).await
}
#[cfg(test)]
/// Set the cache TTL.
pub(crate) fn set_ttl(&mut self, ttl: Duration) {
self.cache_ttl = ttl;
}
#[cfg(test)]
/// Manipulate cache file for testing. Allows setting a custom fetched_at timestamp.
pub(crate) async fn manipulate_cache_for_test<F>(&self, f: F) -> io::Result<()>
where
F: FnOnce(&mut DateTime<Utc>),
{
let mut cache = match self.load().await? {
Some(cache) => cache,
None => return Err(io::Error::new(ErrorKind::NotFound, "cache not found")),
};
f(&mut cache.fetched_at);
self.save_internal(&cache).await
}
}
/// Serialized snapshot of models and metadata cached on disk.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -20,7 +116,7 @@ pub(crate) struct ModelsCache {
impl ModelsCache {
/// Returns `true` when the cache entry has not exceeded the configured TTL.
pub(crate) fn is_fresh(&self, ttl: Duration) -> bool {
fn is_fresh(&self, ttl: Duration) -> bool {
if ttl.is_zero() {
return false;
}
@@ -31,26 +127,3 @@ impl ModelsCache {
age <= ttl_duration
}
}
/// Read and deserialize the cache file if it exists.
pub(crate) async fn load_cache(path: &Path) -> io::Result<Option<ModelsCache>> {
match fs::read(path).await {
Ok(contents) => {
let cache = serde_json::from_slice(&contents)
.map_err(|err| io::Error::new(ErrorKind::InvalidData, err.to_string()))?;
Ok(Some(cache))
}
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
Err(err) => Err(err),
}
}
/// Persist the cache contents to disk, creating parent directories as needed.
pub(crate) async fn save_cache(path: &Path, cache: &ModelsCache) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
let json = serde_json::to_vec_pretty(cache)
.map_err(|err| io::Error::new(ErrorKind::InvalidData, err.to_string()))?;
fs::write(path, json).await
}
+195 -197
View File
@@ -1,4 +1,3 @@
use chrono::Utc;
use codex_api::ModelsClient;
use codex_api::ReqwestTransport;
use codex_app_server_protocol::AuthMode;
@@ -6,7 +5,6 @@ use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelsResponse;
use http::HeaderMap;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
@@ -15,8 +13,7 @@ use tokio::sync::TryLockError;
use tokio::time::timeout;
use tracing::error;
use super::cache;
use super::cache::ModelsCache;
use super::cache::ModelsCacheManager;
use crate::api_bridge::auth_provider_from_auth;
use crate::api_bridge::map_api_error;
use crate::auth::AuthManager;
@@ -36,6 +33,17 @@ const OPENAI_DEFAULT_API_MODEL: &str = "gpt-5.1-codex-max";
const OPENAI_DEFAULT_CHATGPT_MODEL: &str = "gpt-5.2-codex";
const CODEX_AUTO_BALANCED_MODEL: &str = "codex-auto-balanced";
/// Strategy for refreshing available models.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefreshStrategy {
/// Always fetch from the network, ignoring cache.
Online,
/// Only use cached data, never fetch from the network.
Offline,
/// Use cache if available and fresh, otherwise fetch from the network.
OnlineIfUncached,
}
/// Coordinates remote model discovery plus cached metadata on disk.
#[derive(Debug)]
pub struct ModelsManager {
@@ -43,64 +51,157 @@ pub struct ModelsManager {
remote_models: RwLock<Vec<ModelInfo>>,
auth_manager: Arc<AuthManager>,
etag: RwLock<Option<String>>,
codex_home: PathBuf,
cache_ttl: Duration,
cache_manager: ModelsCacheManager,
provider: ModelProviderInfo,
}
impl ModelsManager {
/// Construct a manager scoped to the provided `AuthManager`.
///
/// Uses `codex_home` to store cached model metadata and initializes with built-in presets.
pub fn new(codex_home: PathBuf, auth_manager: Arc<AuthManager>) -> Self {
let cache_path = codex_home.join(MODEL_CACHE_FILE);
let cache_manager = ModelsCacheManager::new(cache_path, DEFAULT_MODEL_CACHE_TTL);
Self {
local_models: builtin_model_presets(auth_manager.get_auth_mode()),
remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()),
auth_manager,
etag: RwLock::new(None),
codex_home,
cache_ttl: DEFAULT_MODEL_CACHE_TTL,
cache_manager,
provider: ModelProviderInfo::create_openai_provider(),
}
}
#[cfg(any(test, feature = "test-support"))]
/// Construct a manager scoped to the provided `AuthManager` with a specific provider. Used for integration tests.
pub fn with_provider(
codex_home: PathBuf,
auth_manager: Arc<AuthManager>,
provider: ModelProviderInfo,
) -> Self {
Self {
local_models: builtin_model_presets(auth_manager.get_auth_mode()),
remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()),
auth_manager,
etag: RwLock::new(None),
codex_home,
cache_ttl: DEFAULT_MODEL_CACHE_TTL,
provider,
/// List all available models, refreshing according to the specified strategy.
///
/// Returns model presets sorted by priority and filtered by auth mode and visibility.
pub async fn list_models(
&self,
config: &Config,
refresh_strategy: RefreshStrategy,
) -> Vec<ModelPreset> {
if let Err(err) = self
.refresh_available_models(config, refresh_strategy)
.await
{
error!("failed to refresh available models: {err}");
}
let remote_models = self.get_remote_models(config).await;
self.build_available_models(remote_models)
}
/// Attempt to list models without blocking, using the current cached state.
///
/// Returns an error if the internal lock cannot be acquired.
pub fn try_list_models(&self, config: &Config) -> Result<Vec<ModelPreset>, TryLockError> {
let remote_models = self.try_get_remote_models(config)?;
Ok(self.build_available_models(remote_models))
}
// todo(aibrahim): should be visible to core only and sent on session_configured event
/// Get the model identifier to use, refreshing according to the specified strategy.
///
/// If `model` is provided, returns it directly. Otherwise selects the default based on
/// auth mode and available models (prefers `codex-auto-balanced` for ChatGPT auth).
pub async fn get_default_model(
&self,
model: &Option<String>,
config: &Config,
refresh_strategy: RefreshStrategy,
) -> String {
if let Some(model) = model.as_ref() {
return model.to_string();
}
if let Err(err) = self
.refresh_available_models(config, refresh_strategy)
.await
{
error!("failed to refresh available models: {err}");
}
// if codex-auto-balanced exists & signed in with chatgpt mode, return it, otherwise return the default model
let auth_mode = self.auth_manager.get_auth_mode();
let remote_models = self.get_remote_models(config).await;
if auth_mode == Some(AuthMode::ChatGPT) {
let has_auto_balanced = self
.build_available_models(remote_models)
.iter()
.any(|model| model.model == CODEX_AUTO_BALANCED_MODEL && model.show_in_picker);
if has_auto_balanced {
return CODEX_AUTO_BALANCED_MODEL.to_string();
}
return OPENAI_DEFAULT_CHATGPT_MODEL.to_string();
}
OPENAI_DEFAULT_API_MODEL.to_string()
}
// todo(aibrahim): look if we can tighten it to pub(crate)
/// Look up model metadata, applying remote overrides and config adjustments.
pub async fn get_model_info(&self, model: &str, config: &Config) -> ModelInfo {
let remote = self
.get_remote_models(config)
.await
.into_iter()
.find(|m| m.slug == model);
let model = if let Some(remote) = remote {
remote
} else {
model_info::find_model_info_for_slug(model)
};
model_info::with_config_overrides(model, config)
}
/// Refresh models if the provided ETag differs from the cached ETag.
///
/// Uses `Online` strategy to fetch latest models when ETags differ.
pub(crate) async fn refresh_if_new_etag(&self, etag: String, config: &Config) {
let current_etag = self.get_etag().await;
if current_etag.clone().is_some() && current_etag.as_deref() == Some(etag.as_str()) {
if let Err(err) = self.cache_manager.renew_cache_ttl().await {
error!("failed to renew cache TTL: {err}");
}
return;
}
if let Err(err) = self
.refresh_available_models(config, RefreshStrategy::Online)
.await
{
error!("failed to refresh available models: {err}");
}
}
/// Fetch the latest remote models, using the on-disk cache when still fresh.
pub async fn refresh_available_models_with_cache(&self, config: &Config) -> CoreResult<()> {
/// Refresh available models according to the specified strategy.
async fn refresh_available_models(
&self,
config: &Config,
refresh_strategy: RefreshStrategy,
) -> CoreResult<()> {
if !config.features.enabled(Feature::RemoteModels)
|| self.auth_manager.get_auth_mode() == Some(AuthMode::ApiKey)
{
return Ok(());
}
if self.try_load_cache().await {
return Ok(());
match refresh_strategy {
RefreshStrategy::Offline => {
// Only try to load from cache, never fetch
self.try_load_cache().await;
Ok(())
}
RefreshStrategy::OnlineIfUncached => {
// Try cache first, fall back to online if unavailable
if self.try_load_cache().await {
return Ok(());
}
self.fetch_and_update_models().await
}
RefreshStrategy::Online => {
// Always fetch from network
self.fetch_and_update_models().await
}
}
self.refresh_available_models_no_cache(config.features.enabled(Feature::RemoteModels))
.await
}
pub(crate) async fn refresh_available_models_no_cache(
&self,
remote_models_feature: bool,
) -> CoreResult<()> {
if !remote_models_feature || self.auth_manager.get_auth_mode() == Some(AuthMode::ApiKey) {
return Ok(());
}
async fn fetch_and_update_models(&self) -> CoreResult<()> {
let auth = self.auth_manager.auth().await;
let api_provider = self.provider.to_api_provider(Some(AuthMode::ChatGPT))?;
let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?;
@@ -118,84 +219,10 @@ impl ModelsManager {
self.apply_remote_models(models.clone()).await;
*self.etag.write().await = etag.clone();
self.persist_cache(&models, etag).await;
self.cache_manager.persist_cache(&models, etag).await;
Ok(())
}
pub async fn list_models(&self, config: &Config) -> Vec<ModelPreset> {
if let Err(err) = self.refresh_available_models_with_cache(config).await {
error!("failed to refresh available models: {err}");
}
let remote_models = self.remote_models(config).await;
self.build_available_models(remote_models)
}
pub fn try_list_models(&self, config: &Config) -> Result<Vec<ModelPreset>, TryLockError> {
let remote_models = self.try_get_remote_models(config)?;
Ok(self.build_available_models(remote_models))
}
/// Look up the requested model metadata while applying remote metadata overrides.
pub async fn construct_model_info(&self, model: &str, config: &Config) -> ModelInfo {
let remote = self
.remote_models(config)
.await
.into_iter()
.find(|m| m.slug == model);
let model = if let Some(remote) = remote {
remote
} else {
model_info::find_model_info_for_slug(model)
};
model_info::with_config_overrides(model, config)
}
pub async fn get_model(&self, model: &Option<String>, config: &Config) -> String {
if let Some(model) = model.as_ref() {
return model.to_string();
}
if let Err(err) = self.refresh_available_models_with_cache(config).await {
error!("failed to refresh available models: {err}");
}
// if codex-auto-balanced exists & signed in with chatgpt mode, return it, otherwise return the default model
let auth_mode = self.auth_manager.get_auth_mode();
let remote_models = self.remote_models(config).await;
if auth_mode == Some(AuthMode::ChatGPT) {
let has_auto_balanced = self
.build_available_models(remote_models)
.iter()
.any(|model| model.model == CODEX_AUTO_BALANCED_MODEL && model.show_in_picker);
if has_auto_balanced {
return CODEX_AUTO_BALANCED_MODEL.to_string();
}
return OPENAI_DEFAULT_CHATGPT_MODEL.to_string();
}
OPENAI_DEFAULT_API_MODEL.to_string()
}
pub async fn refresh_if_new_etag(&self, etag: String, remote_models_feature: bool) {
let current_etag = self.get_etag().await;
if current_etag.clone().is_some() && current_etag.as_deref() == Some(etag.as_str()) {
return;
}
if let Err(err) = self
.refresh_available_models_no_cache(remote_models_feature)
.await
{
error!("failed to refresh available models: {err}");
}
}
#[cfg(any(test, feature = "test-support"))]
pub fn get_model_offline(model: Option<&str>) -> String {
model.unwrap_or(OPENAI_DEFAULT_CHATGPT_MODEL).to_string()
}
#[cfg(any(test, feature = "test-support"))]
/// Offline helper that builds a `ModelInfo` without consulting remote state.
pub fn construct_model_info_offline(model: &str, config: &Config) -> ModelInfo {
model_info::with_config_overrides(model_info::find_model_info_for_slug(model), config)
}
async fn get_etag(&self) -> Option<String> {
self.etag.read().await.clone()
}
@@ -213,49 +240,25 @@ impl ModelsManager {
/// Attempt to satisfy the refresh from the cache when it matches the provider and TTL.
async fn try_load_cache(&self) -> bool {
// todo(aibrahim): think if we should store fetched_at in ModelsManager so we don't always need to read the disk
let cache_path = self.cache_path();
let cache = match cache::load_cache(&cache_path).await {
Ok(cache) => cache,
Err(err) => {
error!("failed to load models cache: {err}");
return false;
}
};
let cache = match cache {
let cache = match self.cache_manager.load_fresh().await {
Some(cache) => cache,
None => return false,
};
if !cache.is_fresh(self.cache_ttl) {
return false;
}
let models = cache.models.clone();
*self.etag.write().await = cache.etag.clone();
self.apply_remote_models(models.clone()).await;
true
}
/// Serialize the latest fetch to disk for reuse across future processes.
async fn persist_cache(&self, models: &[ModelInfo], etag: Option<String>) {
let cache = ModelsCache {
fetched_at: Utc::now(),
etag,
models: models.to_vec(),
};
let cache_path = self.cache_path();
if let Err(err) = cache::save_cache(&cache_path, &cache).await {
error!("failed to write models cache: {err}");
}
}
/// Merge remote model metadata into picker-ready presets, preserving existing entries.
fn build_available_models(&self, mut remote_models: Vec<ModelInfo>) -> Vec<ModelPreset> {
remote_models.sort_by(|a, b| a.priority.cmp(&b.priority));
let remote_presets: Vec<ModelPreset> = remote_models.into_iter().map(Into::into).collect();
let existing_presets = self.local_models.clone();
let mut merged_presets = Self::merge_presets(remote_presets, existing_presets);
merged_presets = self.filter_visible_models(merged_presets);
let mut merged_presets = ModelPreset::merge(remote_presets, existing_presets);
let chatgpt_mode = self.auth_manager.get_auth_mode() == Some(AuthMode::ChatGPT);
merged_presets = ModelPreset::filter_by_auth(merged_presets, chatgpt_mode);
let has_default = merged_presets.iter().any(|preset| preset.is_default);
if !has_default {
@@ -272,40 +275,7 @@ impl ModelsManager {
merged_presets
}
fn filter_visible_models(&self, models: Vec<ModelPreset>) -> Vec<ModelPreset> {
let chatgpt_mode = self.auth_manager.get_auth_mode() == Some(AuthMode::ChatGPT);
models
.into_iter()
.filter(|model| chatgpt_mode || model.supported_in_api)
.collect()
}
fn merge_presets(
remote_presets: Vec<ModelPreset>,
existing_presets: Vec<ModelPreset>,
) -> Vec<ModelPreset> {
if remote_presets.is_empty() {
return existing_presets;
}
let remote_slugs: HashSet<&str> = remote_presets
.iter()
.map(|preset| preset.model.as_str())
.collect();
let mut merged_presets = remote_presets.clone();
for mut preset in existing_presets {
if remote_slugs.contains(preset.model.as_str()) {
continue;
}
preset.is_default = false;
merged_presets.push(preset);
}
merged_presets
}
async fn remote_models(&self, config: &Config) -> Vec<ModelInfo> {
async fn get_remote_models(&self, config: &Config) -> Vec<ModelInfo> {
if config.features.enabled(Feature::RemoteModels) {
self.remote_models.read().await.clone()
} else {
@@ -321,8 +291,35 @@ impl ModelsManager {
}
}
fn cache_path(&self) -> PathBuf {
self.codex_home.join(MODEL_CACHE_FILE)
#[cfg(any(test, feature = "test-support"))]
/// Construct a manager with a specific provider for testing.
pub fn with_provider(
codex_home: PathBuf,
auth_manager: Arc<AuthManager>,
provider: ModelProviderInfo,
) -> Self {
let cache_path = codex_home.join(MODEL_CACHE_FILE);
let cache_manager = ModelsCacheManager::new(cache_path, DEFAULT_MODEL_CACHE_TTL);
Self {
local_models: builtin_model_presets(auth_manager.get_auth_mode()),
remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()),
auth_manager,
etag: RwLock::new(None),
cache_manager,
provider,
}
}
#[cfg(any(test, feature = "test-support"))]
/// Get model identifier without consulting remote state or cache.
pub fn get_model_offline(model: Option<&str>) -> String {
model.unwrap_or(OPENAI_DEFAULT_CHATGPT_MODEL).to_string()
}
#[cfg(any(test, feature = "test-support"))]
/// Build `ModelInfo` without consulting remote state or cache.
pub fn construct_model_info_offline(model: &str, config: &Config) -> ModelInfo {
model_info::with_config_overrides(model_info::find_model_info_for_slug(model), config)
}
}
@@ -338,13 +335,13 @@ fn format_client_version_to_whole() -> String {
#[cfg(test)]
mod tests {
use super::cache::ModelsCache;
use super::*;
use crate::CodexAuth;
use crate::auth::AuthCredentialsStoreMode;
use crate::config::ConfigBuilder;
use crate::features::Feature;
use crate::model_provider_info::WireApi;
use chrono::Utc;
use codex_protocol::openai_models::ModelsResponse;
use core_test_support::responses::mount_models_once;
use pretty_assertions::assert_eq;
@@ -434,13 +431,15 @@ mod tests {
ModelsManager::with_provider(codex_home.path().to_path_buf(), auth_manager, provider);
manager
.refresh_available_models_with_cache(&config)
.refresh_available_models(&config, RefreshStrategy::OnlineIfUncached)
.await
.expect("refresh succeeds");
let cached_remote = manager.remote_models(&config).await;
let cached_remote = manager.get_remote_models(&config).await;
assert_eq!(cached_remote, remote_models);
let available = manager.list_models(&config).await;
let available = manager
.list_models(&config, RefreshStrategy::OnlineIfUncached)
.await;
let high_idx = available
.iter()
.position(|model| model.model == "priority-high")
@@ -494,22 +493,22 @@ mod tests {
ModelsManager::with_provider(codex_home.path().to_path_buf(), auth_manager, provider);
manager
.refresh_available_models_with_cache(&config)
.refresh_available_models(&config, RefreshStrategy::OnlineIfUncached)
.await
.expect("first refresh succeeds");
assert_eq!(
manager.remote_models(&config).await,
manager.get_remote_models(&config).await,
remote_models,
"remote cache should store fetched models"
);
// Second call should read from cache and avoid the network.
manager
.refresh_available_models_with_cache(&config)
.refresh_available_models(&config, RefreshStrategy::OnlineIfUncached)
.await
.expect("cached refresh succeeds");
assert_eq!(
manager.remote_models(&config).await,
manager.get_remote_models(&config).await,
remote_models,
"cache path should not mutate stored models"
);
@@ -549,19 +548,18 @@ mod tests {
ModelsManager::with_provider(codex_home.path().to_path_buf(), auth_manager, provider);
manager
.refresh_available_models_with_cache(&config)
.refresh_available_models(&config, RefreshStrategy::OnlineIfUncached)
.await
.expect("initial refresh succeeds");
// Rewrite cache with an old timestamp so it is treated as stale.
let cache_path = codex_home.path().join(MODEL_CACHE_FILE);
let contents =
std::fs::read_to_string(&cache_path).expect("cache file should exist after refresh");
let mut cache: ModelsCache =
serde_json::from_str(&contents).expect("cache should deserialize");
cache.fetched_at = Utc::now() - chrono::Duration::hours(1);
std::fs::write(&cache_path, serde_json::to_string_pretty(&cache).unwrap())
.expect("cache rewrite succeeds");
manager
.cache_manager
.manipulate_cache_for_test(|fetched_at| {
*fetched_at = Utc::now() - chrono::Duration::hours(1);
})
.await
.expect("cache manipulation succeeds");
let updated_models = vec![remote_model("fresh", "Fresh", 9)];
server.reset().await;
@@ -574,11 +572,11 @@ mod tests {
.await;
manager
.refresh_available_models_with_cache(&config)
.refresh_available_models(&config, RefreshStrategy::OnlineIfUncached)
.await
.expect("second refresh succeeds");
assert_eq!(
manager.remote_models(&config).await,
manager.get_remote_models(&config).await,
updated_models,
"stale cache should trigger refetch"
);
@@ -618,10 +616,10 @@ mod tests {
let provider = provider_for(server.uri());
let mut manager =
ModelsManager::with_provider(codex_home.path().to_path_buf(), auth_manager, provider);
manager.cache_ttl = Duration::ZERO;
manager.cache_manager.set_ttl(Duration::ZERO);
manager
.refresh_available_models_with_cache(&config)
.refresh_available_models(&config, RefreshStrategy::OnlineIfUncached)
.await
.expect("initial refresh succeeds");
@@ -636,7 +634,7 @@ mod tests {
.await;
manager
.refresh_available_models_with_cache(&config)
.refresh_available_models(&config, RefreshStrategy::OnlineIfUncached)
.await
.expect("second refresh succeeds");
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -138,8 +138,15 @@ impl ThreadManager {
self.state.models_manager.clone()
}
pub async fn list_models(&self, config: &Config) -> Vec<ModelPreset> {
self.state.models_manager.list_models(config).await
pub async fn list_models(
&self,
config: &Config,
refresh_strategy: crate::models_manager::manager::RefreshStrategy,
) -> Vec<ModelPreset> {
self.state
.models_manager
.list_models(config, refresh_strategy)
.await
}
pub async fn list_thread_ids(&self) -> Vec<ThreadId> {
+7 -2
View File
@@ -2,6 +2,7 @@ use anyhow::Result;
use codex_core::CodexAuth;
use codex_core::ThreadManager;
use codex_core::built_in_model_providers;
use codex_core::models_manager::manager::RefreshStrategy;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffortPreset;
@@ -18,7 +19,9 @@ async fn list_models_returns_api_key_models() -> Result<()> {
CodexAuth::from_api_key("sk-test"),
built_in_model_providers()["openai"].clone(),
);
let models = manager.list_models(&config).await;
let models = manager
.list_models(&config, RefreshStrategy::OnlineIfUncached)
.await;
let expected_models = expected_models_for_api_key();
assert_eq!(expected_models, models);
@@ -34,7 +37,9 @@ async fn list_models_returns_chatgpt_models() -> Result<()> {
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
built_in_model_providers()["openai"].clone(),
);
let models = manager.list_models(&config).await;
let models = manager
.list_models(&config, RefreshStrategy::OnlineIfUncached)
.await;
let expected_models = expected_models_for_chatgpt();
assert_eq!(expected_models, models);
+1
View File
@@ -42,6 +42,7 @@ mod live_cli;
mod model_info_overrides;
mod model_overrides;
mod model_tools;
mod models_cache_ttl;
mod models_etag_responses;
mod otel;
mod pending_input;
@@ -0,0 +1,184 @@
use std::path::Path;
use std::sync::Arc;
use anyhow::Result;
use chrono::DateTime;
use chrono::TimeZone;
use chrono::Utc;
use codex_core::CodexAuth;
use codex_core::features::Feature;
use codex_core::models_manager::manager::RefreshStrategy;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelVisibility;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::openai_models::TruncationPolicyConfig;
use codex_protocol::user_input::UserInput;
use core_test_support::responses;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::sse;
use core_test_support::responses::sse_response;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde::Deserialize;
use serde::Serialize;
use wiremock::MockServer;
const ETAG: &str = "\"models-etag-ttl\"";
const CACHE_FILE: &str = "models_cache.json";
const REMOTE_MODEL: &str = "codex-test-ttl";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> {
let server = MockServer::start().await;
let remote_model = test_remote_model(REMOTE_MODEL, 1);
let models_mock = responses::mount_models_once_with_etag(
&server,
ModelsResponse {
models: vec![remote_model.clone()],
},
ETAG,
)
.await;
let mut builder = test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing());
builder = builder.with_config(|config| {
config.features.enable(Feature::RemoteModels);
config.model = Some("gpt-5".to_string());
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(1);
});
let test = builder.build(&server).await?;
let codex = Arc::clone(&test.codex);
let config = test.config.clone();
// Populate cache via initial refresh.
let models_manager = test.thread_manager.get_models_manager();
let _ = models_manager
.list_models(&config, RefreshStrategy::OnlineIfUncached)
.await;
let cache_path = config.codex_home.join(CACHE_FILE);
let stale_time = Utc.timestamp_opt(0, 0).single().expect("valid epoch");
rewrite_cache_timestamp(&cache_path, stale_time).await?;
// Trigger responses with matching ETag, which should renew the cache TTL without another /models.
let response_body = sse(vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-1"),
]);
let _responses_mock = responses::mount_response_once(
&server,
sse_response(response_body).insert_header("X-Models-Etag", ETAG),
)
.await;
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text { text: "hi".into() }],
final_output_json_schema: None,
cwd: test.cwd_path().to_path_buf(),
approval_policy: codex_core::protocol::AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: test.session_configured.model.clone(),
effort: None,
summary: ReasoningSummary::Auto,
})
.await?;
let _ = wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let refreshed_cache = read_cache(&cache_path).await?;
assert!(
refreshed_cache.fetched_at > stale_time,
"cache TTL should be renewed"
);
assert_eq!(
models_mock.requests().len(),
1,
"/models should not refetch on matching etag"
);
// Cached models remain usable offline.
let offline_models = test
.thread_manager
.list_models(&config, RefreshStrategy::Offline)
.await;
assert!(
offline_models
.iter()
.any(|preset| preset.model == REMOTE_MODEL),
"offline listing should use renewed cache"
);
Ok(())
}
async fn rewrite_cache_timestamp(path: &Path, fetched_at: DateTime<Utc>) -> Result<()> {
let mut cache = read_cache(path).await?;
cache.fetched_at = fetched_at;
let contents = serde_json::to_vec_pretty(&cache)?;
tokio::fs::write(path, contents).await?;
Ok(())
}
async fn read_cache(path: &Path) -> Result<ModelsCache> {
let contents = tokio::fs::read(path).await?;
let cache = serde_json::from_slice(&contents)?;
Ok(cache)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ModelsCache {
fetched_at: DateTime<Utc>,
#[serde(default)]
etag: Option<String>,
models: Vec<ModelInfo>,
}
fn test_remote_model(slug: &str, priority: i32) -> ModelInfo {
ModelInfo {
slug: slug.to_string(),
display_name: "Remote Test".to_string(),
description: Some("remote model".to_string()),
default_reasoning_level: Some(ReasoningEffort::Medium),
supported_reasoning_levels: vec![
ReasoningEffortPreset {
effort: ReasoningEffort::Low,
description: "low".to_string(),
},
ReasoningEffortPreset {
effort: ReasoningEffort::Medium,
description: "medium".to_string(),
},
],
shell_type: ConfigShellToolType::ShellCommand,
visibility: ModelVisibility::List,
supported_in_api: true,
priority,
upgrade: None,
base_instructions: "base instructions".to_string(),
supports_reasoning_summaries: false,
support_verbosity: false,
default_verbosity: None,
apply_patch_tool_type: None,
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
context_window: Some(272_000),
auto_compact_token_limit: None,
effective_context_window_percent: 95,
experimental_supported_tools: Vec::new(),
}
}
+1 -1
View File
@@ -85,7 +85,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> {
.await?;
let base_instructions = thread_manager
.get_models_manager()
.construct_model_info(
.get_model_info(
config
.model
.as_deref()
+26 -28
View File
@@ -7,9 +7,9 @@ use codex_core::CodexAuth;
use codex_core::ModelProviderInfo;
use codex_core::built_in_model_providers;
use codex_core::config::Config;
use codex_core::error::CodexErr;
use codex_core::features::Feature;
use codex_core::models_manager::manager::ModelsManager;
use codex_core::models_manager::manager::RefreshStrategy;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecCommandSource;
@@ -127,7 +127,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
assert_eq!(requests[0].url.path(), "/v1/models");
let model_info = models_manager
.construct_model_info(REMOTE_MODEL_SLUG, &config)
.get_model_info(REMOTE_MODEL_SLUG, &config)
.await;
assert_eq!(model_info.shell_type, ConfigShellToolType::UnifiedExec);
@@ -225,9 +225,7 @@ async fn remote_models_truncation_policy_without_override_preserves_remote() ->
let models_manager = test.thread_manager.get_models_manager();
wait_for_model_available(&models_manager, slug, &test.config).await;
let model_info = models_manager
.construct_model_info(slug, &test.config)
.await;
let model_info = models_manager.get_model_info(slug, &test.config).await;
assert_eq!(
model_info.truncation_policy,
TruncationPolicyConfig::bytes(12_000)
@@ -273,9 +271,7 @@ async fn remote_models_truncation_policy_with_tool_output_override() -> Result<(
let models_manager = test.thread_manager.get_models_manager();
wait_for_model_available(&models_manager, slug, &test.config).await;
let model_info = models_manager
.construct_model_info(slug, &test.config)
.await;
let model_info = models_manager.get_model_info(slug, &test.config).await;
assert_eq!(
model_info.truncation_policy,
TruncationPolicyConfig::bytes(200)
@@ -423,12 +419,9 @@ async fn remote_models_preserve_builtin_presets() -> Result<()> {
provider,
);
manager
.refresh_available_models_with_cache(&config)
.await
.expect("refresh succeeds");
let available = manager.list_models(&config).await;
let available = manager
.list_models(&config, RefreshStrategy::OnlineIfUncached)
.await;
let remote = available
.iter()
.find(|model| model.model == "remote-alpha")
@@ -483,22 +476,25 @@ async fn remote_models_request_times_out_after_5s() -> Result<()> {
);
let start = Instant::now();
let refresh = timeout(
let model = timeout(
Duration::from_secs(7),
manager.refresh_available_models_with_cache(&config),
manager.get_default_model(&None, &config, RefreshStrategy::OnlineIfUncached),
)
.await;
let elapsed = start.elapsed();
let err = refresh
.expect("refresh should finish")
.expect_err("refresh should time out");
let request_summaries: Vec<String> = server
// get_model should return a default model even when refresh times out
let default_model = model.expect("get_model should finish and return default model");
assert!(
default_model == "gpt-5.2-codex",
"get_model should return default model when refresh times out, got: {default_model}"
);
let _ = server
.received_requests()
.await
.expect("mock server should capture requests")
.iter()
.map(|req| format!("{} {}", req.method, req.url.path()))
.collect();
.collect::<Vec<String>>();
assert!(
elapsed >= Duration::from_millis(4_500),
"expected models call to block near the timeout; took {elapsed:?}"
@@ -507,10 +503,6 @@ async fn remote_models_request_times_out_after_5s() -> Result<()> {
elapsed < Duration::from_millis(5_800),
"expected models call to time out before the delayed response; took {elapsed:?}"
);
match err {
CodexErr::Timeout => {}
other => panic!("expected timeout error, got {other:?}; requests: {request_summaries:?}"),
}
assert_eq!(
models_mock.requests().len(),
1,
@@ -550,10 +542,14 @@ async fn remote_models_hide_picker_only_models() -> Result<()> {
provider,
);
let selected = manager.get_model(&None, &config).await;
let selected = manager
.get_default_model(&None, &config, RefreshStrategy::OnlineIfUncached)
.await;
assert_eq!(selected, "gpt-5.2-codex");
let available = manager.list_models(&config).await;
let available = manager
.list_models(&config, RefreshStrategy::OnlineIfUncached)
.await;
let hidden = available
.iter()
.find(|model| model.model == "codex-auto-balanced")
@@ -571,7 +567,9 @@ async fn wait_for_model_available(
let deadline = Instant::now() + Duration::from_secs(2);
loop {
if let Some(model) = {
let guard = manager.list_models(config).await;
let guard = manager
.list_models(config, RefreshStrategy::OnlineIfUncached)
.await;
guard.iter().find(|model| model.model == slug).cloned()
} {
return model;
+12 -9
View File
@@ -29,6 +29,7 @@ use codex_core::config::find_codex_home;
use codex_core::config::load_config_as_toml_with_cli_overrides;
use codex_core::config::resolve_oss_provider;
use codex_core::git_info::get_git_repo_root;
use codex_core::models_manager::manager::RefreshStrategy;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
@@ -233,15 +234,17 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
}
};
let otel =
codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"), None, false);
#[allow(clippy::print_stderr)]
let otel = match otel {
Ok(otel) => otel,
Err(e) => {
let otel = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"), None, false)
})) {
Ok(Ok(otel)) => otel,
Ok(Err(e)) => {
eprintln!("Could not create otel exporter: {e}");
std::process::exit(1);
None
}
Err(_) => {
eprintln!("Could not create otel exporter: panicked during initialization");
None
}
};
@@ -310,7 +313,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
);
let default_model = thread_manager
.get_models_manager()
.get_model(&config.model, &config)
.get_default_model(&config.model, &config, RefreshStrategy::OnlineIfUncached)
.await;
// Handle resume subcommand by resolving a rollout path and using explicit resume API.
+81 -27
View File
@@ -32,6 +32,24 @@ use tokio::sync::Mutex;
pub(crate) const INVALID_PARAMS_ERROR_CODE: i64 = -32602;
fn call_tool_result_with_conversation_id(
conversation_id: ThreadId,
text: String,
is_error: Option<bool>,
) -> CallToolResult {
CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_string(),
text,
annotations: None,
})],
is_error,
structured_content: Some(json!({
"conversationId": conversation_id,
})),
}
}
/// Run a complete Codex session and stream events back to the client.
///
/// On completion (success or error) the function sends the appropriate
@@ -73,7 +91,10 @@ pub async fn run_codex_tool_session(
outgoing
.send_event_as_notification(
&session_configured_event,
Some(OutgoingNotificationMeta::new(Some(id.clone()))),
Some(OutgoingNotificationMeta::new(
Some(id.clone()),
Some(thread_id),
)),
)
.await;
@@ -100,12 +121,25 @@ pub async fn run_codex_tool_session(
if let Err(e) = thread.submit_with_id(submission).await {
tracing::error!("Failed to submit initial prompt: {e}");
let result = call_tool_result_with_conversation_id(
thread_id,
format!("Failed to submit initial prompt: {e}"),
Some(true),
);
outgoing.send_response(id.clone(), result).await;
// unregister the id so we don't keep it in the map
running_requests_id_to_codex_uuid.lock().await.remove(&id);
return;
}
run_codex_tool_session_inner(thread, outgoing, id, running_requests_id_to_codex_uuid).await;
run_codex_tool_session_inner(
thread,
outgoing,
id,
thread_id,
running_requests_id_to_codex_uuid,
)
.await;
}
pub async fn run_codex_tool_session_reply(
@@ -128,6 +162,12 @@ pub async fn run_codex_tool_session_reply(
.await
{
tracing::error!("Failed to submit user input: {e}");
let result = call_tool_result_with_conversation_id(
conversation_id,
format!("Failed to submit user input: {e}"),
Some(true),
);
outgoing.send_response(request_id.clone(), result).await;
// unregister the id so we don't keep it in the map
running_requests_id_to_codex_uuid
.lock()
@@ -140,6 +180,7 @@ pub async fn run_codex_tool_session_reply(
conversation,
outgoing,
request_id,
conversation_id,
running_requests_id_to_codex_uuid,
)
.await;
@@ -149,6 +190,7 @@ async fn run_codex_tool_session_inner(
codex: Arc<CodexThread>,
outgoing: Arc<OutgoingMessageSender>,
request_id: RequestId,
conversation_id: ThreadId,
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, ThreadId>>>,
) {
let request_id_str = match &request_id {
@@ -164,7 +206,10 @@ async fn run_codex_tool_session_inner(
outgoing
.send_event_as_notification(
&event,
Some(OutgoingNotificationMeta::new(Some(request_id.clone()))),
Some(OutgoingNotificationMeta::new(
Some(request_id.clone()),
Some(conversation_id),
)),
)
.await;
@@ -188,15 +233,18 @@ async fn run_codex_tool_session_inner(
event.id.clone(),
call_id,
parsed_cmd,
conversation_id,
)
.await;
continue;
}
EventMsg::Error(err_event) => {
// Return a response to conclude the tool call when the Codex session reports an error (e.g., interruption).
let result = json!({
"error": err_event.message,
});
// Always respond in tools/call's expected shape, and include conversationId so the client can resume.
let result = call_tool_result_with_conversation_id(
conversation_id,
err_event.message,
Some(true),
);
outgoing.send_response(request_id.clone(), result).await;
break;
}
@@ -224,6 +272,7 @@ async fn run_codex_tool_session_inner(
request_id.clone(),
request_id_str.clone(),
event.id.clone(),
conversation_id,
)
.await;
continue;
@@ -233,15 +282,8 @@ async fn run_codex_tool_session_inner(
Some(msg) => msg,
None => "".to_string(),
};
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_string(),
text,
annotations: None,
})],
is_error: None,
structured_content: None,
};
let result =
call_tool_result_with_conversation_id(conversation_id, text, None);
outgoing.send_response(request_id.clone(), result).await;
// unregister the id so we don't keep it in the map
running_requests_id_to_codex_uuid
@@ -317,20 +359,32 @@ async fn run_codex_tool_session_inner(
}
}
Err(e) => {
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_string(),
text: format!("Codex runtime error: {e}"),
annotations: None,
})],
is_error: Some(true),
// TODO(mbolin): Could present the error in a more
// structured way.
structured_content: None,
};
let result = call_tool_result_with_conversation_id(
conversation_id,
format!("Codex runtime error: {e}"),
Some(true),
);
outgoing.send_response(request_id.clone(), result).await;
break;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn call_tool_result_includes_conversation_id_in_structured_content() {
let thread_id = ThreadId::new();
let result = call_tool_result_with_conversation_id(thread_id, "done".to_string(), None);
assert_eq!(
result.structured_content,
Some(json!({
"conversationId": thread_id,
}))
);
}
}
+5
View File
@@ -4,6 +4,7 @@ use std::sync::Arc;
use codex_core::CodexThread;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
use codex_protocol::ThreadId;
use codex_protocol::parse_command::ParsedCommand;
use mcp_types::ElicitRequest;
use mcp_types::ElicitRequestParamsRequestedSchema;
@@ -30,6 +31,8 @@ pub struct ExecApprovalElicitRequestParams {
// These are additional fields the client can use to
// correlate the request with the codex tool call.
#[serde(rename = "threadId")]
pub thread_id: ThreadId,
pub codex_elicitation: String,
pub codex_mcp_tool_call_id: String,
pub codex_event_id: String,
@@ -59,6 +62,7 @@ pub(crate) async fn handle_exec_approval_request(
event_id: String,
call_id: String,
codex_parsed_cmd: Vec<ParsedCommand>,
thread_id: ThreadId,
) {
let escaped_command =
shlex::try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" "));
@@ -74,6 +78,7 @@ pub(crate) async fn handle_exec_approval_request(
properties: json!({}),
required: None,
},
thread_id,
codex_elicitation: "exec-approval".to_string(),
codex_mcp_tool_call_id: tool_call_id.clone(),
codex_event_id: event_id.clone(),
+3 -1
View File
@@ -507,7 +507,9 @@ impl MessageProcessor {
annotations: None,
})],
is_error: Some(true),
structured_content: None,
structured_content: Some(json!({
"conversationId": conversation_id,
})),
};
outgoing.send_response(request_id, result).await;
return;
+73 -2
View File
@@ -3,6 +3,7 @@ use std::sync::atomic::AtomicI64;
use std::sync::atomic::Ordering;
use codex_core::protocol::Event;
use codex_protocol::ThreadId;
use mcp_types::JSONRPC_VERSION;
use mcp_types::JSONRPCError;
use mcp_types::JSONRPCErrorError;
@@ -209,11 +210,16 @@ pub(crate) struct OutgoingNotificationParams {
#[serde(rename_all = "camelCase")]
pub(crate) struct OutgoingNotificationMeta {
pub request_id: Option<RequestId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<ThreadId>,
}
impl OutgoingNotificationMeta {
pub(crate) fn new(request_id: Option<RequestId>) -> Self {
Self { request_id }
pub(crate) fn new(request_id: Option<RequestId>, conversation_id: Option<ThreadId>) -> Self {
Self {
request_id,
conversation_id,
}
}
}
@@ -313,6 +319,7 @@ mod tests {
};
let meta = OutgoingNotificationMeta {
request_id: Some(RequestId::String("123".to_string())),
conversation_id: None,
};
outgoing_message_sender
@@ -348,4 +355,68 @@ mod tests {
assert_eq!(params.unwrap(), expected_params);
Ok(())
}
#[tokio::test]
async fn test_send_event_as_notification_with_meta_and_conversation_id() -> Result<()> {
let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::<OutgoingMessage>();
let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx);
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new()?;
let session_configured_event = SessionConfiguredEvent {
session_id: conversation_id,
model: "gpt-4o".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: Some(ReasoningEffort::default()),
history_log_id: 1,
history_entry_count: 1000,
initial_messages: None,
rollout_path: rollout_file.path().to_path_buf(),
};
let event = Event {
id: "1".to_string(),
msg: EventMsg::SessionConfigured(session_configured_event.clone()),
};
let meta = OutgoingNotificationMeta {
request_id: Some(RequestId::String("123".to_string())),
conversation_id: Some(conversation_id),
};
outgoing_message_sender
.send_event_as_notification(&event, Some(meta))
.await;
let result = outgoing_rx.recv().await.unwrap();
let OutgoingMessage::Notification(OutgoingNotification { method, params }) = result else {
panic!("expected Notification for first message");
};
assert_eq!(method, "codex/event");
let expected_params = json!({
"_meta": {
"requestId": "123",
"conversationId": conversation_id.to_string(),
},
"id": "1",
"msg": {
"type": "session_configured",
"session_id": session_configured_event.session_id,
"model": "gpt-4o",
"model_provider_id": "test-provider",
"approval_policy": "never",
"sandbox_policy": {
"type": "read-only"
},
"cwd": "/home/user/project",
"reasoning_effort": session_configured_event.reasoning_effort,
"history_log_id": session_configured_event.history_log_id,
"history_entry_count": session_configured_event.history_entry_count,
"rollout_path": rollout_file.path().to_path_buf(),
}
});
assert_eq!(params.unwrap(), expected_params);
Ok(())
}
}
+6 -1
View File
@@ -6,6 +6,7 @@ use codex_core::CodexThread;
use codex_core::protocol::FileChange;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
use codex_protocol::ThreadId;
use mcp_types::ElicitRequest;
use mcp_types::ElicitRequestParamsRequestedSchema;
use mcp_types::JSONRPCErrorError;
@@ -19,11 +20,13 @@ use tracing::error;
use crate::codex_tool_runner::INVALID_PARAMS_ERROR_CODE;
use crate::outgoing_message::OutgoingMessageSender;
#[derive(Debug, Serialize)]
#[derive(Debug, Deserialize, Serialize)]
pub struct PatchApprovalElicitRequestParams {
pub message: String,
#[serde(rename = "requestedSchema")]
pub requested_schema: ElicitRequestParamsRequestedSchema,
#[serde(rename = "threadId")]
pub thread_id: ThreadId,
pub codex_elicitation: String,
pub codex_mcp_tool_call_id: String,
pub codex_event_id: String,
@@ -51,6 +54,7 @@ pub(crate) async fn handle_patch_approval_request(
request_id: RequestId,
tool_call_id: String,
event_id: String,
thread_id: ThreadId,
) {
let mut message_lines = Vec::new();
if let Some(r) = &reason {
@@ -65,6 +69,7 @@ pub(crate) async fn handle_patch_approval_request(
properties: json!({}),
required: None,
},
thread_id,
codex_elicitation: "patch-approval".to_string(),
codex_mcp_tool_call_id: tool_call_id.clone(),
codex_event_id: event_id.clone(),
+42 -18
View File
@@ -119,6 +119,7 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
workdir_for_shell_function_call.path(),
codex_request_id.to_string(),
params.codex_event_id.clone(),
params.thread_id,
)?;
assert_eq!(expected_elicitation_request, elicitation_request);
@@ -158,7 +159,10 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
"text": "File created!",
"type": "text"
}
]
],
"structuredContent": {
"threadId": params.thread_id,
}
}),
},
codex_response
@@ -175,6 +179,7 @@ fn create_expected_elicitation_request(
workdir: &Path,
codex_mcp_tool_call_id: String,
codex_event_id: String,
thread_id: codex_protocol::ThreadId,
) -> anyhow::Result<JSONRPCRequest> {
let expected_message = format!(
"Allow Codex to run `{}` in `{}`?",
@@ -193,6 +198,7 @@ fn create_expected_elicitation_request(
properties: json!({}),
required: None,
},
thread_id,
codex_elicitation: "exec-approval".to_string(),
codex_mcp_tool_call_id,
codex_event_id,
@@ -260,7 +266,13 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> {
)
.await??;
let elicitation_request_id = RequestId::Integer(0);
let elicitation_request_id = elicitation_request.id.clone();
let params = serde_json::from_value::<PatchApprovalElicitRequestParams>(
elicitation_request
.params
.clone()
.ok_or_else(|| anyhow::anyhow!("elicitation_request.params must be set"))?,
)?;
let mut expected_changes = HashMap::new();
expected_changes.insert(
@@ -277,7 +289,8 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> {
None, // No grant_root expected
None, // No reason expected
codex_request_id.to_string(),
"1".to_string(),
params.codex_event_id.clone(),
params.thread_id,
)?;
assert_eq!(expected_elicitation_request, elicitation_request);
@@ -307,7 +320,10 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> {
"text": "Patch has been applied successfully!",
"type": "text"
}
]
],
"structuredContent": {
"threadId": params.thread_id,
}
}),
},
codex_response
@@ -331,7 +347,7 @@ async fn test_codex_tool_passes_base_instructions() {
}
async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> {
#![expect(clippy::unwrap_used)]
#![expect(clippy::expect_used, clippy::unwrap_used)]
let server =
create_mock_chat_completions_server(vec![create_final_assistant_message_sse_response(
@@ -360,20 +376,26 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> {
mcp_process.read_stream_until_response_message(RequestId::Integer(codex_request_id)),
)
.await??;
assert_eq!(codex_response.jsonrpc, JSONRPC_VERSION);
assert_eq!(codex_response.id, RequestId::Integer(codex_request_id));
assert_eq!(
JSONRPCResponse {
jsonrpc: JSONRPC_VERSION.into(),
id: RequestId::Integer(codex_request_id),
result: json!({
"content": [
{
"text": "Enjoy!",
"type": "text"
}
]
}),
},
codex_response
codex_response.result,
json!({
"content": [
{
"text": "Enjoy!",
"type": "text"
}
],
"structuredContent": {
"threadId": codex_response
.result
.get("structuredContent")
.and_then(|v| v.get("threadId"))
.and_then(serde_json::Value::as_str)
.expect("codex tool response should include structuredContent.threadId"),
}
})
);
let requests = server.received_requests().await.unwrap();
@@ -412,6 +434,7 @@ fn create_expected_patch_approval_elicitation_request(
reason: Option<String>,
codex_mcp_tool_call_id: String,
codex_event_id: String,
thread_id: codex_protocol::ThreadId,
) -> anyhow::Result<JSONRPCRequest> {
let mut message_lines = Vec::new();
if let Some(r) = &reason {
@@ -430,6 +453,7 @@ fn create_expected_patch_approval_elicitation_request(
properties: json!({}),
required: None,
},
thread_id,
codex_elicitation: "patch-approval".to_string(),
codex_mcp_tool_call_id,
codex_event_id,
+41
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::collections::HashSet;
use schemars::JsonSchema;
use serde::Deserialize;
@@ -243,6 +244,46 @@ impl From<ModelInfo> for ModelPreset {
}
}
impl ModelPreset {
/// Filter models based on authentication mode.
///
/// In ChatGPT mode, all models are visible. Otherwise, only API-supported models are shown.
pub fn filter_by_auth(models: Vec<ModelPreset>, chatgpt_mode: bool) -> Vec<ModelPreset> {
models
.into_iter()
.filter(|model| chatgpt_mode || model.supported_in_api)
.collect()
}
/// Merge remote presets with existing presets, preferring remote when slugs match.
///
/// Remote presets take precedence. Existing presets not in remote are appended with `is_default` set to false.
pub fn merge(
remote_presets: Vec<ModelPreset>,
existing_presets: Vec<ModelPreset>,
) -> Vec<ModelPreset> {
if remote_presets.is_empty() {
return existing_presets;
}
let remote_slugs: HashSet<&str> = remote_presets
.iter()
.map(|preset| preset.model.as_str())
.collect();
let mut merged_presets = remote_presets.clone();
for mut preset in existing_presets {
if remote_slugs.contains(preset.model.as_str()) {
continue;
}
preset.is_default = false;
merged_presets.push(preset);
}
merged_presets
}
}
fn reasoning_effort_mapping_from_presets(
presets: &[ReasoningEffortPreset],
) -> Option<HashMap<ReasoningEffort, ReasoningEffort>> {
+9 -6
View File
@@ -32,7 +32,7 @@ use codex_core::config::edit::ConfigEdit;
use codex_core::config::edit::ConfigEditsBuilder;
#[cfg(target_os = "windows")]
use codex_core::features::Feature;
use codex_core::models_manager::manager::ModelsManager;
use codex_core::models_manager::manager::RefreshStrategy;
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
use codex_core::protocol::DeprecationNoticeEvent;
@@ -210,9 +210,8 @@ async fn handle_model_migration_prompt_if_needed(
config: &mut Config,
model: &str,
app_event_tx: &AppEventSender,
models_manager: Arc<ModelsManager>,
available_models: Vec<ModelPreset>,
) -> Option<AppExitInfo> {
let available_models = models_manager.list_models(config).await;
let upgrade = available_models
.iter()
.find(|preset| preset.model == model)
@@ -383,14 +382,18 @@ impl App {
));
let mut model = thread_manager
.get_models_manager()
.get_model(&config.model, &config)
.get_default_model(&config.model, &config, RefreshStrategy::Offline)
.await;
let available_models = thread_manager
.get_models_manager()
.list_models(&config, RefreshStrategy::Offline)
.await;
let exit_info = handle_model_migration_prompt_if_needed(
tui,
&mut config,
model.as_str(),
&app_event_tx,
thread_manager.get_models_manager(),
available_models,
)
.await;
if let Some(exit_info) = exit_info {
@@ -619,7 +622,7 @@ impl App {
let model_info = self
.server
.get_models_manager()
.construct_model_info(self.current_model.as_str(), &self.config)
.get_model_info(self.current_model.as_str(), &self.config)
.await;
match event {
AppEvent::NewSession => {
+144 -12
View File
@@ -33,7 +33,8 @@
//! burst detection for actual paste streams.
//!
//! The burst detector can also be disabled (`disable_paste_burst`), which bypasses the state
//! machine and treats the key stream as normal typing.
//! machine and treats the key stream as normal typing. When toggling from enabled → disabled, the
//! composer flushes/clears any in-flight burst state so it cannot leak into subsequent input.
//!
//! For the detailed burst state machine, see `codex-rs/tui/src/bottom_pane/paste_burst.rs`.
//! For a narrative overview of the combined state machine, see `docs/tui-chat-composer.md`.
@@ -379,16 +380,27 @@ impl ChatComposer {
/// `disable_paste_burst` is an escape hatch for terminals/platforms where the burst heuristic
/// is unwanted or has already been handled elsewhere.
///
/// When enabling the flag we clear the burst classification window so subsequent input cannot
/// be incorrectly grouped into a previous burst.
/// When transitioning from enabled → disabled, we "defuse" any in-flight burst state so it
/// cannot affect subsequent normal typing:
///
/// This does not flush any in-progress buffer; callers should avoid toggling this mid-burst
/// (or should flush first).
/// - First, flush any held/buffered text immediately via
/// [`PasteBurst::flush_before_modified_input`], and feed it through `handle_paste(String)`.
/// This preserves user input and routes it through the same integration path as explicit
/// pastes (large-paste placeholders, image-path detection, and popup sync).
/// - Then clear the burst timing and Enter-suppression window via
/// [`PasteBurst::clear_after_explicit_paste`].
///
/// We intentionally do not use `clear_window_after_non_char()` here: it clears timing state
/// without emitting any buffered text, which can leave a non-empty buffer unable to flush
/// later (because `flush_if_due()` relies on `last_plain_char_time` to time out).
pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) {
let was_disabled = self.disable_paste_burst;
self.disable_paste_burst = disabled;
if disabled && !was_disabled {
self.paste_burst.clear_window_after_non_char();
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
}
self.paste_burst.clear_after_explicit_paste();
}
}
@@ -799,6 +811,15 @@ impl ChatComposer {
/// the cursor to a UTF-8 char boundary before slicing `textarea.text()`.
#[inline]
fn handle_non_ascii_char(&mut self, input: KeyEvent) -> (InputResult, bool) {
if self.disable_paste_burst {
// When burst detection is disabled, treat IME/non-ASCII input as normal typing.
// In particular, do not retro-capture or buffer already-inserted prefix text.
self.textarea.input(input);
let text_after = self.textarea.text();
self.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));
return (InputResult::None, true);
}
if let KeyEvent {
code: KeyCode::Char(ch),
..
@@ -1372,7 +1393,7 @@ impl ChatComposer {
.next()
.unwrap_or("")
.starts_with('/');
if self.paste_burst.is_active() && !in_slash_context {
if !self.disable_paste_burst && self.paste_burst.is_active() && !in_slash_context {
let now = Instant::now();
if self.paste_burst.append_newline_if_active(now) {
return (InputResult::None, true);
@@ -1381,10 +1402,11 @@ impl ChatComposer {
// During a paste-like burst, treat Enter/Ctrl+Shift+Q as a newline instead of submit.
let now = Instant::now();
if self
.paste_burst
.newline_should_insert_instead_of_submit(now)
&& !in_slash_context
if !in_slash_context
&& !self.disable_paste_burst
&& self
.paste_burst
.newline_should_insert_instead_of_submit(now)
{
self.textarea.insert_str("\n");
self.paste_burst.extend_window(now);
@@ -1580,6 +1602,7 @@ impl ChatComposer {
// If we're capturing a burst and receive Enter, accumulate it instead of inserting.
if matches!(input.code, KeyCode::Enter)
&& !self.disable_paste_burst
&& self.paste_burst.is_active()
&& self.paste_burst.append_newline_if_active(now)
{
@@ -1598,7 +1621,7 @@ impl ChatComposer {
} = input
{
let has_ctrl_or_alt = has_ctrl_or_alt(modifiers);
if !has_ctrl_or_alt {
if !has_ctrl_or_alt && !self.disable_paste_burst {
// Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts, so avoid
// holding the first char while still allowing burst detection for paste input.
if !ch.is_ascii() {
@@ -1644,6 +1667,17 @@ impl ChatComposer {
}
}
// Flush any buffered burst before applying a non-char input (arrow keys, etc).
//
// `clear_window_after_non_char()` clears `last_plain_char_time`. If we cleared that while
// `PasteBurst.buffer` is non-empty, `flush_if_due()` would no longer have a timestamp to
// time out against, and the buffered paste could remain stuck until another plain char
// arrives.
if !matches!(input.code, KeyCode::Char(_) | KeyCode::Enter)
&& let Some(pasted) = self.paste_burst.flush_before_modified_input()
{
self.handle_paste(pasted);
}
// Backspace at the start of an image placeholder should delete that placeholder (rather
// than deleting content before it). Do this without scanning the full text by consulting
// the textarea's element list.
@@ -2921,6 +2955,104 @@ mod tests {
assert_eq!(composer.textarea.text(), "hi\nthere");
}
/// Behavior: even if Enter suppression would normally be active for a burst, Enter should
/// still dispatch a built-in slash command when the first line begins with `/`.
#[test]
fn slash_context_enter_ignores_paste_burst_enter_suppression() {
use crate::slash_command::SlashCommand;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.textarea.set_text("/diff");
composer.textarea.set_cursor("/diff".len());
composer
.paste_burst
.begin_with_retro_grabbed(String::new(), Instant::now());
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Command(SlashCommand::Diff)));
}
/// Behavior: if a burst is buffering text and the user presses a non-char key, flush the
/// buffered burst *before* applying that key so the buffer cannot get stuck.
#[test]
fn non_char_key_flushes_active_burst_before_input() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Force an active burst so we can deterministically buffer characters without relying on
// timing.
composer
.paste_burst
.begin_with_retro_grabbed(String::new(), Instant::now());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
assert!(composer.textarea.text().is_empty());
assert!(composer.is_in_paste_burst());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
assert_eq!(composer.textarea.text(), "hi");
assert_eq!(composer.textarea.cursor(), 1);
assert!(!composer.is_in_paste_burst());
}
/// Behavior: enabling `disable_paste_burst` flushes any held first character (flicker
/// suppression) and then inserts subsequent chars immediately without creating burst state.
#[test]
fn disable_paste_burst_flushes_pending_first_char_and_inserts_immediately() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// First ASCII char is normally held briefly. Flip the config mid-stream and ensure the
// held char is not dropped.
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
assert!(composer.is_in_paste_burst());
assert!(composer.textarea.text().is_empty());
composer.set_disable_paste_burst(true);
assert_eq!(composer.textarea.text(), "a");
assert!(!composer.is_in_paste_burst());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE));
assert_eq!(composer.textarea.text(), "ab");
assert!(!composer.is_in_paste_burst());
}
/// Behavior: a small explicit paste inserts text directly (no placeholder), and the submitted
/// text matches what is visible in the textarea.
#[test]
+17 -9
View File
@@ -301,15 +301,23 @@ pub async fn run_main(
ensure_oss_provider_ready(provider_id, &config).await?;
}
let otel =
codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"), None, true);
#[allow(clippy::print_stderr)]
let otel = match otel {
Ok(otel) => otel,
Err(e) => {
eprintln!("Could not create otel exporter: {e}");
std::process::exit(1);
let otel = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"), None, true)
})) {
Ok(Ok(otel)) => otel,
Ok(Err(e)) => {
#[allow(clippy::print_stderr)]
{
eprintln!("Could not create otel exporter: {e}");
}
None
}
Err(_) => {
#[allow(clippy::print_stderr)]
{
eprintln!("Could not create otel exporter: panicked during initialization");
}
None
}
};
@@ -35,14 +35,14 @@ model_provider = "ollama"
std::fs::write(codex_home.join("config.toml"), config_contents)?;
let CodexCliOutput { exit_code, output } = run_codex_cli(codex_home, cwd).await?;
assert_eq!(1, exit_code, "Codex CLI should exit nonzero.");
assert_ne!(0, exit_code, "Codex CLI should exit nonzero.");
assert!(
output.contains("ERROR: Failed to initialize codex:"),
"expected startup error in output, got: {output}"
);
assert!(
output.contains("failed to read execpolicy files"),
"expected execpolicy read error in output, got: {output}"
output.contains("failed to read rules files"),
"expected rules read error in output, got: {output}"
);
Ok(())
}
@@ -63,7 +63,7 @@ async fn run_codex_cli(
codex_home.as_ref().display().to_string(),
);
let args = vec!["-c".to_string(), "analytics_enabled=false".to_string()];
let args = vec!["-c".to_string(), "analytics.enabled=false".to_string()];
let spawned = codex_utils_pty::spawn_pty_process(
codex_cli.to_string_lossy().as_ref(),
&args,
+8 -5
View File
@@ -49,7 +49,7 @@ use codex_core::config::Config;
use codex_core::config::edit::ConfigEditsBuilder;
#[cfg(target_os = "windows")]
use codex_core::features::Feature;
use codex_core::models_manager::manager::ModelsManager;
use codex_core::models_manager::manager::RefreshStrategy;
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
use codex_core::protocol::DeprecationNoticeEvent;
@@ -247,9 +247,8 @@ async fn handle_model_migration_prompt_if_needed(
config: &mut Config,
model: &str,
app_event_tx: &AppEventSender,
models_manager: Arc<ModelsManager>,
available_models: Vec<ModelPreset>,
) -> Option<AppExitInfo> {
let available_models = models_manager.list_models(config).await;
let upgrade = available_models
.iter()
.find(|preset| preset.model == model)
@@ -451,14 +450,18 @@ impl App {
));
let mut model = thread_manager
.get_models_manager()
.get_model(&config.model, &config)
.get_default_model(&config.model, &config, RefreshStrategy::Offline)
.await;
let available_models = thread_manager
.get_models_manager()
.list_models(&config, RefreshStrategy::Offline)
.await;
let exit_info = handle_model_migration_prompt_if_needed(
tui,
&mut config,
model.as_str(),
&app_event_tx,
thread_manager.get_models_manager(),
available_models,
)
.await;
if let Some(exit_info) = exit_info {
+178 -12
View File
@@ -32,7 +32,8 @@
//! burst detection for actual paste streams.
//!
//! The burst detector can also be disabled (`disable_paste_burst`), which bypasses the state
//! machine and treats the key stream as normal typing.
//! machine and treats the key stream as normal typing. When toggling from enabled → disabled, the
//! composer flushes/clears any in-flight burst state so it cannot leak into subsequent input.
//!
//! For the detailed burst state machine, see `codex-rs/tui2/src/bottom_pane/paste_burst.rs`.
//! For a narrative overview of the combined state machine, see `docs/tui-chat-composer.md`.
@@ -391,16 +392,27 @@ impl ChatComposer {
/// `disable_paste_burst` is an escape hatch for terminals/platforms where the burst heuristic
/// is unwanted or has already been handled elsewhere.
///
/// When enabling the flag we clear the burst classification window so subsequent input cannot
/// be incorrectly grouped into a previous burst.
/// When transitioning from enabled → disabled, we "defuse" any in-flight burst state so it
/// cannot affect subsequent normal typing:
///
/// This does not flush any in-progress buffer; callers should avoid toggling this mid-burst
/// (or should flush first).
/// - First, flush any held/buffered text immediately via
/// [`PasteBurst::flush_before_modified_input`], and feed it through `handle_paste(String)`.
/// This preserves user input and routes it through the same integration path as explicit
/// pastes (large-paste placeholders, image-path detection, and popup sync).
/// - Then clear the burst timing and Enter-suppression window via
/// [`PasteBurst::clear_after_explicit_paste`].
///
/// We intentionally do not use `clear_window_after_non_char()` here: it clears timing state
/// without emitting any buffered text, which can leave a non-empty buffer unable to flush
/// later (because `flush_if_due()` relies on `last_plain_char_time` to time out).
pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) {
let was_disabled = self.disable_paste_burst;
self.disable_paste_burst = disabled;
if disabled && !was_disabled {
self.paste_burst.clear_window_after_non_char();
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
}
self.paste_burst.clear_after_explicit_paste();
}
}
@@ -703,6 +715,7 @@ impl ChatComposer {
}
#[inline]
/// Clamp a cursor index to a UTF-8 char boundary.
fn clamp_to_char_boundary(text: &str, pos: usize) -> usize {
let mut p = pos.min(text.len());
if p < text.len() && !text.is_char_boundary(p) {
@@ -732,6 +745,15 @@ impl ChatComposer {
/// the cursor to a UTF-8 char boundary before slicing `textarea.text()`.
#[inline]
fn handle_non_ascii_char(&mut self, input: KeyEvent) -> (InputResult, bool) {
if self.disable_paste_burst {
// When burst detection is disabled, treat IME/non-ASCII input as normal typing.
// In particular, do not retro-capture or buffer already-inserted prefix text.
self.textarea.input(input);
let text_after = self.textarea.text();
self.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));
return (InputResult::None, true);
}
if let KeyEvent {
code: KeyCode::Char(ch),
..
@@ -1305,7 +1327,7 @@ impl ChatComposer {
.next()
.unwrap_or("")
.starts_with('/');
if self.paste_burst.is_active() && !in_slash_context {
if !self.disable_paste_burst && self.paste_burst.is_active() && !in_slash_context {
let now = Instant::now();
if self.paste_burst.append_newline_if_active(now) {
return (InputResult::None, true);
@@ -1314,10 +1336,11 @@ impl ChatComposer {
// During a paste-like burst, treat Enter/Ctrl+Shift+Q as a newline instead of submit.
let now = Instant::now();
if self
.paste_burst
.newline_should_insert_instead_of_submit(now)
&& !in_slash_context
if !in_slash_context
&& !self.disable_paste_burst
&& self
.paste_burst
.newline_should_insert_instead_of_submit(now)
{
self.textarea.insert_str("\n");
self.paste_burst.extend_window(now);
@@ -1519,6 +1542,7 @@ impl ChatComposer {
// If we're capturing a burst and receive Enter, accumulate it instead of inserting.
if matches!(input.code, KeyCode::Enter)
&& !self.disable_paste_burst
&& self.paste_burst.is_active()
&& self.paste_burst.append_newline_if_active(now)
{
@@ -1537,7 +1561,7 @@ impl ChatComposer {
} = input
{
let has_ctrl_or_alt = has_ctrl_or_alt(modifiers);
if !has_ctrl_or_alt {
if !has_ctrl_or_alt && !self.disable_paste_burst {
// Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts, so avoid
// holding the first char while still allowing burst detection for paste input.
if !ch.is_ascii() {
@@ -1583,6 +1607,18 @@ impl ChatComposer {
}
}
// Flush any buffered burst before applying a non-char input (arrow keys, etc).
//
// `clear_window_after_non_char()` clears `last_plain_char_time`. If we cleared that while
// `PasteBurst.buffer` is non-empty, `flush_if_due()` would no longer have a timestamp to
// time out against, and the buffered paste could remain stuck until another plain char
// arrives.
if !matches!(input.code, KeyCode::Char(_) | KeyCode::Enter)
&& let Some(pasted) = self.paste_burst.flush_before_modified_input()
{
self.handle_paste(pasted);
}
// Backspace at the start of an image placeholder should delete that placeholder (rather
// than deleting content before it). Do this without scanning the full text by consulting
// the textarea's element list.
@@ -2896,6 +2932,136 @@ mod tests {
assert_eq!(composer.textarea.text(), "hi\nthere");
}
/// Behavior: even if Enter suppression would normally be active for a burst, Enter should
/// still dispatch a built-in slash command when the first line begins with `/`.
#[test]
fn slash_context_enter_ignores_paste_burst_enter_suppression() {
use crate::slash_command::SlashCommand;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.textarea.set_text("/diff");
composer.textarea.set_cursor("/diff".len());
composer
.paste_burst
.begin_with_retro_grabbed(String::new(), Instant::now());
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Command(SlashCommand::Diff)));
}
/// Behavior: if a burst is buffering text and the user presses a non-char key, flush the
/// buffered burst *before* applying that key so the buffer cannot get stuck.
#[test]
fn non_char_key_flushes_active_burst_before_input() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Force an active burst so we can deterministically buffer characters without relying on
// timing.
composer
.paste_burst
.begin_with_retro_grabbed(String::new(), Instant::now());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
assert!(composer.textarea.text().is_empty());
assert!(composer.is_in_paste_burst());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
assert_eq!(composer.textarea.text(), "hi");
assert_eq!(composer.textarea.cursor(), 1);
assert!(!composer.is_in_paste_burst());
}
/// Behavior: enabling `disable_paste_burst` flushes any held first character (flicker
/// suppression) and then inserts subsequent chars immediately without creating burst state.
#[test]
fn disable_paste_burst_flushes_pending_first_char_and_inserts_immediately() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// First ASCII char is normally held briefly. Flip the config mid-stream and ensure the
// held char is not dropped.
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
assert!(composer.is_in_paste_burst());
assert!(composer.textarea.text().is_empty());
composer.set_disable_paste_burst(true);
assert_eq!(composer.textarea.text(), "a");
assert!(!composer.is_in_paste_burst());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE));
assert_eq!(composer.textarea.text(), "ab");
assert!(!composer.is_in_paste_burst());
}
/// Behavior: when a burst is already active, a non-ASCII char should be captured into the
/// burst buffer via the `try_append_char_if_active` fast-path.
#[test]
fn non_ascii_appends_to_active_burst_buffer() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Force an active burst so the non-ASCII char takes the fast-path
// (try_append_char_if_active) into the burst buffer.
composer
.paste_burst
.begin_with_retro_grabbed(String::new(), Instant::now());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE));
assert!(composer.textarea.text().is_empty());
let _ = flush_after_paste_burst(&mut composer);
assert_eq!(composer.textarea.text(), "1あ");
}
/// Behavior: a small explicit paste inserts text directly (no placeholder), and the submitted
/// text matches what is visible in the textarea.
#[test]
+17 -9
View File
@@ -317,15 +317,23 @@ pub async fn run_main(
ensure_oss_provider_ready(provider_id, &config).await?;
}
let otel =
codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"), None, true);
#[allow(clippy::print_stderr)]
let otel = match otel {
Ok(otel) => otel,
Err(e) => {
eprintln!("Could not create otel exporter: {e}");
std::process::exit(1);
let otel = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"), None, true)
})) {
Ok(Ok(otel)) => otel,
Ok(Err(e)) => {
#[allow(clippy::print_stderr)]
{
eprintln!("Could not create otel exporter: {e}");
}
None
}
Err(_) => {
#[allow(clippy::print_stderr)]
{
eprintln!("Could not create otel exporter: panicked during initialization");
}
None
}
};
@@ -35,14 +35,14 @@ model_provider = "ollama"
std::fs::write(codex_home.join("config.toml"), config_contents)?;
let CodexCliOutput { exit_code, output } = run_codex_cli(codex_home, cwd).await?;
assert_eq!(1, exit_code, "Codex CLI should exit nonzero.");
assert_ne!(0, exit_code, "Codex CLI should exit nonzero.");
assert!(
output.contains("ERROR: Failed to initialize codex:"),
"expected startup error in output, got: {output}"
);
assert!(
output.contains("failed to read execpolicy files"),
"expected execpolicy read error in output, got: {output}"
output.contains("failed to read rules files"),
"expected rules read error in output, got: {output}"
);
Ok(())
}
@@ -63,7 +63,7 @@ async fn run_codex_cli(
codex_home.as_ref().display().to_string(),
);
let args = vec!["-c".to_string(), "analytics_enabled=false".to_string()];
let args = vec!["-c".to_string(), "analytics.enabled=false".to_string()];
let spawned = codex_utils_pty::spawn_pty_process(
codex_cli.to_string_lossy().as_ref(),
&args,
+67
View File
@@ -0,0 +1,67 @@
## Using Codex as an MCP Server
The Codex CLI can also be run as an MCP _server_ via `codex mcp-server`. For example, you can use `codex mcp-server` to make Codex available as a tool inside of a multi-agent framework like the OpenAI [Agents SDK](https://platform.openai.com/docs/guides/agents). Use `codex mcp` separately to add/list/get/remove MCP server launchers in your configuration.
### Codex MCP Server Quickstart
You can launch a Codex MCP server with the [Model Context Protocol Inspector](https://modelcontextprotocol.io/legacy/tools/inspector):
```bash
npx @modelcontextprotocol/inspector codex mcp-server
```
Send a `tools/list` request and you will see that there are two tools available:
**`codex`** - Run a Codex session. Accepts configuration parameters matching the Codex Config struct. The `codex` tool takes the following properties:
| Property | Type | Description |
| ----------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **`prompt`** (required) | string | The initial user prompt to start the Codex conversation. |
| `approval-policy` | string | Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `on-request`, `never`. |
| `base-instructions` | string | The set of instructions to use instead of the default ones. |
| `config` | object | Individual [config settings](https://github.com/openai/codex/blob/main/docs/config.md#config) that will override what is in `$CODEX_HOME/config.toml`. |
| `cwd` | string | Working directory for the session. If relative, resolved against the server process's current directory. |
| `model` | string | Optional override for the model name (e.g. `o3`, `o4-mini`). |
| `profile` | string | Configuration profile from `config.toml` to specify default options. |
| `sandbox` | string | Sandbox mode: `read-only`, `workspace-write`, or `danger-full-access`. |
The `tools/call` response for `codex` includes the thread id in `structuredContent` so the client can resume the session later:
```json
{
"content": [{ "type": "text", "text": "..." }],
"structuredContent": { "threadId": "..." }
}
```
While the tool is running, `codex/event` notifications include `_meta.threadId` so clients can correlate events to a conversation:
```json
{
"_meta": { "requestId": 1, "threadId": "..." },
"id": "evt-...",
"msg": { "type": "..." }
}
```
**`codex-reply`** - Continue a Codex session by providing the thread id and prompt. The `codex-reply` tool takes the following properties:
| Property | Type | Description |
| ------------------------- | ------ | -------------------------------------------------------- |
| **`prompt`** (required) | string | The next user prompt to continue the Codex conversation. |
| **`threadId`** (required) | string | The id of the conversation to continue. |
### Trying it Out
> [!TIP]
> Codex often takes a few minutes to run. To accommodate this, adjust the MCP inspector's Request and Total timeouts to 600000ms (10 minutes) under ⛭ Configuration.
Use the MCP inspector and `codex mcp-server` to build a simple tic-tac-toe game with the following settings:
**approval-policy:** never
**prompt:** Implement a simple tic-tac-toe game with HTML, JavaScript, and CSS. Write the game in a single file called index.html.
**sandbox:** workspace-write
Click "Run Tool" and you should see a list of events emitted from the Codex MCP server as it builds the game.
+3 -3
View File
@@ -92,9 +92,9 @@ When enabled:
- The burst detector is bypassed for new input (no flicker suppression hold and no burst buffering
decisions for incoming characters).
- The key stream is treated as normal typing (including normal slash command behavior).
- Enabling the flag clears the burst classification window. In the current implementation it does
**not** flush or clear an already-buffered burst, so callers should avoid toggling this flag
mid-burst (or should flush first).
- Enabling the flag flushes any held/buffered burst text through the normal paste path
(`ChatComposer::handle_paste`) and then clears the burst timing and Enter-suppression windows so
transient burst state cannot leak into subsequent input.
### Enter handling