From 6dc5391c7c38a903139aa2bb8e8e3a5e588f2b39 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 8 Apr 2026 21:50:02 -0700 Subject: [PATCH 1/3] Add TUI notification condition config (#17175) Problem: TUI desktop notifications are hard-gated on terminal focus, so terminal/IDE hosts that want in-focus notifications cannot opt in. Solution: Add a flat `[tui] notification_condition` setting (`unfocused` by default, `always` opt-in), carry grouped TUI notification settings through runtime config, apply method + condition together in the TUI, and regenerate the config schema. --- codex-rs/config/src/types.rs | 49 ++++++++++--- codex-rs/core/config.schema.json | 31 +++++++- codex-rs/core/src/config/config_tests.rs | 70 +++++++++++++++---- codex-rs/core/src/config/mod.rs | 18 ++--- codex-rs/tui/src/app.rs | 10 ++- codex-rs/tui/src/chatwidget.rs | 2 +- .../tui/src/chatwidget/tests/plan_mode.rs | 9 ++- codex-rs/tui/src/tui.rs | 50 ++++++++++++- 8 files changed, 191 insertions(+), 48 deletions(-) diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index f26d336018..cee7271691 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -472,6 +472,44 @@ impl fmt::Display for NotificationMethod { } } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] +#[serde(rename_all = "lowercase")] +pub enum NotificationCondition { + /// Emit TUI notifications only while the terminal is unfocused. + #[default] + Unfocused, + /// Emit TUI notifications regardless of terminal focus. + Always, +} + +impl fmt::Display for NotificationCondition { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + NotificationCondition::Unfocused => write!(f, "unfocused"), + NotificationCondition::Always => write!(f, "always"), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct TuiNotificationSettings { + /// Enable desktop notifications from the TUI. + /// Defaults to `true`. + #[serde(default, rename = "notifications")] + pub notifications: Notifications, + + /// Notification method to use for terminal notifications. + /// Defaults to `auto`. + #[serde(default, rename = "notification_method")] + pub method: NotificationMethod, + + /// Controls whether TUI notifications are delivered only when the terminal is unfocused or + /// regardless of focus. Defaults to `unfocused`. + #[serde(default, rename = "notification_condition")] + pub condition: NotificationCondition, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct ModelAvailabilityNuxConfig { @@ -484,15 +522,8 @@ pub struct ModelAvailabilityNuxConfig { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct Tui { - /// Enable desktop notifications from the TUI when the terminal is unfocused. - /// Defaults to `true`. - #[serde(default)] - pub notifications: Notifications, - - /// Notification method to use for unfocused terminal notifications. - /// Defaults to `auto`. - #[serde(default)] - pub notification_method: NotificationMethod, + #[serde(default, flatten)] + pub notification_settings: TuiNotificationSettings, /// Enable animations (welcome screen, shimmer effects, spinners). /// Defaults to `true`. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index ce43d3ceca..bcc5193b2c 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1108,6 +1108,24 @@ }, "type": "object" }, + "NotificationCondition": { + "oneOf": [ + { + "description": "Emit TUI notifications only while the terminal is unfocused.", + "enum": [ + "unfocused" + ], + "type": "string" + }, + { + "description": "Emit TUI notifications regardless of terminal focus.", + "enum": [ + "always" + ], + "type": "string" + } + ] + }, "NotificationMethod": { "enum": [ "auto", @@ -1812,6 +1830,15 @@ "default": {}, "description": "Startup tooltip availability NUX state persisted by the TUI." }, + "notification_condition": { + "allOf": [ + { + "$ref": "#/definitions/NotificationCondition" + } + ], + "default": "unfocused", + "description": "Controls whether TUI notifications are delivered only when the terminal is unfocused or regardless of focus. Defaults to `unfocused`." + }, "notification_method": { "allOf": [ { @@ -1819,7 +1846,7 @@ } ], "default": "auto", - "description": "Notification method to use for unfocused terminal notifications. Defaults to `auto`." + "description": "Notification method to use for terminal notifications. Defaults to `auto`." }, "notifications": { "allOf": [ @@ -1828,7 +1855,7 @@ } ], "default": true, - "description": "Enable desktop notifications from the TUI when the terminal is unfocused. Defaults to `true`." + "description": "Enable desktop notifications from the TUI. Defaults to `true`." }, "show_tooltips": { "default": true, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 5bc34b4574..478f8182fd 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -34,12 +34,14 @@ use codex_config::types::McpServerTransportConfig; use codex_config::types::MemoriesConfig; use codex_config::types::MemoriesToml; use codex_config::types::ModelAvailabilityNuxConfig; +use codex_config::types::NotificationCondition; use codex_config::types::NotificationMethod; use codex_config::types::Notifications; use codex_config::types::SandboxWorkspaceWrite; use codex_config::types::SkillsConfig; use codex_config::types::ToolSuggestDiscoverableType; use codex_config::types::Tui; +use codex_config::types::TuiNotificationSettings; use codex_features::Feature; use codex_features::FeaturesToml; use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; @@ -355,8 +357,7 @@ fn config_toml_deserializes_model_availability_nux() { assert_eq!( cfg.tui.expect("tui config should deserialize"), Tui { - notifications: Notifications::default(), - notification_method: NotificationMethod::default(), + notification_settings: TuiNotificationSettings::default(), animations: true, show_tooltips: true, alternate_screen: AltScreenMode::default(), @@ -1052,8 +1053,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { assert_eq!( tui, Tui { - notifications: Notifications::Enabled(true), - notification_method: NotificationMethod::Auto, + notification_settings: TuiNotificationSettings::default(), animations: true, show_tooltips: true, alternate_screen: AltScreenMode::Auto, @@ -4583,7 +4583,6 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { check_for_update_on_startup: true, disable_paste_burst: false, tui_notifications: Default::default(), - tui_notification_method: Default::default(), animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), @@ -4730,7 +4729,6 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { check_for_update_on_startup: true, disable_paste_burst: false, tui_notifications: Default::default(), - tui_notification_method: Default::default(), animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), @@ -4875,7 +4873,6 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { check_for_update_on_startup: true, disable_paste_burst: false, tui_notifications: Default::default(), - tui_notification_method: Default::default(), animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), @@ -5006,7 +5003,6 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { check_for_update_on_startup: true, disable_paste_burst: false, tui_notifications: Default::default(), - tui_notification_method: Default::default(), animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), @@ -6559,10 +6555,8 @@ speaker = "Desk Speakers" #[derive(Deserialize, Debug, PartialEq)] struct TuiTomlTest { - #[serde(default)] - notifications: Notifications, - #[serde(default)] - notification_method: NotificationMethod, + #[serde(default, flatten)] + notifications: TuiNotificationSettings, } #[derive(Deserialize, Debug, PartialEq)] @@ -6577,7 +6571,10 @@ fn test_tui_notifications_true() { notifications = true "#; let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize notifications=true"); - assert_matches!(parsed.tui.notifications, Notifications::Enabled(true)); + assert_matches!( + parsed.tui.notifications.notifications, + Notifications::Enabled(true) + ); } #[test] @@ -6588,7 +6585,7 @@ fn test_tui_notifications_custom_array() { "#; let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize notifications=[\"foo\"]"); assert_matches!( - parsed.tui.notifications, + parsed.tui.notifications.notifications, Notifications::Custom(ref v) if v == &vec!["foo".to_string()] ); } @@ -6601,5 +6598,48 @@ fn test_tui_notification_method() { "#; let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize notification_method=\"bel\""); - assert_eq!(parsed.tui.notification_method, NotificationMethod::Bel); + assert_eq!(parsed.tui.notifications.method, NotificationMethod::Bel); +} + +#[test] +fn test_tui_notification_condition_defaults_to_unfocused() { + let toml = r#" + [tui] + "#; + let parsed: RootTomlTest = + toml::from_str(toml).expect("deserialize default notification condition"); + assert_eq!( + parsed.tui.notifications.condition, + NotificationCondition::Unfocused + ); +} + +#[test] +fn test_tui_notification_condition_always() { + let toml = r#" + [tui] + notification_condition = "always" + "#; + let parsed: RootTomlTest = + toml::from_str(toml).expect("deserialize notification_condition=\"always\""); + assert_eq!( + parsed.tui.notifications.condition, + NotificationCondition::Always + ); +} + +#[test] +fn test_tui_notification_condition_rejects_unknown_value() { + let toml = r#" + [tui] + notification_condition = "background" + "#; + let err = toml::from_str::(toml).expect_err("reject unknown condition"); + let err = err.to_string(); + assert!( + err.contains("unknown variant `background`") + && err.contains("unfocused") + && err.contains("always"), + "unexpected error: {err}" + ); } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 904862eaf7..ebb98e48a2 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -38,8 +38,6 @@ use codex_config::types::McpServerTransportConfig; use codex_config::types::MemoriesConfig; use codex_config::types::ModelAvailabilityNuxConfig; use codex_config::types::Notice; -use codex_config::types::NotificationMethod; -use codex_config::types::Notifications; use codex_config::types::OAuthCredentialsStoreMode; use codex_config::types::OtelConfig; use codex_config::types::OtelConfigToml; @@ -47,6 +45,7 @@ use codex_config::types::OtelExporterKind; use codex_config::types::ShellEnvironmentPolicy; use codex_config::types::ToolSuggestConfig; use codex_config::types::ToolSuggestDiscoverable; +use codex_config::types::TuiNotificationSettings; use codex_config::types::UriBasedFileOpener; use codex_config::types::WindowsSandboxModeToml; use codex_features::Feature; @@ -301,12 +300,8 @@ pub struct Config { /// If unset the feature is disabled. pub notify: Option>, - /// TUI notifications preference. When set, the TUI will send terminal notifications on - /// approvals and turn completions when not focused. - pub tui_notifications: Notifications, - - /// Notification method for terminal notifications (osc9 or bel). - pub tui_notification_method: NotificationMethod, + /// TUI notification settings, including enabled events, delivery method, and focus condition. + pub tui_notifications: TuiNotificationSettings, /// Enable ASCII animations and shimmer effects in the TUI. pub animations: bool, @@ -2136,12 +2131,7 @@ impl Config { tui_notifications: cfg .tui .as_ref() - .map(|t| t.notifications.clone()) - .unwrap_or_default(), - tui_notification_method: cfg - .tui - .as_ref() - .map(|t| t.notification_method) + .map(|t| t.notification_settings.clone()) .unwrap_or_default(), animations: cfg.tui.as_ref().map(|t| t.animations).unwrap_or(true), show_tooltips: cfg.tui.as_ref().map(|t| t.show_tooltips).unwrap_or(true), diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6a8a32316b..3ca5edb00d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3604,7 +3604,10 @@ impl App { let app_event_tx = AppEventSender::new(app_event_tx); emit_project_config_warnings(&app_event_tx, &config); emit_system_bwrap_warning(&app_event_tx, &config); - tui.set_notification_method(config.tui_notification_method); + tui.set_notification_settings( + config.tui_notifications.method, + config.tui_notifications.condition, + ); let harness_overrides = normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?; @@ -4145,7 +4148,10 @@ impl App { Ok(resumed) => { self.shutdown_current_thread(app_server).await; self.config = resume_config; - tui.set_notification_method(self.config.tui_notification_method); + tui.set_notification_settings( + self.config.tui_notifications.method, + self.config.tui_notifications.condition, + ); self.file_search .update_search_dir(self.config.cwd.to_path_buf()); match self diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 07c8f0b759..fd87c58f69 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -7224,7 +7224,7 @@ impl ChatWidget { } fn notify(&mut self, notification: Notification) { - if !notification.allowed_for(&self.config.tui_notifications) { + if !notification.allowed_for(&self.config.tui_notifications.notifications) { return; } if let Some(existing) = self.pending_notification.as_ref() diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 29b1b8c591..660bb8fa97 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -287,7 +287,8 @@ fn user_input_requested_notification_uses_dedicated_type_name() { #[tokio::test] async fn open_plan_implementation_prompt_sets_pending_notification() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; - chat.config.tui_notifications = Notifications::Custom(vec!["plan-mode-prompt".to_string()]); + chat.config.tui_notifications.notifications = + Notifications::Custom(vec!["plan-mode-prompt".to_string()]); chat.open_plan_implementation_prompt(); @@ -300,7 +301,8 @@ async fn open_plan_implementation_prompt_sets_pending_notification() { #[tokio::test] async fn open_plan_reasoning_scope_prompt_sets_pending_notification() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; - chat.config.tui_notifications = Notifications::Custom(vec!["plan-mode-prompt".to_string()]); + chat.config.tui_notifications.notifications = + Notifications::Custom(vec!["plan-mode-prompt".to_string()]); chat.open_plan_reasoning_scope_prompt( "gpt-5.1-codex-max".to_string(), @@ -363,7 +365,8 @@ async fn user_input_notification_overrides_pending_agent_turn_complete_notificat #[tokio::test] async fn handle_request_user_input_sets_pending_notification() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; - chat.config.tui_notifications = Notifications::Custom(vec!["user-input-requested".to_string()]); + chat.config.tui_notifications.notifications = + Notifications::Custom(vec!["user-input-requested".to_string()]); chat.handle_request_user_input_now(RequestUserInputEvent { call_id: "call-1".to_string(), diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 9fc67d0a52..5cff8521ad 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -46,6 +46,7 @@ use crate::tui::event_stream::EventBroker; use crate::tui::event_stream::TuiEventStream; #[cfg(unix)] use crate::tui::job_control::SuspendContext; +use codex_config::types::NotificationCondition; use codex_config::types::NotificationMethod; mod event_stream; @@ -60,6 +61,43 @@ pub(crate) const TARGET_FRAME_INTERVAL: Duration = frame_rate_limiter::MIN_FRAME /// A type alias for the terminal type used in this application pub type Terminal = CustomTerminal>; +fn should_emit_notification(condition: NotificationCondition, terminal_focused: bool) -> bool { + match condition { + NotificationCondition::Unfocused => !terminal_focused, + NotificationCondition::Always => true, + } +} + +#[cfg(test)] +mod tests { + use super::should_emit_notification; + use codex_config::types::NotificationCondition; + + #[test] + fn unfocused_notification_condition_is_suppressed_when_focused() { + assert!(!should_emit_notification( + NotificationCondition::Unfocused, + /*terminal_focused*/ true + )); + } + + #[test] + fn always_notification_condition_emits_when_focused() { + assert!(should_emit_notification( + NotificationCondition::Always, + /*terminal_focused*/ true + )); + } + + #[test] + fn unfocused_notification_condition_emits_when_unfocused() { + assert!(should_emit_notification( + NotificationCondition::Unfocused, + /*terminal_focused*/ false + )); + } +} + pub fn set_modes() -> Result<()> { execute!(stdout(), EnableBracketedPaste)?; @@ -254,6 +292,7 @@ pub struct Tui { terminal_focused: Arc, enhanced_keys_supported: bool, notification_backend: Option, + notification_condition: NotificationCondition, is_zellij: bool, // When false, enter_alt_screen() becomes a no-op (for Zellij scrollback support) alt_screen_enabled: bool, @@ -288,6 +327,7 @@ impl Tui { terminal_focused: Arc::new(AtomicBool::new(true)), enhanced_keys_supported, notification_backend: Some(detect_backend(NotificationMethod::default())), + notification_condition: NotificationCondition::default(), is_zellij, alt_screen_enabled: true, } @@ -298,8 +338,13 @@ impl Tui { self.alt_screen_enabled = enabled; } - pub fn set_notification_method(&mut self, method: NotificationMethod) { + pub fn set_notification_settings( + &mut self, + method: NotificationMethod, + condition: NotificationCondition, + ) { self.notification_backend = Some(detect_backend(method)); + self.notification_condition = condition; } pub fn frame_requester(&self) -> FrameRequester { @@ -367,7 +412,8 @@ impl Tui { /// Emit a desktop notification now if the terminal is unfocused. /// Returns true if a notification was posted. pub fn notify(&mut self, message: impl AsRef) -> bool { - if self.terminal_focused.load(Ordering::Relaxed) { + let terminal_focused = self.terminal_focused.load(Ordering::Relaxed); + if !should_emit_notification(self.notification_condition, terminal_focused) { return false; } From 1fdb695e427b08c77e28e12c4d90cb46df49f7c9 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 8 Apr 2026 22:11:30 -0700 Subject: [PATCH 2/3] Default realtime startup to v2 model (#17183) - Default realtime sessions to v2 and gpt-realtime-1.5 when no override is configured. - Add Op::RealtimeConversationStart integration coverage and keep v1-specific tests explicit. --------- Co-authored-by: Codex --- .../tests/suite/v2/realtime_conversation.rs | 4 +- codex-rs/core/src/config/config_tests.rs | 29 ++++++ codex-rs/core/src/config/mod.rs | 13 +-- codex-rs/core/src/realtime_conversation.rs | 8 +- codex-rs/core/tests/common/test_codex.rs | 2 + .../core/tests/suite/realtime_conversation.rs | 89 ++++++++++++++++--- codex-rs/protocol/src/protocol.rs | 2 +- 7 files changed, 128 insertions(+), 19 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index 2ca4e39ab9..f1f60be45e 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -973,7 +973,7 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { Some("multipart/form-data; boundary=codex-realtime-call-boundary") ); let body = String::from_utf8(request.body).context("multipart body should be utf-8")?; - let session = r#"{"tool_choice":"auto","type":"realtime","instructions":"backend prompt\n\nstartup context","output_modalities":["audio"],"audio":{"input":{"format":{"type":"audio/pcm","rate":24000},"noise_reduction":{"type":"near_field"},"turn_detection":{"type":"server_vad","interrupt_response":true,"create_response":true}},"output":{"format":{"type":"audio/pcm","rate":24000},"voice":"marin"}},"tools":[{"type":"function","name":"codex","description":"Delegate a request to Codex and return the final result to the user. Use this as the default action. If the user asks to do something next, later, after this, or once current work finishes, call this tool so the work is actually queued instead of merely promising to do it later.","parameters":{"type":"object","properties":{"prompt":{"type":"string","description":"The user request to delegate to Codex."}},"required":["prompt"],"additionalProperties":false}}]}"#; + let session = r#"{"tool_choice":"auto","type":"realtime","model":"gpt-realtime-1.5","instructions":"backend prompt\n\nstartup context","output_modalities":["audio"],"audio":{"input":{"format":{"type":"audio/pcm","rate":24000},"noise_reduction":{"type":"near_field"},"turn_detection":{"type":"server_vad","interrupt_response":true,"create_response":true}},"output":{"format":{"type":"audio/pcm","rate":24000},"voice":"marin"}},"tools":[{"type":"function","name":"codex","description":"Delegate a request to Codex and return the final result to the user. Use this as the default action. If the user asks to do something next, later, after this, or once current work finishes, call this tool so the work is actually queued instead of merely promising to do it later.","parameters":{"type":"object","properties":{"prompt":{"type":"string","description":"The user request to delegate to Codex."}},"required":["prompt"],"additionalProperties":false}}]}"#; assert_eq!( body, format!( @@ -1709,7 +1709,7 @@ fn assert_call_create_multipart( } fn v1_session_create_json() -> &'static str { - r#"{"audio":{"input":{"format":{"type":"audio/pcm","rate":24000}},"output":{"voice":"cove"}},"type":"quicksilver","instructions":"backend prompt\n\nstartup context"}"# + r#"{"audio":{"input":{"format":{"type":"audio/pcm","rate":24000}},"output":{"voice":"cove"}},"type":"quicksilver","model":"gpt-realtime-1.5","instructions":"backend prompt\n\nstartup context"}"# } fn create_config_toml( diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 478f8182fd..dab42da753 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -6478,6 +6478,35 @@ experimental_realtime_ws_model = "realtime-test-model" Ok(()) } +#[test] +fn realtime_config_partial_table_uses_realtime_defaults() -> std::io::Result<()> { + let cfg: ConfigToml = toml::from_str( + r#" +[realtime] +voice = "marin" +"#, + ) + .expect("TOML deserialization should succeed"); + + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.realtime, + RealtimeConfig { + version: RealtimeWsVersion::V2, + session_type: RealtimeWsMode::Conversational, + transport: RealtimeTransport::Websocket, + voice: Some(RealtimeVoice::Marin), + } + ); + Ok(()) +} + #[test] fn realtime_loads_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index ebb98e48a2..eac8f9e361 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2089,11 +2089,14 @@ impl Config { experimental_realtime_ws_model: cfg.experimental_realtime_ws_model, realtime: cfg .realtime - .map_or_else(RealtimeConfig::default, |realtime| RealtimeConfig { - version: realtime.version.unwrap_or_default(), - session_type: realtime.session_type.unwrap_or_default(), - transport: realtime.transport.unwrap_or_default(), - voice: realtime.voice, + .map_or_else(RealtimeConfig::default, |realtime| { + let defaults = RealtimeConfig::default(); + RealtimeConfig { + version: realtime.version.unwrap_or(defaults.version), + session_type: realtime.session_type.unwrap_or(defaults.session_type), + transport: realtime.transport.unwrap_or(defaults.transport), + voice: realtime.voice, + } }), experimental_realtime_ws_backend_prompt: cfg.experimental_realtime_ws_backend_prompt, experimental_realtime_ws_startup_context: cfg.experimental_realtime_ws_startup_context, diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 38cb0c273a..ea719f5f8b 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -61,6 +61,7 @@ const USER_TEXT_IN_QUEUE_CAPACITY: usize = 64; const HANDOFF_OUT_QUEUE_CAPACITY: usize = 64; const OUTPUT_EVENTS_QUEUE_CAPACITY: usize = 256; const REALTIME_STARTUP_CONTEXT_TOKEN_BUDGET: usize = 5_000; +const DEFAULT_REALTIME_MODEL: &str = "gpt-realtime-1.5"; const ACTIVE_RESPONSE_CONFLICT_ERROR_PREFIX: &str = "Conversation already has an active response in progress:"; @@ -572,7 +573,12 @@ pub(crate) async fn build_realtime_session_config( (false, true) => prompt, (false, false) => format!("{prompt}\n\n{startup_context}"), }; - let model = config.experimental_realtime_ws_model.clone(); + let model = Some( + config + .experimental_realtime_ws_model + .clone() + .unwrap_or_else(|| DEFAULT_REALTIME_MODEL.to_string()), + ); let event_parser = match config.realtime.version { RealtimeWsVersion::V1 => RealtimeEventParser::V1, RealtimeWsVersion::V2 => RealtimeEventParser::RealtimeV2, diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index ad62db4d79..eaa9f8e2b4 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -32,6 +32,7 @@ use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; @@ -453,6 +454,7 @@ impl TestCodexBuilder { 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.realtime.version = RealtimeWsVersion::V1; })); let test_env = TestEnv::local().await?; Box::pin(self.build_with_home_and_base_url(base_url, home, /*resume_from*/ None, test_env)) diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index fb4f871551..0f68af428e 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -360,6 +360,65 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_start_defaults_to_v2_and_gpt_realtime_1_5() -> Result<()> { + skip_if_no_network!(Ok(())); + + let api_server = start_mock_server().await; + let realtime_server = start_websocket_server(vec![vec![vec![]]]).await; + let realtime_base_url = realtime_server.uri().to_string(); + let mut builder = test_codex().with_config(move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.experimental_realtime_ws_startup_context = Some(String::new()); + }); + let test = builder.build(&api_server).await?; + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: Some(Some("backend prompt".to_string())), + session_id: None, + transport: None, + voice: None, + })) + .await?; + + let started = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationStarted(started) => Some(Ok(started.clone())), + EventMsg::Error(err) => Some(Err(err.clone())), + _ => None, + }) + .await + .unwrap_or_else(|err: ErrorEvent| panic!("conversation start failed: {err:?}")); + + assert!( + realtime_server + .wait_for_handshakes(/*expected*/ 1, Duration::from_secs(2)) + .await + ); + + let session_update = realtime_server + .wait_for_request(/*connection_index*/ 0, /*request_index*/ 0) + .await; + let body = session_update.body_json(); + assert_eq!( + json!({ + "startedVersion": started.version, + "handshakeUri": realtime_server.single_handshake().uri(), + "voice": body["session"]["audio"]["output"]["voice"], + "instructions": body["session"]["instructions"], + }), + json!({ + "startedVersion": RealtimeConversationVersion::V2, + "handshakeUri": "/v1/realtime?model=gpt-realtime-1.5", + "voice": "marin", + "instructions": "backend prompt", + }) + ); + + realtime_server.shutdown().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { skip_if_no_network!(Ok(())); @@ -393,6 +452,7 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { config.experimental_realtime_ws_model = Some("realtime-test-model".to_string()); config.experimental_realtime_ws_startup_context = Some("startup context".to_string()); config.experimental_realtime_ws_base_url = Some(realtime_ws_base_url); + config.realtime.version = RealtimeWsVersion::V1; }); let test = builder.build(&server).await?; @@ -718,6 +778,7 @@ async fn conversation_start_connect_failure_emits_realtime_error_only() -> Resul let server = start_websocket_server(vec![]).await; let mut builder = test_codex().with_config(|config| { config.experimental_realtime_ws_base_url = Some("http://127.0.0.1:1".to_string()); + config.realtime.version = RealtimeWsVersion::V1; }); let test = builder.build_with_websocket_server(&server).await?; @@ -908,6 +969,7 @@ async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Resul let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build_with_websocket_server(&startup_server).await?; @@ -1179,16 +1241,11 @@ async fn conversation_uses_configured_realtime_voice() -> Result<()> { async fn conversation_rejects_voice_for_wrong_realtime_version() -> Result<()> { skip_if_no_network!(Ok(())); - let server = start_websocket_server(vec![vec![]]).await; + let api_server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { config.realtime.version = RealtimeWsVersion::V2; }); - let test = builder.build_with_websocket_server(&server).await?; - assert!( - server - .wait_for_handshakes(/*expected*/ 1, Duration::from_secs(2)) - .await - ); + let test = builder.build(&api_server).await?; test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { @@ -1207,9 +1264,6 @@ async fn conversation_rejects_voice_for_wrong_realtime_version() -> Result<()> { }) .await; assert!(error.contains("realtime voice `cove` is not supported for v2")); - - assert_eq!(server.connections().len(), 1); - server.shutdown().await; Ok(()) } @@ -1279,6 +1333,7 @@ async fn conversation_uses_experimental_realtime_ws_startup_context_override() - let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; config.experimental_realtime_ws_backend_prompt = Some("prompt from config".to_string()); config.experimental_realtime_ws_startup_context = Some("custom startup context".to_string()); @@ -1342,6 +1397,7 @@ async fn conversation_disables_realtime_startup_context_with_empty_override() -> let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; config.experimental_realtime_ws_backend_prompt = Some("prompt from config".to_string()); config.experimental_realtime_ws_startup_context = Some(String::new()); } @@ -1404,6 +1460,7 @@ async fn conversation_start_injects_startup_context_from_thread_history() -> Res let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build_with_websocket_server(&startup_server).await?; @@ -1466,6 +1523,7 @@ async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<() let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build_with_websocket_server(&startup_server).await?; @@ -1519,6 +1577,7 @@ async fn conversation_startup_context_is_truncated_and_sent_once_per_start() -> let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build_with_websocket_server(&startup_server).await?; @@ -1607,6 +1666,7 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_handoff() -> Re let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build(&api_server).await?; @@ -1735,6 +1795,7 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build_with_streaming_server(&api_server).await?; @@ -1878,6 +1939,7 @@ async fn inbound_handoff_request_starts_turn() -> Result<()> { let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build(&api_server).await?; @@ -1974,6 +2036,7 @@ async fn inbound_handoff_request_uses_active_transcript() -> Result<()> { let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build(&api_server).await?; @@ -2068,6 +2131,7 @@ async fn inbound_handoff_request_clears_active_transcript_after_each_handoff() - let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build(&api_server).await?; @@ -2169,6 +2233,7 @@ async fn inbound_conversation_item_does_not_start_turn_and_still_forwards_audio( let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build(&api_server).await?; @@ -2283,6 +2348,7 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build_with_streaming_server(&api_server).await?; @@ -2427,6 +2493,7 @@ async fn inbound_handoff_request_does_not_block_realtime_event_forwarding() -> R let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build_with_streaming_server(&api_server).await?; @@ -2555,6 +2622,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build_with_streaming_server(&api_server).await?; @@ -2698,6 +2766,7 @@ async fn inbound_handoff_request_starts_turn_and_does_not_block_realtime_audio() let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build_with_streaming_server(&api_server).await?; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index dd3d1bfca9..8083db9358 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1649,8 +1649,8 @@ pub struct HookCompletedEvent { #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum RealtimeConversationVersion { - #[default] V1, + #[default] V2, } From 23f4cd8459bcc254b4f6f10456f5a12a3eb4faaa Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 8 Apr 2026 22:26:05 -0700 Subject: [PATCH 3/3] Skip update prompts for source builds (#17186) Addresses #17166 Problem: Source builds report version 0.0.0, so the TUI update path can treat any released Codex version as upgradeable and show startup or popup prompts. Solution: Skip both TUI update prompt entry points when the running CLI version is the source-build sentinel 0.0.0. --- codex-rs/tui/src/updates.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs index 1edd871d0b..8a4b65955b 100644 --- a/codex-rs/tui/src/updates.rs +++ b/codex-rs/tui/src/updates.rs @@ -15,7 +15,7 @@ use std::path::PathBuf; use crate::version::CODEX_CLI_VERSION; pub fn get_upgrade_version(config: &Config) -> Option { - if !config.check_for_update_on_startup { + if !config.check_for_update_on_startup || is_source_build_version(CODEX_CLI_VERSION) { return None; } @@ -137,7 +137,7 @@ fn extract_version_from_latest_tag(latest_tag_name: &str) -> anyhow::Result Option { - if !config.check_for_update_on_startup { + if !config.check_for_update_on_startup || is_source_build_version(CODEX_CLI_VERSION) { return None; } @@ -177,6 +177,10 @@ fn parse_version(v: &str) -> Option<(u64, u64, u64)> { Some((maj, min, pat)) } +fn is_source_build_version(version: &str) -> bool { + parse_version(version) == Some((0, 0, 0)) +} + #[cfg(test)] mod tests { use super::*;