Compare commits

..

2 Commits

Author SHA1 Message Date
Ahmed Ibrahim
1fdb695e42 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 <noreply@openai.com>
2026-04-08 22:11:30 -07:00
Eric Traut
6dc5391c7c 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.
2026-04-08 21:50:02 -07:00
13 changed files with 319 additions and 67 deletions

View File

@@ -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(

View File

@@ -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`.

View File

@@ -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,

View File

@@ -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(),
@@ -6482,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(
@@ -6559,10 +6584,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 +6600,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 +6614,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 +6627,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::<RootTomlTest>(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}"
);
}

View File

@@ -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<Vec<String>>,
/// 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,
@@ -2094,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,
@@ -2136,12 +2134,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),

View File

@@ -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,

View File

@@ -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))

View File

@@ -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?;

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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(),

View File

@@ -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<CrosstermBackend<Stdout>>;
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<AtomicBool>,
enhanced_keys_supported: bool,
notification_backend: Option<DesktopNotificationBackend>,
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<str>) -> 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;
}