Expose voice conversations in experimental features (#44331)

## What changed

- Mark `realtime_conversation` as experimental, keeping it disabled by default, and label it “Voice conversations” in `/experimental`.
- Add an announcement directing users to enable the feature, restart Codex, and run `/voice`.
- Hide server-discovered voice entries and the announcement when the client lacks native voice runtime support.

## Testing

Add tests and snapshots for voice discovery and announcement visibility with and without runtime support, and update the experimental features popup snapshot.

GitOrigin-RevId: 709655f3733cb88191eb78b40ac1f67e27c9816e
This commit is contained in:
Benjamin Carlsson
2026-09-09 23:16:06 +00:00
committed by copyberry
parent 130d6e4fba
commit e722303e38
9 changed files with 123 additions and 10 deletions
+5 -1
View File
@@ -1680,7 +1680,11 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::RealtimeConversation,
key: "realtime_conversation",
stage: Stage::Removed,
stage: Stage::Experimental {
name: "Voice conversations",
menu_description: "Talk with Codex using /voice.",
announcement: "NEW: Voice conversations can now be enabled from /experimental. Restart Codex after enabling, then use /voice.",
},
default_enabled: false,
},
FeatureSpec {
@@ -1,5 +1,6 @@
//! Experimental controls with popup-owned discovery and configured enablement.
//! Failed saves retain intent for explicit retry; cancellation remains available.
//! Server voice discovery is gated by the client package's native runtime.
use crate::experimental_features::FeatureWriteResult;
use codex_app_server_protocol::ExperimentalFeature;
@@ -53,6 +54,7 @@ pub(crate) struct ExperimentalFeatureItem {
pub(crate) struct ExperimentalFeaturesView {
features: Vec<ExperimentalFeatureItem>,
voice_supported: bool,
initial_enabled: Vec<bool>,
unconfirmed: Vec<String>,
catalog_rx: Option<oneshot::Receiver<Result<Vec<ExperimentalFeature>, String>>>,
@@ -82,6 +84,7 @@ impl ExperimentalFeaturesView {
}
.to_string(),
thread_id,
voice_supported: codex_realtime_webrtc::RealtimeWebrtcSession::is_supported(),
write_rx: None,
catalog_rx,
unconfirmed: Vec::new(),
@@ -312,7 +315,10 @@ impl BottomPaneView for ExperimentalFeaturesView {
Ok(features) => {
let mut count = 0;
for feature in features {
if feature.stage != ExperimentalFeatureStage::Beta {
if feature.stage != ExperimentalFeatureStage::Beta
|| (feature.name == Feature::RealtimeConversation.key()
&& !self.voice_supported)
{
continue;
}
self.initial_enabled.push(feature.enabled);
@@ -207,3 +207,32 @@ fn snapshot_view(name: &str, view: &ExperimentalFeaturesView) {
view.render(area, &mut buffer);
insta::assert_snapshot!(name, buffer_text(&buffer));
}
#[test]
fn voice_discovery_requires_the_client_runtime() {
for supported in [false, true] {
let (app_tx, _app_rx) = tokio::sync::mpsc::unbounded_channel();
let (catalog_tx, catalog_rx) = oneshot::channel();
let mut view = ExperimentalFeaturesView::new(
Vec::new(),
ThreadId::new(),
Some(catalog_rx),
AppEventSender::new(app_tx),
crate::keymap::RuntimeKeymap::defaults().list,
);
view.voice_supported = supported;
catalog_tx
.send(Ok(vec![server_feature("realtime_conversation")]))
.unwrap();
assert!(view.pre_draw_tick(Instant::now()));
assert_eq!(view.features.len(), usize::from(supported));
snapshot_view(
if supported {
"voice_runtime_available"
} else {
"voice_runtime_unavailable"
},
&view,
);
}
}
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/experimental_features_view_tests.rs
assertion_line: 208
expression: buffer_text(&buffer)
---
Experimental features
Checked features are configured on. Some experimental features take effect
only in new tasks or after restarting the Codex server.
› [x] Server realtime_conversation Description supplied by the server.
Press space to select or enter to save
@@ -0,0 +1,14 @@
---
source: tui/src/bottom_pane/experimental_features_view_tests.rs
assertion_line: 208
expression: buffer_text(&buffer)
---
Experimental features
Checked features are configured on. Some experimental features take effect
only in new tasks or after restarting the Codex server.
No server experiments available.
No experimental features available for now
Press space to select or enter to save
@@ -1,16 +1,18 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
assertion_line: 3083
expression: popup
---
Experimental features
Checked features are configured on. Some experimental features take effect
only in new tasks or after restarting the Codex server.
› [ ] JavaScript REPL Enable a persistent Node-backed JavaScript REPL for
interactive website debugging and other inline
JavaScript execution capabilities.
[x] Shell tool Allow the model to run shell commands.
[ ] Worktrees Create isolated Git worktrees and group sessions by
repository.
› [ ] JavaScript REPL Enable a persistent Node-backed JavaScript REPL for
interactive website debugging and other inline
JavaScript execution capabilities.
[x] Shell tool Allow the model to run shell commands.
[ ] Worktrees Create isolated Git worktrees and group sessions by
repository.
[ ] Voice conversations Talk with Codex using /voice.
Press space to select or enter to save
@@ -3039,6 +3039,7 @@ async fn experimental_features_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let worktrees = Feature::Worktrees.stage();
let voice = Feature::RealtimeConversation.stage();
let features = vec![
ExperimentalFeatureItem {
key: Feature::JsRepl.key().to_string(),
@@ -3061,6 +3062,13 @@ async fn experimental_features_popup_snapshot() {
description: worktrees.experimental_menu_description().unwrap().to_string(),
enabled: false,
},
ExperimentalFeatureItem {
key: Feature::RealtimeConversation.key().to_string(),
writable: true,
name: voice.experimental_menu_name().unwrap().to_string(),
description: voice.experimental_menu_description().unwrap().to_string(),
enabled: false,
},
];
let view = ExperimentalFeaturesView::new(
features,
@@ -0,0 +1,6 @@
---
source: tui/src/tooltips.rs
assertion_line: 382
expression: "available.into_iter().filter(|tip| *tip ==\nvoice_tip).collect::<Vec<_>>().join(\"\\n\")"
---
NEW: Voice conversations can now be enabled from /experimental. Restart Codex after enabling, then use /voice.
+33 -2
View File
@@ -1,4 +1,5 @@
use codex_features::FEATURES;
use codex_features::Feature;
use codex_protocol::account::PlanType;
use lazy_static::lazy_static;
use rand::Rng;
@@ -36,14 +37,17 @@ lazy_static! {
static ref ALL_TOOLTIPS: Vec<&'static str> = {
let mut tips = Vec::new();
tips.extend(TOOLTIPS.iter().copied());
tips.extend(experimental_tooltips());
tips.extend(experimental_tooltips(
codex_realtime_webrtc::RealtimeWebrtcSession::is_supported,
));
tips
};
}
fn experimental_tooltips() -> Vec<&'static str> {
fn experimental_tooltips(voice_supported: impl Fn() -> bool) -> Vec<&'static str> {
FEATURES
.iter()
.filter(|spec| spec.id != Feature::RealtimeConversation || voice_supported())
.filter_map(|spec| spec.stage.experimental_announcement())
.collect()
}
@@ -358,6 +362,33 @@ mod tests {
use rand::SeedableRng;
use rand::rngs::StdRng;
#[test]
fn experimental_voice_tooltip_requires_runtime_support() {
let unavailable = experimental_tooltips(|| false);
let available = experimental_tooltips(|| true);
let voice_tip = FEATURES
.iter()
.find(|spec| spec.id == Feature::RealtimeConversation)
.and_then(|spec| spec.stage.experimental_announcement())
.expect("voice has an experimental announcement");
assert_eq!(
unavailable,
available
.iter()
.copied()
.filter(|tip| *tip != voice_tip)
.collect::<Vec<_>>()
);
insta::assert_snapshot!(
"experimental_voice_tooltip",
available
.into_iter()
.filter(|tip| *tip == voice_tip)
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn random_tooltip_returns_some_tip_when_available() {
let mut rng = StdRng::seed_from_u64(42);