From 770616414a51fa179ce4cef10f7f8df838d3f46f Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 17 Mar 2026 19:46:44 -0700 Subject: [PATCH] Prefer websockets when providers support them (#13592) Remove all flags and model settings. --------- Co-authored-by: Codex --- .../app-server/src/codex_message_processor.rs | 8 + codex-rs/app-server/tests/common/config.rs | 18 +- .../app-server/tests/common/models_cache.rs | 1 - .../app-server/tests/suite/v2/compaction.rs | 2 +- .../codex-api/tests/models_integration.rs | 1 - codex-rs/core/models.json | 13 -- codex-rs/core/src/client.rs | 44 ++--- codex-rs/core/src/client_tests.rs | 1 - codex-rs/core/src/codex.rs | 7 +- codex-rs/core/src/codex_tests.rs | 3 - codex-rs/core/src/features.rs | 8 +- codex-rs/core/src/lib.rs | 1 - .../core/src/models_manager/model_info.rs | 1 - codex-rs/core/tests/common/test_codex.rs | 8 +- codex-rs/core/tests/responses_headers.rs | 3 - codex-rs/core/tests/suite/client.rs | 4 +- .../core/tests/suite/client_websockets.rs | 84 ++++---- codex-rs/core/tests/suite/compact.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 2 - codex-rs/core/tests/suite/models_cache_ttl.rs | 1 - codex-rs/core/tests/suite/personality.rs | 2 - codex-rs/core/tests/suite/remote_models.rs | 3 - codex-rs/core/tests/suite/rmcp_client.rs | 1 - .../tests/suite/spawn_agent_description.rs | 1 - codex-rs/core/tests/suite/view_image.rs | 1 - .../core/tests/suite/websocket_fallback.rs | 21 +- codex-rs/protocol/src/openai_models.rs | 7 +- sdk/typescript/jest.config.cjs | 1 + sdk/typescript/tests/abort.test.ts | 22 +-- sdk/typescript/tests/exec.test.ts | 50 +++++ sdk/typescript/tests/run.test.ts | 187 +++++++----------- sdk/typescript/tests/runStreamed.test.ts | 22 +-- sdk/typescript/tests/setupCodexHome.ts | 28 +++ sdk/typescript/tests/testCodex.ts | 94 +++++++++ 34 files changed, 348 insertions(+), 303 deletions(-) create mode 100644 sdk/typescript/tests/setupCodexHome.ts create mode 100644 sdk/typescript/tests/testCodex.ts diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index b17ab82d8..c70be7e8e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1943,6 +1943,7 @@ impl CodexMessageProcessor { config_overrides, typesafe_overrides, &cloud_requirements, + &listener_task_context.codex_home, ) .await { @@ -3392,6 +3393,7 @@ impl CodexMessageProcessor { typesafe_overrides, history_cwd, &cloud_requirements, + &self.config.codex_home, ) .await { @@ -3918,6 +3920,7 @@ impl CodexMessageProcessor { typesafe_overrides, history_cwd, &cloud_requirements, + &self.config.codex_home, ) .await { @@ -7016,6 +7019,7 @@ impl CodexMessageProcessor { }, Some(command_cwd.clone()), &cloud_requirements, + &config.codex_home, ) .await; let setup_result = match derived_config { @@ -7610,6 +7614,7 @@ async fn derive_config_from_params( request_overrides: Option>, typesafe_overrides: ConfigOverrides, cloud_requirements: &CloudRequirementsLoader, + codex_home: &Path, ) -> std::io::Result { let merged_cli_overrides = cli_overrides .iter() @@ -7623,6 +7628,7 @@ async fn derive_config_from_params( .collect::>(); codex_core::config::ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) .cli_overrides(merged_cli_overrides) .harness_overrides(typesafe_overrides) .cloud_requirements(cloud_requirements.clone()) @@ -7636,6 +7642,7 @@ async fn derive_config_for_cwd( typesafe_overrides: ConfigOverrides, cwd: Option, cloud_requirements: &CloudRequirementsLoader, + codex_home: &Path, ) -> std::io::Result { let merged_cli_overrides = cli_overrides .iter() @@ -7649,6 +7656,7 @@ async fn derive_config_for_cwd( .collect::>(); codex_core::config::ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) .cli_overrides(merged_cli_overrides) .harness_overrides(typesafe_overrides) .fallback_cwd(cwd) diff --git a/codex-rs/app-server/tests/common/config.rs b/codex-rs/app-server/tests/common/config.rs index c4c16eceb..7784f36e9 100644 --- a/codex-rs/app-server/tests/common/config.rs +++ b/codex-rs/app-server/tests/common/config.rs @@ -34,21 +34,23 @@ pub fn write_mock_responses_config_toml( Some(true) => "requires_openai_auth = true\n".to_string(), Some(false) | None => String::new(), }; - let provider_block = if model_provider_id == "openai" { - String::new() + let provider_name = if matches!(requires_openai_auth, Some(true)) { + "OpenAI" } else { - format!( - r#" -[model_providers.mock_provider] -name = "Mock provider for test" + "Mock provider for test" + }; + let provider_block = format!( + r#" +[model_providers.{model_provider_id}] +name = "{provider_name}" base_url = "{server_uri}/v1" wire_api = "responses" request_max_retries = 0 stream_max_retries = 0 +supports_websockets = false {requires_line} "# - ) - }; + ); let openai_base_url_line = if model_provider_id == "openai" { format!("openai_base_url = \"{server_uri}/v1\"\n") } else { diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index d22524545..427f6cc1f 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -45,7 +45,6 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), - prefer_websockets: false, used_fallback_model_metadata: false, supports_search_tool: false, } diff --git a/codex-rs/app-server/tests/suite/v2/compaction.rs b/codex-rs/app-server/tests/suite/v2/compaction.rs index 44ba3e207..c0922d325 100644 --- a/codex-rs/app-server/tests/suite/v2/compaction.rs +++ b/codex-rs/app-server/tests/suite/v2/compaction.rs @@ -149,7 +149,7 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<() &BTreeMap::default(), REMOTE_AUTO_COMPACT_LIMIT, Some(true), - "openai", + "mock_provider", COMPACT_PROMPT, )?; write_chatgpt_auth( diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index fd95f5551..4167c877d 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -93,7 +93,6 @@ async fn models_client_hits_models_endpoint() { effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), - prefer_websockets: false, used_fallback_model_metadata: false, supports_search_tool: false, }], diff --git a/codex-rs/core/models.json b/codex-rs/core/models.json index c3f0fb838..068d0915b 100644 --- a/codex-rs/core/models.json +++ b/codex-rs/core/models.json @@ -1,7 +1,6 @@ { "models": [ { - "prefer_websockets": false, "support_verbosity": true, "default_verbosity": "low", "apply_patch_tool_type": "freeform", @@ -75,7 +74,6 @@ "supports_reasoning_summaries": true }, { - "prefer_websockets": false, "support_verbosity": true, "default_verbosity": "low", "apply_patch_tool_type": "freeform", @@ -146,7 +144,6 @@ "supports_reasoning_summaries": true }, { - "prefer_websockets": false, "support_verbosity": false, "default_verbosity": null, "apply_patch_tool_type": "freeform", @@ -221,7 +218,6 @@ "supports_reasoning_summaries": true }, { - "prefer_websockets": false, "support_verbosity": false, "default_verbosity": null, "apply_patch_tool_type": "freeform", @@ -289,7 +285,6 @@ "supports_reasoning_summaries": true }, { - "prefer_websockets": false, "support_verbosity": false, "default_verbosity": null, "apply_patch_tool_type": "freeform", @@ -353,7 +348,6 @@ "supports_reasoning_summaries": true }, { - "prefer_websockets": false, "support_verbosity": true, "default_verbosity": "low", "apply_patch_tool_type": "freeform", @@ -421,7 +415,6 @@ "supports_reasoning_summaries": true }, { - "prefer_websockets": false, "support_verbosity": true, "default_verbosity": "low", "apply_patch_tool_type": "freeform", @@ -485,7 +478,6 @@ "supports_reasoning_summaries": true }, { - "prefer_websockets": false, "support_verbosity": false, "default_verbosity": null, "apply_patch_tool_type": "freeform", @@ -549,7 +541,6 @@ "supports_reasoning_summaries": true }, { - "prefer_websockets": false, "support_verbosity": true, "default_verbosity": null, "apply_patch_tool_type": null, @@ -617,7 +608,6 @@ "supports_reasoning_summaries": true }, { - "prefer_websockets": false, "support_verbosity": true, "default_verbosity": null, "apply_patch_tool_type": "freeform", @@ -677,7 +667,6 @@ "supports_reasoning_summaries": true }, { - "prefer_websockets": false, "support_verbosity": true, "default_verbosity": null, "apply_patch_tool_type": "freeform", @@ -737,7 +726,6 @@ "supports_reasoning_summaries": true }, { - "prefer_websockets": false, "support_verbosity": false, "default_verbosity": null, "apply_patch_tool_type": "freeform", @@ -797,7 +785,6 @@ "supports_reasoning_summaries": true }, { - "prefer_websockets": false, "support_verbosity": false, "default_verbosity": null, "apply_patch_tool_type": "freeform", diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 48e8d90ec..eb5cb4c08 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -2,7 +2,7 @@ //! //! `ModelClient` is intended to live for the lifetime of a Codex session and holds the stable //! configuration and state needed to talk to a provider (auth, provider selection, conversation id, -//! and feature-gated request behavior). +//! and transport fallback state). //! //! Per-turn settings (model selection, reasoning controls, telemetry context, and turn metadata) //! are passed explicitly to streaming and unary methods so that the turn lifetime is visible at the @@ -94,7 +94,6 @@ use crate::auth::RefreshTokenError; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::client_common::ResponseStream; -use crate::config::Config; use crate::default_client::build_reqwest_client; use crate::error::CodexErr; use crate::error::Result; @@ -122,14 +121,6 @@ const MEMORIES_SUMMARIZE_ENDPOINT: &str = "/memories/trace_summarize"; #[cfg(test)] pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration = Duration::from_millis(crate::model_provider_info::DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS); -pub fn ws_version_from_features(config: &Config) -> bool { - config - .features - .enabled(crate::features::Feature::ResponsesWebsockets) - || config - .features - .enabled(crate::features::Feature::ResponsesWebsocketsV2) -} /// Session-scoped state shared by all [`ModelClient`] clones. /// @@ -143,7 +134,6 @@ struct ModelClientState { auth_env_telemetry: AuthEnvTelemetry, session_source: SessionSource, model_verbosity: Option, - responses_websockets_enabled_by_feature: bool, enable_request_compression: bool, include_timing_metrics: bool, beta_features_header: Option, @@ -175,8 +165,7 @@ impl RequestRouteTelemetry { /// A session-scoped client for model-provider API calls. /// /// This holds configuration and state that should be shared across turns within a Codex session -/// (auth, provider selection, conversation id, feature-gated request behavior, and transport -/// fallback state). +/// (auth, provider selection, conversation id, and transport fallback state). /// /// WebSocket fallback is session-scoped: once a turn activates the HTTP fallback, subsequent turns /// will also use HTTP for the remainder of the session. @@ -265,7 +254,6 @@ impl ModelClient { provider: ModelProviderInfo, session_source: SessionSource, model_verbosity: Option, - responses_websockets_enabled_by_feature: bool, enable_request_compression: bool, include_timing_metrics: bool, beta_features_header: Option, @@ -282,7 +270,6 @@ impl ModelClient { auth_env_telemetry, session_source, model_verbosity, - responses_websockets_enabled_by_feature, enable_request_compression, include_timing_metrics, beta_features_header, @@ -324,9 +311,9 @@ impl ModelClient { pub(crate) fn force_http_fallback( &self, session_telemetry: &SessionTelemetry, - model_info: &ModelInfo, + _model_info: &ModelInfo, ) -> bool { - let websocket_enabled = self.responses_websocket_enabled(model_info); + let websocket_enabled = self.responses_websocket_enabled(); let activated = websocket_enabled && !self.state.disable_websockets.swap(true, Ordering::Relaxed); if activated { @@ -517,19 +504,16 @@ impl ModelClient { /// Returns whether the Responses-over-WebSocket transport is active for this session. /// - /// This combines provider capability and feature gating; both must be true for websocket paths - /// to be eligible. - /// - /// If websockets are only enabled via model preference (no explicit feature flag), prefer the - /// current v2 behavior. - pub fn responses_websocket_enabled(&self, model_info: &ModelInfo) -> bool { + /// WebSocket use is controlled by provider capability and session-scoped fallback state. + pub fn responses_websocket_enabled(&self) -> bool { if !self.state.provider.supports_websockets || self.state.disable_websockets.load(Ordering::Relaxed) + || (*CODEX_RS_SSE_FIXTURE).is_some() { return false; } - self.state.responses_websockets_enabled_by_feature || model_info.prefer_websockets + true } /// Returns auth + provider configuration resolved from the current session auth state. @@ -868,9 +852,9 @@ impl ModelClientSession { pub async fn preconnect_websocket( &mut self, session_telemetry: &SessionTelemetry, - model_info: &ModelInfo, + _model_info: &ModelInfo, ) -> std::result::Result<(), ApiError> { - if !self.client.responses_websocket_enabled(model_info) { + if !self.client.responses_websocket_enabled() { return Ok(()); } if self.websocket_session.connection.is_some() { @@ -1248,7 +1232,7 @@ impl ModelClientSession { service_tier: Option, turn_metadata_header: Option<&str>, ) -> Result<()> { - if !self.client.responses_websocket_enabled(model_info) { + if !self.client.responses_websocket_enabled() { return Ok(()); } if self.websocket_session.last_request.is_some() { @@ -1292,8 +1276,8 @@ impl ModelClientSession { /// /// The caller is responsible for passing per-turn settings explicitly (model selection, /// reasoning settings, telemetry context, and turn metadata). This method will prefer the - /// Responses WebSocket transport when enabled and healthy, and will fall back to the HTTP - /// Responses API transport otherwise. + /// Responses WebSocket transport when the provider supports it and it remains healthy, and will + /// fall back to the HTTP Responses API transport otherwise. pub async fn stream( &mut self, prompt: &Prompt, @@ -1307,7 +1291,7 @@ impl ModelClientSession { let wire_api = self.client.state.provider.wire_api; match wire_api { WireApi::Responses => { - if self.client.responses_websocket_enabled(model_info) { + if self.client.responses_websocket_enabled() { match self .stream_responses_websocket( prompt, diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 441a34864..2c07b4fd1 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -23,7 +23,6 @@ fn test_model_client(session_source: SessionSource) -> ModelClient { None, false, false, - false, None, ) } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b7503b8c6..1b41760f6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,7 +53,6 @@ use crate::terminal; use crate::truncate::TruncationPolicy; use crate::turn_metadata::TurnMetadataState; use crate::util::error_or_panic; -use crate::ws_version_from_features; use async_channel::Receiver; use async_channel::Sender; use chrono::Local; @@ -1807,7 +1806,6 @@ impl Session { session_configuration.provider.clone(), session_configuration.session_source.clone(), config.model_verbosity, - ws_version_from_features(config.as_ref()), config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), Self::build_model_client_beta_features_header(config.as_ref()), @@ -6239,10 +6237,7 @@ async fn run_sampling_request( // transient reconnect messages. In debug builds, keep full visibility for diagnosis. let report_error = retries > 1 || cfg!(debug_assertions) - || !sess - .services - .model_client - .responses_websocket_enabled(&turn_context.model_info); + || !sess.services.model_client.responses_websocket_enabled(); if report_error { // Surface retry information to any UI/front‑end so the // user understands what is happening instead of staring diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index bb70bdd7d..e7b401818 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -239,7 +239,6 @@ fn test_model_client_session() -> crate::client::ModelClientSession { None, false, false, - false, None, ) .new_session() @@ -2513,7 +2512,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { session_configuration.provider.clone(), session_configuration.session_source.clone(), config.model_verbosity, - ws_version_from_features(config.as_ref()), config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), Session::build_model_client_beta_features_header(config.as_ref()), @@ -3308,7 +3306,6 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( session_configuration.provider.clone(), session_configuration.session_source.clone(), config.model_verbosity, - ws_version_from_features(config.as_ref()), config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), Session::build_model_client_beta_features_header(config.as_ref()), diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index d6b3ae101..bcd064302 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -184,9 +184,9 @@ pub enum Feature { TuiAppServer, /// Prevent idle system sleep while a turn is actively running. PreventIdleSleep, - /// Use the Responses API WebSocket transport for OpenAI by default. + /// Legacy rollout flag for Responses API WebSocket transport experiments. ResponsesWebsockets, - /// Enable Responses API websocket v2 mode. + /// Legacy rollout flag for Responses API WebSocket transport v2 experiments. ResponsesWebsocketsV2, } @@ -860,13 +860,13 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ResponsesWebsockets, key: "responses_websockets", - stage: Stage::UnderDevelopment, + stage: Stage::Removed, default_enabled: false, }, FeatureSpec { id: Feature::ResponsesWebsocketsV2, key: "responses_websockets_v2", - stage: Stage::UnderDevelopment, + stage: Stage::Removed, default_enabled: false, }, ]; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 51d9fcf8d..5ca2e0a7b 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -162,7 +162,6 @@ pub(crate) use codex_shell_command::powershell; pub use client::ModelClient; pub use client::ModelClientSession; pub use client::X_CODEX_TURN_METADATA_HEADER; -pub use client::ws_version_from_features; pub use client_common::Prompt; pub use client_common::REVIEW_PROMPT; pub use client_common::ResponseEvent; diff --git a/codex-rs/core/src/models_manager/model_info.rs b/codex-rs/core/src/models_manager/model_info.rs index 159e7c6ea..d4f82b223 100644 --- a/codex-rs/core/src/models_manager/model_info.rs +++ b/codex-rs/core/src/models_manager/model_info.rs @@ -88,7 +88,6 @@ pub(crate) fn model_info_from_slug(slug: &str) -> ModelInfo { effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), - prefer_websockets: false, used_fallback_model_metadata: true, // this is the fallback model metadata supports_search_tool: false, } diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index f66855a6a..860b99468 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -153,11 +153,8 @@ impl TestCodexBuilder { let base_url_clone = base_url.clone(); self.config_mutators.push(Box::new(move |config| { config.model_provider.base_url = Some(base_url_clone); + config.model_provider.supports_websockets = true; config.experimental_realtime_ws_model = Some("realtime-test-model".to_string()); - config - .features - .enable(Feature::ResponsesWebsockets) - .expect("test config should allow feature update"); })); Box::pin(self.build_with_home_and_base_url(base_url, home, /*resume_from*/ None)).await } @@ -271,6 +268,9 @@ impl TestCodexBuilder { ) -> anyhow::Result<(Config, Arc)> { let model_provider = ModelProviderInfo { base_url: Some(base_url), + // Most core tests use SSE-only mock servers, so keep websocket transport off unless + // a test explicitly opts into websocket coverage. + supports_websockets: false, ..built_in_model_providers(/*openai_base_url*/ None)["openai"].clone() }; let cwd = Arc::new(TempDir::new()?); diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index d1c73f39d..823057797 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -94,7 +94,6 @@ async fn responses_stream_includes_subagent_header_on_review() { config.model_verbosity, false, false, - false, None, ); let mut client_session = client.new_session(); @@ -208,7 +207,6 @@ async fn responses_stream_includes_subagent_header_on_other() { config.model_verbosity, false, false, - false, None, ); let mut client_session = client.new_session(); @@ -321,7 +319,6 @@ async fn responses_respects_model_info_overrides_from_config() { config.model_verbosity, false, false, - false, None, ); let mut client_session = client.new_session(); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 7d785694d..5a6a22b6e 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -717,6 +717,7 @@ async fn chatgpt_auth_sends_correct_request() { let mut model_provider = built_in_model_providers(/* openai_base_url */ None)["openai"].clone(); model_provider.base_url = Some(format!("{}/api/codex", server.uri())); + model_provider.supports_websockets = false; let mut builder = test_codex() .with_auth(create_dummy_codex_auth()) .with_config(move |config| { @@ -791,6 +792,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), + supports_websockets: false, ..built_in_model_providers(/* openai_base_url */ None)["openai"].clone() }; @@ -1832,7 +1834,6 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { config.model_verbosity, false, false, - false, None, ); let mut client_session = client.new_session(); @@ -1968,6 +1969,7 @@ async fn token_count_includes_rate_limits_snapshot() { let mut provider = built_in_model_providers(/* openai_base_url */ None)["openai"].clone(); provider.base_url = Some(format!("{}/v1", server.uri())); + provider.supports_websockets = false; let mut builder = test_codex() .with_auth(CodexAuth::from_api_key("test")) diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index c2dd16f8b..38ef3b682 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -8,7 +8,6 @@ use codex_core::ResponseEvent; use codex_core::WireApi; use codex_core::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER; use codex_core::features::Feature; -use codex_core::ws_version_from_features; use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; use codex_otel::metrics::MetricsClient; @@ -98,6 +97,28 @@ async fn responses_websocket_streams_request() { server.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_websocket_streams_without_feature_flag_when_provider_supports_websockets() { + skip_if_no_network!(); + + let server = start_websocket_server(vec![vec![vec![ + ev_response_created("resp-1"), + ev_completed("resp-1"), + ]]]) + .await; + + let harness = websocket_harness_with_options(&server, false).await; + let mut client_session = harness.client.new_session(); + let prompt = prompt_with_input(vec![message_item("hello")]); + + stream_until_complete(&mut client_session, &harness, &prompt).await; + + assert_eq!(server.handshakes().len(), 1); + assert_eq!(server.single_connection().len(), 1); + + server.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_websocket_preconnect_reuses_connection() { skip_if_no_network!(); @@ -133,7 +154,7 @@ async fn responses_websocket_request_prewarm_reuses_connection() { ]]) .await; - let harness = websocket_harness_with_options(&server, false, false, true, true).await; + let harness = websocket_harness_with_options(&server, true).await; let mut client_session = harness.client.new_session(); let prompt = prompt_with_input(vec![message_item("hello")]); client_session @@ -252,7 +273,7 @@ async fn responses_websocket_request_prewarm_is_reused_even_with_header_changes( ]]) .await; - let harness = websocket_harness_with_options(&server, false, false, true, true).await; + let harness = websocket_harness_with_options(&server, true).await; let mut client_session = harness.client.new_session(); let prompt = prompt_with_input(vec![message_item("hello")]); client_session @@ -308,7 +329,7 @@ async fn responses_websocket_request_prewarm_is_reused_even_with_header_changes( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_websocket_prewarm_uses_v2_when_model_prefers_websockets_and_feature_disabled() { +async fn responses_websocket_prewarm_uses_v2_when_provider_supports_websockets() { skip_if_no_network!(); let server = start_websocket_server(vec![vec![vec![ @@ -317,7 +338,7 @@ async fn responses_websocket_prewarm_uses_v2_when_model_prefers_websockets_and_f ]]]) .await; - let harness = websocket_harness_with_options(&server, false, false, false, true).await; + let harness = websocket_harness_with_options(&server, false).await; let mut client_session = harness.client.new_session(); let prompt = prompt_with_input(vec![message_item("hello")]); client_session @@ -374,7 +395,7 @@ async fn responses_websocket_preconnect_runs_when_only_v2_feature_enabled() { ]]]) .await; - let harness = websocket_harness_with_options(&server, false, false, true, false).await; + let harness = websocket_harness_with_options(&server, true).await; let mut client_session = harness.client.new_session(); client_session .preconnect_websocket(&harness.session_telemetry, &harness.model_info) @@ -404,7 +425,7 @@ async fn responses_websocket_preconnect_runs_when_only_v2_feature_enabled() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_websocket_v2_requests_use_v2_when_model_prefers_websockets() { +async fn responses_websocket_v2_requests_use_v2_when_provider_supports_websockets() { skip_if_no_network!(); let server = start_websocket_server(vec![vec![ @@ -417,7 +438,7 @@ async fn responses_websocket_v2_requests_use_v2_when_model_prefers_websockets() ]]) .await; - let harness = websocket_harness_with_options(&server, false, false, true, true).await; + let harness = websocket_harness_with_options(&server, true).await; let mut client_session = harness.client.new_session(); let prompt_one = prompt_with_input(vec![message_item("hello")]); let prompt_two = prompt_with_input(vec![ @@ -466,7 +487,7 @@ async fn responses_websocket_v2_incremental_requests_are_reused_across_turns() { ]]) .await; - let harness = websocket_harness_with_options(&server, false, false, true, true).await; + let harness = websocket_harness_with_options(&server, false).await; let prompt_one = prompt_with_input(vec![message_item("hello")]); let prompt_two = prompt_with_input(vec![ message_item("hello"), @@ -510,7 +531,7 @@ async fn responses_websocket_v2_wins_when_both_features_enabled() { ]]) .await; - let harness = websocket_harness_with_options(&server, false, true, true, false).await; + let harness = websocket_harness_with_options(&server, false).await; let mut client_session = harness.client.new_session(); let prompt_one = prompt_with_input(vec![message_item("hello")]); let prompt_two = prompt_with_input(vec![ @@ -1534,69 +1555,39 @@ async fn websocket_harness_with_runtime_metrics( server: &WebSocketTestServer, runtime_metrics_enabled: bool, ) -> WebsocketTestHarness { - websocket_harness_with_options(server, runtime_metrics_enabled, true, false, false).await + websocket_harness_with_options(server, runtime_metrics_enabled).await } async fn websocket_harness_with_v2( server: &WebSocketTestServer, - websocket_v2_enabled: bool, + runtime_metrics_enabled: bool, ) -> WebsocketTestHarness { - websocket_harness_with_options(server, false, true, websocket_v2_enabled, false).await + websocket_harness_with_options(server, runtime_metrics_enabled).await } async fn websocket_harness_with_options( server: &WebSocketTestServer, runtime_metrics_enabled: bool, - websocket_enabled: bool, - websocket_v2_enabled: bool, - prefer_websockets: bool, ) -> WebsocketTestHarness { - websocket_harness_with_provider_options( - websocket_provider(server), - runtime_metrics_enabled, - websocket_enabled, - websocket_v2_enabled, - prefer_websockets, - ) - .await + websocket_harness_with_provider_options(websocket_provider(server), runtime_metrics_enabled) + .await } async fn websocket_harness_with_provider_options( provider: ModelProviderInfo, runtime_metrics_enabled: bool, - websocket_enabled: bool, - websocket_v2_enabled: bool, - prefer_websockets: bool, ) -> WebsocketTestHarness { let codex_home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&codex_home).await; config.model = Some(MODEL.to_string()); - if websocket_enabled { - config - .features - .enable(Feature::ResponsesWebsockets) - .expect("test config should allow feature update"); - } else { - config - .features - .disable(Feature::ResponsesWebsockets) - .expect("test config should allow feature update"); - } if runtime_metrics_enabled { config .features .enable(Feature::RuntimeMetrics) .expect("test config should allow feature update"); } - if websocket_v2_enabled { - config - .features - .enable(Feature::ResponsesWebsocketsV2) - .expect("test config should allow feature update"); - } let config = Arc::new(config); - let mut model_info = codex_core::test_support::construct_model_info_offline(MODEL, &config); - model_info.prefer_websockets = prefer_websockets; + let model_info = codex_core::test_support::construct_model_info_offline(MODEL, &config); let conversation_id = ThreadId::new(); let auth_manager = codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("Test API Key")); @@ -1627,7 +1618,6 @@ async fn websocket_harness_with_provider_options( provider.clone(), SessionSource::Exec, config.model_verbosity, - ws_version_from_features(&config), false, runtime_metrics_enabled, None, diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 6cf1c275a..f02ab6574 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -96,6 +96,7 @@ fn non_openai_model_provider(server: &MockServer) -> ModelProviderInfo { let mut provider = built_in_model_providers(/* openai_base_url */ None)["openai"].clone(); provider.name = "OpenAI (test)".into(); provider.base_url = Some(format!("{}/v1", server.uri())); + provider.supports_websockets = false; provider } diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 104f99601..748fce023 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -53,7 +53,6 @@ fn test_model_info( visibility: ModelVisibility::List, supported_in_api: true, input_modalities, - prefer_websockets: false, used_fallback_model_metadata: false, supports_search_tool: false, priority: 1, @@ -849,7 +848,6 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< visibility: ModelVisibility::List, supported_in_api: true, input_modalities: default_input_modalities(), - prefer_websockets: false, used_fallback_model_metadata: false, supports_search_tool: false, priority: 1, diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 103817ba9..7cb757334 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -351,7 +351,6 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), - prefer_websockets: false, used_fallback_model_metadata: false, supports_search_tool: false, } diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 1d1aeaf1c..600b6490a 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -659,7 +659,6 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), - prefer_websockets: false, used_fallback_model_metadata: false, supports_search_tool: false, }; @@ -775,7 +774,6 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), input_modalities: default_input_modalities(), - prefer_websockets: false, used_fallback_model_metadata: false, supports_search_tool: false, }; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 589c6b9d5..639561179 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -289,7 +289,6 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { visibility: ModelVisibility::List, supported_in_api: true, input_modalities: default_input_modalities(), - prefer_websockets: false, used_fallback_model_metadata: false, supports_search_tool: false, priority: 1, @@ -533,7 +532,6 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { visibility: ModelVisibility::List, supported_in_api: true, input_modalities: default_input_modalities(), - prefer_websockets: false, used_fallback_model_metadata: false, supports_search_tool: false, priority: 1, @@ -1001,7 +999,6 @@ fn test_remote_model_with_policy( visibility, supported_in_api: true, input_modalities: default_input_modalities(), - prefer_websockets: false, used_fallback_model_metadata: false, supports_search_tool: false, priority, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 5b7e025ec..772674f79 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -419,7 +419,6 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re effective_context_window_percent: 95, experimental_supported_tools: Vec::new(), input_modalities: vec![InputModality::Text], - prefer_websockets: false, used_fallback_model_metadata: false, supports_search_tool: false, }], diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index b194c8704..df2d49a93 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -64,7 +64,6 @@ fn test_model_info( visibility, supported_in_api: true, input_modalities: default_input_modalities(), - prefer_websockets: false, used_fallback_model_metadata: false, supports_search_tool: false, priority: 1, diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index a3e341f19..240620a27 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1270,7 +1270,6 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an visibility: ModelVisibility::List, supported_in_api: true, input_modalities: vec![InputModality::Text], - prefer_websockets: false, used_fallback_model_metadata: false, supports_search_tool: false, priority: 1, diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index 302ae3965..0090093c7 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use codex_core::features::Feature; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; @@ -45,10 +44,7 @@ async fn websocket_fallback_switches_to_http_on_upgrade_required_connect() -> Re move |config| { config.model_provider.base_url = Some(base_url); config.model_provider.wire_api = codex_core::WireApi::Responses; - config - .features - .enable(Feature::ResponsesWebsockets) - .expect("test config should allow feature update"); + config.model_provider.supports_websockets = true; // If we don't treat 426 specially, the sampling loop would retry the WebSocket // handshake before switching to the HTTP transport. config.model_provider.stream_max_retries = Some(2); @@ -94,10 +90,7 @@ async fn websocket_fallback_switches_to_http_after_retries_exhausted() -> Result move |config| { config.model_provider.base_url = Some(base_url); config.model_provider.wire_api = codex_core::WireApi::Responses; - config - .features - .enable(Feature::ResponsesWebsockets) - .expect("test config should allow feature update"); + config.model_provider.supports_websockets = true; config.model_provider.stream_max_retries = Some(2); config.model_provider.request_max_retries = Some(0); } @@ -142,10 +135,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result move |config| { config.model_provider.base_url = Some(base_url); config.model_provider.wire_api = codex_core::WireApi::Responses; - config - .features - .enable(Feature::ResponsesWebsockets) - .expect("test config should allow feature update"); + config.model_provider.supports_websockets = true; config.model_provider.stream_max_retries = Some(2); config.model_provider.request_max_retries = Some(0); } @@ -220,10 +210,7 @@ async fn websocket_fallback_is_sticky_across_turns() -> Result<()> { move |config| { config.model_provider.base_url = Some(base_url); config.model_provider.wire_api = codex_core::WireApi::Responses; - config - .features - .enable(Feature::ResponsesWebsockets) - .expect("test config should allow feature update"); + config.model_provider.supports_websockets = true; config.model_provider.stream_max_retries = Some(2); config.model_provider.request_max_retries = Some(0); } diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index a113460c1..01515d107 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -284,9 +284,6 @@ pub struct ModelInfo { /// Input modalities accepted by the backend for this model. #[serde(default = "default_input_modalities")] pub input_modalities: Vec, - /// When true, this model should use websocket transport even when websocket features are off. - #[serde(default)] - pub prefer_websockets: bool, /// Internal-only marker set by core when a model slug resolved to fallback metadata. #[serde(default, skip_serializing, skip_deserializing)] #[schemars(skip)] @@ -548,7 +545,6 @@ mod tests { effective_context_window_percent: 95, experimental_supported_tools: vec![], input_modalities: default_input_modalities(), - prefer_websockets: false, used_fallback_model_metadata: false, supports_search_tool: false, } @@ -751,8 +747,7 @@ mod tests { "auto_compact_token_limit": null, "effective_context_window_percent": 95, "experimental_supported_tools": [], - "input_modalities": ["text", "image"], - "prefer_websockets": false + "input_modalities": ["text", "image"] })) .expect("deserialize model info"); diff --git a/sdk/typescript/jest.config.cjs b/sdk/typescript/jest.config.cjs index 05d51f832..37f03d51d 100644 --- a/sdk/typescript/jest.config.cjs +++ b/sdk/typescript/jest.config.cjs @@ -3,6 +3,7 @@ module.exports = { preset: "ts-jest/presets/default-esm", testEnvironment: "node", extensionsToTreatAsEsm: [".ts"], + setupFilesAfterEnv: ["/tests/setupCodexHome.ts"], moduleNameMapper: { "^(\\.{1,2}/.*)\\.js$": "$1", }, diff --git a/sdk/typescript/tests/abort.test.ts b/sdk/typescript/tests/abort.test.ts index d79319d65..0af318272 100644 --- a/sdk/typescript/tests/abort.test.ts +++ b/sdk/typescript/tests/abort.test.ts @@ -1,9 +1,5 @@ -import path from "node:path"; - import { describe, expect, it } from "@jest/globals"; -import { Codex } from "../src/codex"; - import { assistantMessage, responseCompleted, @@ -13,8 +9,7 @@ import { SseResponseBody, startResponsesTestProxy, } from "./responsesProxy"; - -const codexExecPath = path.join(process.cwd(), "..", "..", "codex-rs", "target", "debug", "codex"); +import { createMockClient } from "./testCodex"; function* infiniteShellCall(): Generator { while (true) { @@ -28,9 +23,9 @@ describe("AbortSignal support", () => { statusCode: 200, responseBodies: infiniteShellCall(), }); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); const thread = client.startThread(); // Create an abort controller and abort it immediately @@ -40,6 +35,7 @@ describe("AbortSignal support", () => { // The operation should fail because the signal is already aborted await expect(thread.run("Hello, world!", { signal: controller.signal })).rejects.toThrow(); } finally { + cleanup(); await close(); } }); @@ -49,9 +45,9 @@ describe("AbortSignal support", () => { statusCode: 200, responseBodies: infiniteShellCall(), }); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); const thread = client.startThread(); // Create an abort controller and abort it immediately @@ -78,6 +74,7 @@ describe("AbortSignal support", () => { expect(error).toBeDefined(); } } finally { + cleanup(); await close(); } }); @@ -87,9 +84,9 @@ describe("AbortSignal support", () => { statusCode: 200, responseBodies: infiniteShellCall(), }); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); const thread = client.startThread(); const controller = new AbortController(); @@ -103,6 +100,7 @@ describe("AbortSignal support", () => { // The operation should fail await expect(runPromise).rejects.toThrow(); } finally { + cleanup(); await close(); } }); @@ -112,9 +110,9 @@ describe("AbortSignal support", () => { statusCode: 200, responseBodies: infiniteShellCall(), }); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); const thread = client.startThread(); const controller = new AbortController(); @@ -137,6 +135,7 @@ describe("AbortSignal support", () => { })(), ).rejects.toThrow(); } finally { + cleanup(); await close(); } }); @@ -146,9 +145,9 @@ describe("AbortSignal support", () => { statusCode: 200, responseBodies: [sse(responseStarted(), assistantMessage("Hi!"), responseCompleted())], }); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); const thread = client.startThread(); const controller = new AbortController(); @@ -159,6 +158,7 @@ describe("AbortSignal support", () => { expect(result.finalResponse).toBe("Hi!"); expect(result.items).toHaveLength(1); } finally { + cleanup(); await close(); } }); diff --git a/sdk/typescript/tests/exec.test.ts b/sdk/typescript/tests/exec.test.ts index 7ef52d72e..b32b67572 100644 --- a/sdk/typescript/tests/exec.test.ts +++ b/sdk/typescript/tests/exec.test.ts @@ -93,4 +93,54 @@ describe("CodexExec", () => { expect(imageIndex).toBeGreaterThan(-1); expect(resumeIndex).toBeLessThan(imageIndex); }); + + it("allows overriding the env passed to the Codex CLI", async () => { + const { CodexExec } = await import("../src/exec"); + spawnMock.mockClear(); + const child = new FakeChildProcess(); + spawnMock.mockReturnValue(child as unknown as child_process.ChildProcess); + + setImmediate(() => { + child.stdout.end(); + child.stderr.end(); + child.emit("exit", 0, null); + }); + + process.env.CODEX_ENV_SHOULD_NOT_LEAK = "leak"; + + try { + const exec = new CodexExec("codex", { + CODEX_HOME: "/tmp/codex-home", + CUSTOM_ENV: "custom", + }); + + for await (const _ of exec.run({ + input: "custom env", + apiKey: "test", + baseUrl: "https://example.test", + })) { + // no-op + } + + const commandArgs = spawnMock.mock.calls[0]?.[1] as string[] | undefined; + expect(commandArgs).toBeDefined(); + const spawnOptions = spawnMock.mock.calls[0]?.[2] as child_process.SpawnOptions | undefined; + const spawnEnv = spawnOptions?.env as Record | undefined; + expect(spawnEnv).toBeDefined(); + if (!spawnEnv || !commandArgs) { + throw new Error("Spawn args missing"); + } + + expect(spawnEnv.CODEX_HOME).toBe("/tmp/codex-home"); + expect(spawnEnv.CUSTOM_ENV).toBe("custom"); + expect(spawnEnv.CODEX_ENV_SHOULD_NOT_LEAK).toBeUndefined(); + expect(spawnEnv.OPENAI_BASE_URL).toBeUndefined(); + expect(spawnEnv.CODEX_API_KEY).toBe("test"); + expect(spawnEnv.CODEX_INTERNAL_ORIGINATOR_OVERRIDE).toBeDefined(); + expect(commandArgs).toContain("--config"); + expect(commandArgs).toContain(`openai_base_url=${JSON.stringify("https://example.test")}`); + } finally { + delete process.env.CODEX_ENV_SHOULD_NOT_LEAK; + } + }); }); diff --git a/sdk/typescript/tests/run.test.ts b/sdk/typescript/tests/run.test.ts index 6db66826b..7af8126e7 100644 --- a/sdk/typescript/tests/run.test.ts +++ b/sdk/typescript/tests/run.test.ts @@ -5,8 +5,6 @@ import path from "node:path"; import { codexExecSpy } from "./codexExecSpy"; import { describe, expect, it } from "@jest/globals"; -import { Codex } from "../src/codex"; - import { assistantMessage, responseCompleted, @@ -16,8 +14,7 @@ import { startResponsesTestProxy, SseResponseBody, } from "./responsesProxy"; - -const codexExecPath = path.join(process.cwd(), "..", "..", "codex-rs", "target", "debug", "codex"); +import { createMockClient, createTestClient } from "./testCodex"; describe("Codex", () => { it("returns thread events", async () => { @@ -25,10 +22,9 @@ describe("Codex", () => { statusCode: 200, responseBodies: [sse(responseStarted(), assistantMessage("Hi!"), responseCompleted())], }); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); - const thread = client.startThread(); const result = await thread.run("Hello, world!"); @@ -47,6 +43,7 @@ describe("Codex", () => { }); expect(thread.id).toEqual(expect.any(String)); } finally { + cleanup(); await close(); } }); @@ -67,10 +64,9 @@ describe("Codex", () => { ), ], }); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); - const thread = client.startThread(); await thread.run("first input"); await thread.run("second input"); @@ -90,6 +86,7 @@ describe("Codex", () => { )?.text; expect(assistantText).toBe("First response"); } finally { + cleanup(); await close(); } }); @@ -110,10 +107,9 @@ describe("Codex", () => { ), ], }); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); - const thread = client.startThread(); await thread.run("first input"); await thread.run("second input"); @@ -134,6 +130,7 @@ describe("Codex", () => { )?.text; expect(assistantText).toBe("First response"); } finally { + cleanup(); await close(); } }); @@ -154,10 +151,9 @@ describe("Codex", () => { ), ], }); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); - const originalThread = client.startThread(); await originalThread.run("first input"); @@ -181,6 +177,7 @@ describe("Codex", () => { )?.text; expect(assistantText).toBe("First response"); } finally { + cleanup(); await close(); } }); @@ -198,10 +195,9 @@ describe("Codex", () => { }); const { args: spawnArgs, restore } = codexExecSpy(); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); - const thread = client.startThread({ model: "gpt-test-1", sandboxMode: "workspace-write", @@ -219,6 +215,7 @@ describe("Codex", () => { expectPair(commandArgs, ["--sandbox", "workspace-write"]); expectPair(commandArgs, ["--model", "gpt-test-1"]); } finally { + cleanup(); restore(); await close(); } @@ -237,10 +234,9 @@ describe("Codex", () => { }); const { args: spawnArgs, restore } = codexExecSpy(); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); - const thread = client.startThread({ modelReasoningEffort: "high", }); @@ -250,6 +246,7 @@ describe("Codex", () => { expect(commandArgs).toBeDefined(); expectPair(commandArgs, ["--config", 'model_reasoning_effort="high"']); } finally { + cleanup(); restore(); await close(); } @@ -268,10 +265,9 @@ describe("Codex", () => { }); const { args: spawnArgs, restore } = codexExecSpy(); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); - const thread = client.startThread({ networkAccessEnabled: true, }); @@ -281,6 +277,7 @@ describe("Codex", () => { expect(commandArgs).toBeDefined(); expectPair(commandArgs, ["--config", "sandbox_workspace_write.network_access=true"]); } finally { + cleanup(); restore(); await close(); } @@ -299,10 +296,9 @@ describe("Codex", () => { }); const { args: spawnArgs, restore } = codexExecSpy(); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); - const thread = client.startThread({ webSearchEnabled: true, }); @@ -312,6 +308,7 @@ describe("Codex", () => { expect(commandArgs).toBeDefined(); expectPair(commandArgs, ["--config", 'web_search="live"']); } finally { + cleanup(); restore(); await close(); } @@ -330,10 +327,9 @@ describe("Codex", () => { }); const { args: spawnArgs, restore } = codexExecSpy(); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); - const thread = client.startThread({ webSearchMode: "cached", }); @@ -343,6 +339,7 @@ describe("Codex", () => { expect(commandArgs).toBeDefined(); expectPair(commandArgs, ["--config", 'web_search="cached"']); } finally { + cleanup(); restore(); await close(); } @@ -361,10 +358,9 @@ describe("Codex", () => { }); const { args: spawnArgs, restore } = codexExecSpy(); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); - const thread = client.startThread({ webSearchEnabled: false, }); @@ -374,6 +370,7 @@ describe("Codex", () => { expect(commandArgs).toBeDefined(); expectPair(commandArgs, ["--config", 'web_search="disabled"']); } finally { + cleanup(); restore(); await close(); } @@ -392,10 +389,9 @@ describe("Codex", () => { }); const { args: spawnArgs, restore } = codexExecSpy(); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); - const thread = client.startThread({ approvalPolicy: "on-request", }); @@ -405,6 +401,7 @@ describe("Codex", () => { expect(commandArgs).toBeDefined(); expectPair(commandArgs, ["--config", 'approval_policy="on-request"']); } finally { + cleanup(); restore(); await close(); } @@ -423,20 +420,18 @@ describe("Codex", () => { }); const { args: spawnArgs, restore } = codexExecSpy(); + const { client, cleanup } = createTestClient({ + baseUrl: url, + apiKey: "test", + config: { + approval_policy: "never", + sandbox_workspace_write: { network_access: true }, + retry_budget: 3, + tool_rules: { allow: ["git status", "git diff"] }, + }, + }); try { - const client = new Codex({ - codexPathOverride: codexExecPath, - baseUrl: url, - apiKey: "test", - config: { - approval_policy: "never", - sandbox_workspace_write: { network_access: true }, - retry_budget: 3, - tool_rules: { allow: ["git status", "git diff"] }, - }, - }); - const thread = client.startThread(); await thread.run("apply config overrides"); @@ -447,6 +442,7 @@ describe("Codex", () => { expectPair(commandArgs, ["--config", "retry_budget=3"]); expectPair(commandArgs, ["--config", 'tool_rules.allow=["git status", "git diff"]']); } finally { + cleanup(); restore(); await close(); } @@ -465,15 +461,13 @@ describe("Codex", () => { }); const { args: spawnArgs, restore } = codexExecSpy(); + const { client, cleanup } = createTestClient({ + baseUrl: url, + apiKey: "test", + config: { approval_policy: "never" }, + }); try { - const client = new Codex({ - codexPathOverride: codexExecPath, - baseUrl: url, - apiKey: "test", - config: { approval_policy: "never" }, - }); - const thread = client.startThread({ approvalPolicy: "on-request" }); await thread.run("override approval policy"); @@ -485,56 +479,7 @@ describe("Codex", () => { ]); expect(approvalPolicyOverrides.at(-1)).toBe('approval_policy="on-request"'); } finally { - restore(); - await close(); - } - }); - - it("allows overriding the env passed to the Codex CLI", async () => { - const { url, close } = await startResponsesTestProxy({ - statusCode: 200, - responseBodies: [ - sse( - responseStarted("response_1"), - assistantMessage("Custom env", "item_1"), - responseCompleted("response_1"), - ), - ], - }); - - const { args: spawnArgs, envs: spawnEnvs, restore } = codexExecSpy(); - process.env.CODEX_ENV_SHOULD_NOT_LEAK = "leak"; - - try { - const client = new Codex({ - codexPathOverride: codexExecPath, - baseUrl: url, - apiKey: "test", - env: { CUSTOM_ENV: "custom" }, - }); - - const thread = client.startThread(); - await thread.run("custom env"); - - const spawnEnv = spawnEnvs[0]; - expect(spawnEnv).toBeDefined(); - if (!spawnEnv) { - throw new Error("Spawn env missing"); - } - const commandArgs = spawnArgs[0]; - expect(commandArgs).toBeDefined(); - if (!commandArgs) { - throw new Error("Command args missing"); - } - expect(spawnEnv.CUSTOM_ENV).toBe("custom"); - expect(spawnEnv.CODEX_ENV_SHOULD_NOT_LEAK).toBeUndefined(); - expect(spawnEnv.OPENAI_BASE_URL).toBeUndefined(); - expect(spawnEnv.CODEX_API_KEY).toBe("test"); - expect(spawnEnv.CODEX_INTERNAL_ORIGINATOR_OVERRIDE).toBeDefined(); - expect(commandArgs).toContain("--config"); - expect(commandArgs).toContain(`openai_base_url=${JSON.stringify(url)}`); - } finally { - delete process.env.CODEX_ENV_SHOULD_NOT_LEAK; + cleanup(); restore(); await close(); } @@ -553,10 +498,9 @@ describe("Codex", () => { }); const { args: spawnArgs, restore } = codexExecSpy(); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); - const thread = client.startThread({ additionalDirectories: ["../backend", "/tmp/shared"], }); @@ -577,6 +521,7 @@ describe("Codex", () => { } expect(addDirArgs).toEqual(["../backend", "/tmp/shared"]); } finally { + cleanup(); restore(); await close(); } @@ -605,9 +550,9 @@ describe("Codex", () => { additionalProperties: false, } as const; - try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); + const { client, cleanup } = createMockClient(url); + try { const thread = client.startThread(); await thread.run("structured", { outputSchema: schema }); @@ -634,6 +579,7 @@ describe("Codex", () => { } expect(fs.existsSync(schemaPath)).toBe(false); } finally { + cleanup(); restore(); await close(); } @@ -649,10 +595,9 @@ describe("Codex", () => { ), ], }); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); - const thread = client.startThread(); await thread.run([ { type: "text", text: "Describe file changes" }, @@ -664,6 +609,7 @@ describe("Codex", () => { const lastUser = payload!.json.input.at(-1); expect(lastUser?.content?.[0]?.text).toBe("Describe file changes\n\nFocus on impacted tests"); } finally { + cleanup(); await close(); } }); @@ -688,10 +634,9 @@ describe("Codex", () => { imagesDirectoryEntries.forEach((image, index) => { fs.writeFileSync(image, `image-${index}`); }); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); - const thread = client.startThread(); await thread.run([ { type: "text", text: "describe the images" }, @@ -709,6 +654,7 @@ describe("Codex", () => { } expect(forwardedImages).toEqual(imagesDirectoryEntries); } finally { + cleanup(); fs.rmSync(tempDir, { recursive: true, force: true }); restore(); await close(); @@ -727,15 +673,13 @@ describe("Codex", () => { }); const { args: spawnArgs, restore } = codexExecSpy(); + const workingDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "codex-working-dir-")); + const { client, cleanup } = createTestClient({ + baseUrl: url, + apiKey: "test", + }); try { - const workingDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "codex-working-dir-")); - const client = new Codex({ - codexPathOverride: codexExecPath, - baseUrl: url, - apiKey: "test", - }); - const thread = client.startThread({ workingDirectory, skipGitRepoCheck: true, @@ -745,6 +689,8 @@ describe("Codex", () => { const commandArgs = spawnArgs[0]; expectPair(commandArgs, ["--cd", workingDirectory]); } finally { + cleanup(); + fs.rmSync(workingDirectory, { recursive: true, force: true }); restore(); await close(); } @@ -761,15 +707,13 @@ describe("Codex", () => { ), ], }); + const workingDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "codex-working-dir-")); + const { client, cleanup } = createTestClient({ + baseUrl: url, + apiKey: "test", + }); try { - const workingDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "codex-working-dir-")); - const client = new Codex({ - codexPathOverride: codexExecPath, - baseUrl: url, - apiKey: "test", - }); - const thread = client.startThread({ workingDirectory, }); @@ -777,6 +721,8 @@ describe("Codex", () => { /Not inside a trusted directory/, ); } finally { + cleanup(); + fs.rmSync(workingDirectory, { recursive: true, force: true }); await close(); } }); @@ -786,10 +732,9 @@ describe("Codex", () => { statusCode: 200, responseBodies: [sse(responseStarted(), assistantMessage("Hi!"), responseCompleted())], }); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); - const thread = client.startThread(); await thread.run("Hello, originator!"); @@ -801,6 +746,7 @@ describe("Codex", () => { expect(originatorHeader).toBe("codex_sdk_ts"); } } finally { + cleanup(); await close(); } }); @@ -814,12 +760,13 @@ describe("Codex", () => { } })(), }); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); const thread = client.startThread(); await expect(thread.run("fail")).rejects.toThrow("stream disconnected before completion:"); } finally { + cleanup(); await close(); } }, 10000); // TODO(pakrym): remove timeout diff --git a/sdk/typescript/tests/runStreamed.test.ts b/sdk/typescript/tests/runStreamed.test.ts index 6cdf22fea..3eb0552d3 100644 --- a/sdk/typescript/tests/runStreamed.test.ts +++ b/sdk/typescript/tests/runStreamed.test.ts @@ -1,8 +1,5 @@ -import path from "node:path"; - import { describe, expect, it } from "@jest/globals"; -import { Codex } from "../src/codex"; import { ThreadEvent } from "../src/index"; import { @@ -12,8 +9,7 @@ import { sse, startResponsesTestProxy, } from "./responsesProxy"; - -const codexExecPath = path.join(process.cwd(), "..", "..", "codex-rs", "target", "debug", "codex"); +import { createMockClient } from "./testCodex"; describe("Codex", () => { it("returns thread events", async () => { @@ -21,10 +17,9 @@ describe("Codex", () => { statusCode: 200, responseBodies: [sse(responseStarted(), assistantMessage("Hi!"), responseCompleted())], }); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); - const thread = client.startThread(); const result = await thread.runStreamed("Hello, world!"); @@ -60,6 +55,7 @@ describe("Codex", () => { ]); expect(thread.id).toEqual(expect.any(String)); } finally { + cleanup(); await close(); } }); @@ -80,10 +76,9 @@ describe("Codex", () => { ), ], }); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); - const thread = client.startThread(); const first = await thread.runStreamed("first input"); await drainEvents(first.events); @@ -106,6 +101,7 @@ describe("Codex", () => { )?.text; expect(assistantText).toBe("First response"); } finally { + cleanup(); await close(); } }); @@ -126,10 +122,9 @@ describe("Codex", () => { ), ], }); + const { client, cleanup } = createMockClient(url); try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); - const originalThread = client.startThread(); const first = await originalThread.runStreamed("first input"); await drainEvents(first.events); @@ -154,6 +149,7 @@ describe("Codex", () => { )?.text; expect(assistantText).toBe("First response"); } finally { + cleanup(); await close(); } }); @@ -169,6 +165,7 @@ describe("Codex", () => { ), ], }); + const { client, cleanup } = createMockClient(url); const schema = { type: "object", @@ -180,8 +177,6 @@ describe("Codex", () => { } as const; try { - const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); - const thread = client.startThread(); const streamed = await thread.runStreamed("structured", { outputSchema: schema }); await drainEvents(streamed.events); @@ -198,6 +193,7 @@ describe("Codex", () => { schema, }); } finally { + cleanup(); await close(); } }); diff --git a/sdk/typescript/tests/setupCodexHome.ts b/sdk/typescript/tests/setupCodexHome.ts new file mode 100644 index 000000000..83e49c6ac --- /dev/null +++ b/sdk/typescript/tests/setupCodexHome.ts @@ -0,0 +1,28 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach } from "@jest/globals"; + +const originalCodexHome = process.env.CODEX_HOME; +let currentCodexHome: string | undefined; + +beforeEach(async () => { + currentCodexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-sdk-test-")); + process.env.CODEX_HOME = currentCodexHome; +}); + +afterEach(async () => { + const codexHomeToDelete = currentCodexHome; + currentCodexHome = undefined; + + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } + + if (codexHomeToDelete) { + await fs.rm(codexHomeToDelete, { recursive: true, force: true }); + } +}); diff --git a/sdk/typescript/tests/testCodex.ts b/sdk/typescript/tests/testCodex.ts new file mode 100644 index 000000000..d73b519b6 --- /dev/null +++ b/sdk/typescript/tests/testCodex.ts @@ -0,0 +1,94 @@ +import path from "node:path"; + +import { Codex } from "../src/codex"; +import type { CodexConfigObject } from "../src/codexOptions"; + +export const codexExecPath = path.join(process.cwd(), "..", "..", "codex-rs", "target", "debug", "codex"); + +type CreateTestClientOptions = { + apiKey?: string; + baseUrl?: string; + config?: CodexConfigObject; + env?: Record; + inheritEnv?: boolean; +}; + +export type TestClient = { + cleanup: () => void; + client: Codex; +}; + +export function createMockClient(url: string): TestClient { + return createTestClient({ + config: { + model_provider: "mock", + model_providers: { + mock: { + name: "Mock provider for test", + base_url: url, + wire_api: "responses", + supports_websockets: false, + }, + }, + }, + }); +} + +export function createTestClient(options: CreateTestClientOptions = {}): TestClient { + const env = + options.inheritEnv === false ? { ...options.env } : { ...getCurrentEnv(), ...options.env }; + + return { + cleanup: () => {}, + client: new Codex({ + codexPathOverride: codexExecPath, + baseUrl: options.baseUrl, + apiKey: options.apiKey, + config: mergeTestProviderConfig(options.baseUrl, options.config), + env, + }), + }; +} + +function mergeTestProviderConfig( + baseUrl: string | undefined, + config: CodexConfigObject | undefined, +): CodexConfigObject | undefined { + if (!baseUrl || hasExplicitProviderConfig(config)) { + return config; + } + + // Built-in providers are merged before user config, so tests need a custom + // provider entry to force SSE against the local mock server. + return { + ...config, + model_provider: "mock", + model_providers: { + mock: { + name: "Mock provider for test", + base_url: baseUrl, + wire_api: "responses", + supports_websockets: false, + }, + }, + }; +} + +function hasExplicitProviderConfig(config: CodexConfigObject | undefined): boolean { + return config?.model_provider !== undefined || config?.model_providers !== undefined; +} + +function getCurrentEnv(): Record { + const env: Record = {}; + + for (const [key, value] of Object.entries(process.env)) { + if (key === "CODEX_INTERNAL_ORIGINATOR_OVERRIDE") { + continue; + } + if (value !== undefined) { + env[key] = value; + } + } + + return env; +}