Discover TUI experimental features from the server (#42425)

## What changed

- Load the `/experimental` catalog from the running server for the active thread, with loading, empty, and failure states in the popup.
- Show beta features in server order and preserve server-reported names, descriptions, and configured values. Mark features as read-only when the local config writer cannot safely update them.
- Persist only changed, writable feature flags. Bound discovery with page-size and page-count limits, repeated-cursor detection, deduplication, cancellation, and a timeout.

## Testing

- Cover paginated discovery, malformed or unresponsive servers, popup cancellation, read-only controls, selective writes, and the new popup states.

GitOrigin-RevId: 1f49eb961c8ab220a466e15f032c1dc61ed86d38
This commit is contained in:
Eric Traut
2026-09-03 02:49:36 +00:00
committed by copyberry
parent 38ba8cdceb
commit cac96cd7b1
20 changed files with 630 additions and 71 deletions
+7
View File
@@ -2380,6 +2380,13 @@ impl App {
.add_error_message(format!("Failed to save approvals reviewer: {err}"));
}
}
AppEvent::FetchExperimentalFeatures { thread_id, response_tx } => {
crate::experimental_features::fetch(
app_server.request_handle(),
thread_id,
response_tx,
);
}
AppEvent::UpdateFeatureFlags { updates } => {
self.update_feature_flags(app_server, updates).await;
}
+8
View File
@@ -1148,6 +1148,14 @@ pub(crate) enum AppEvent {
/// Update the current approvals reviewer in the running app and widget.
UpdateApprovalsReviewer(ApprovalsReviewer),
/// Discover experimental features for the requesting popup only.
FetchExperimentalFeatures {
thread_id: ThreadId,
response_tx: tokio::sync::oneshot::Sender<
Result<Vec<codex_app_server_protocol::ExperimentalFeature>, String>,
>,
},
/// Update feature flags and persist them to the top-level config.
UpdateFeatureFlags {
updates: Vec<(Feature, bool)>,
@@ -1,3 +1,13 @@
//! Experimental controls with popup-owned discovery and configured enablement.
//! Only changed, supported controls are submitted to the existing writer.
use codex_app_server_protocol::ExperimentalFeature;
use codex_app_server_protocol::ExperimentalFeatureStage;
use codex_features::FEATURES;
use std::time::Duration;
use std::time::Instant;
use tokio::sync::oneshot;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -33,7 +43,7 @@ use super::selection_popup_common::measure_rows_height;
use super::selection_popup_common::render_rows;
pub(crate) struct ExperimentalFeatureItem {
pub feature: Feature,
pub feature: Option<Feature>,
pub name: String,
pub description: String,
pub enabled: bool,
@@ -41,10 +51,12 @@ pub(crate) struct ExperimentalFeatureItem {
pub(crate) struct ExperimentalFeaturesView {
features: Vec<ExperimentalFeatureItem>,
initial_enabled: Vec<bool>,
catalog_rx: Option<oneshot::Receiver<Result<Vec<ExperimentalFeature>, String>>>,
discovery_status: &'static str,
state: ScrollState,
complete: bool,
app_event_tx: AppEventSender,
header: Box<dyn Renderable>,
footer_hint: Line<'static>,
keymap: ListKeymap,
}
@@ -52,21 +64,22 @@ pub(crate) struct ExperimentalFeaturesView {
impl ExperimentalFeaturesView {
pub(crate) fn new(
features: Vec<ExperimentalFeatureItem>,
catalog_rx: Option<oneshot::Receiver<Result<Vec<ExperimentalFeature>, String>>>,
app_event_tx: AppEventSender,
keymap: ListKeymap,
) -> Self {
let mut header = ColumnRenderable::new();
header.push(Line::from("Experimental features".bold()));
header.push(Line::from(
"Toggle experimental features. Changes are saved to config.toml.".dim(),
));
let mut view = Self {
discovery_status: if catalog_rx.is_some() {
"Loading server experiments…"
} else {
""
},
catalog_rx,
initial_enabled: features.iter().map(|item| item.enabled).collect(),
features,
state: ScrollState::new(),
complete: false,
app_event_tx,
header: Box::new(header),
footer_hint: experimental_popup_hint_line(&keymap),
keymap,
};
@@ -74,6 +87,20 @@ impl ExperimentalFeaturesView {
view
}
fn header(&self, width: u16) -> impl Renderable {
let mut header = ColumnRenderable::new();
header.push(Line::from("Experimental features".bold()));
for text in [
"Checked features are configured on. Some experimental features take effect only in new tasks or after restarting the Codex server.",
self.discovery_status,
].into_iter().filter(|text| !text.is_empty()) {
for line in textwrap::wrap(text, usize::from(width.max(1))) {
header.push(Line::from(line.into_owned().dim()));
}
}
header
}
fn initialize_selection(&mut self) {
if self.visible_len() == 0 {
self.state.selected_idx = None;
@@ -96,10 +123,16 @@ impl ExperimentalFeaturesView {
' '
};
let marker = if item.enabled { 'x' } else { ' ' };
let name = format!("{prefix} [{marker}] {}", item.name);
let read_only = if item.feature.is_none() {
" (read-only)"
} else {
""
};
let name = format!("{prefix} [{marker}] {}{read_only}", item.name);
rows.push(GenericDisplayRow {
name,
description: Some(item.description.clone()),
is_disabled: item.feature.is_none(),
..Default::default()
});
}
@@ -154,7 +187,9 @@ impl ExperimentalFeaturesView {
return;
};
if let Some(item) = self.features.get_mut(selected_idx) {
if let Some(item) = self.features.get_mut(selected_idx)
&& item.feature.is_some()
{
item.enabled = !item.enabled;
}
}
@@ -165,6 +200,65 @@ impl ExperimentalFeaturesView {
}
impl BottomPaneView for ExperimentalFeaturesView {
fn pre_draw_tick(&mut self, _now: Instant) -> bool {
let Some(receiver) = self.catalog_rx.as_mut() else {
return false;
};
let result = match receiver.try_recv() {
Ok(result) => result,
Err(oneshot::error::TryRecvError::Empty) => return false,
Err(oneshot::error::TryRecvError::Closed) => {
Err("Discovery was interrupted".to_string())
}
};
self.catalog_rx = None;
match result {
Ok(features) => {
let mut count = 0;
for feature in features {
if feature.stage != ExperimentalFeatureStage::Beta {
continue;
}
// The existing writer uses local defaults and Feature IDs. A new
// server control or a changed default must wait for config-write migration.
let writable = FEATURES
.iter()
.find(|spec| {
spec.key == feature.name
&& spec.stage.experimental_menu_name().is_some()
&& spec.default_enabled == feature.default_enabled
})
.map(|spec| spec.id);
self.initial_enabled.push(feature.enabled);
self.features.push(ExperimentalFeatureItem {
feature: writable,
name: feature.display_name.unwrap_or(feature.name),
description: feature.description.unwrap_or_default(),
enabled: feature.enabled,
});
count += 1;
}
self.discovery_status = if count == 0 {
"No server experiments available."
} else {
""
};
self.initialize_selection();
}
Err(error) => {
tracing::warn!(%error, "experimental feature discovery failed");
self.discovery_status = "Server experiments unavailable. Reopen /experimental to retry; restart this Codex client if requests remain unanswered.";
}
}
true
}
fn next_frame_delay(&self) -> Option<Duration> {
self.catalog_rx
.as_ref()
.map(|_| Duration::from_millis(/*millis*/ 100))
}
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
}
@@ -196,13 +290,14 @@ impl BottomPaneView for ExperimentalFeaturesView {
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
// Save the updates
if !self.features.is_empty() {
let updates = self
.features
.iter()
.map(|item| (item.feature, item.enabled))
.collect();
let updates: Vec<_> = self
.features
.iter()
.zip(&self.initial_enabled)
.filter(|(item, initial)| item.enabled != **initial)
.filter_map(|(item, _)| item.feature.map(|feature| (feature, item.enabled)))
.collect();
if !updates.is_empty() {
self.app_event_tx
.send(AppEvent::UpdateFeatureFlags { updates });
}
@@ -225,9 +320,8 @@ impl Renderable for ExperimentalFeaturesView {
.style(user_message_style())
.render(content_area, buf);
let header_height = self
.header
.desired_height(content_area.width.saturating_sub(4));
let header = self.header(content_area.width.saturating_sub(4));
let header_height = header.desired_height(content_area.width.saturating_sub(4));
let rows = self.build_rows();
let rows_width = Self::rows_width(content_area.width);
let rows_height = measure_rows_height(
@@ -243,9 +337,9 @@ impl Renderable for ExperimentalFeaturesView {
])
.areas(content_area.inset(Insets::vh(/*v*/ 1, /*h*/ 2)));
self.header.render(header_area, buf);
header.render(header_area, buf);
if list_area.height > 0 {
if list_area.height > 0 && (!rows.is_empty() || self.catalog_rx.is_none()) {
let render_area = Rect {
x: list_area.x.saturating_sub(2),
y: list_area.y,
@@ -281,7 +375,9 @@ impl Renderable for ExperimentalFeaturesView {
rows_width.saturating_add(1),
);
let mut height = self.header.desired_height(width.saturating_sub(4));
let mut height = self
.header(width.saturating_sub(4))
.desired_height(width.saturating_sub(4));
height = height.saturating_add(rows_height + 3);
height.saturating_add(1)
}
@@ -294,11 +390,11 @@ fn experimental_popup_hint_line(keymap: &ListKeymap) -> Line<'static> {
" to select".into(),
];
if let Some(accept) = keymap.primary_hint(ListAction::Accept) {
spans.extend([
" or ".into(),
accept.into(),
" to save for next conversation".into(),
]);
spans.extend([" or ".into(), accept.into(), " to save".into()]);
}
Line::from(spans)
}
#[cfg(test)]
#[path = "experimental_features_view_tests.rs"]
mod tests;
@@ -0,0 +1,148 @@
use super::*;
use pretty_assertions::assert_eq;
fn server_feature(name: &str) -> ExperimentalFeature {
ExperimentalFeature {
name: name.to_string(),
stage: ExperimentalFeatureStage::Beta,
display_name: Some(format!("Server {name}")),
description: Some("Description supplied by the server.".to_string()),
announcement: None,
enabled: true,
default_enabled: false,
}
}
#[test]
fn experimental_features_discovery_preserves_server_order_and_limits_writes() {
let (app_tx, mut app_rx) = tokio::sync::mpsc::unbounded_channel();
let (catalog_tx, catalog_rx) = oneshot::channel();
let mut view = ExperimentalFeaturesView::new(
Vec::new(),
Some(catalog_rx),
AppEventSender::new(app_tx),
crate::keymap::RuntimeKeymap::defaults().list,
);
view.toggle_selected();
let mut stable = server_feature("stable_feature");
stable.stage = ExperimentalFeatureStage::Stable;
catalog_tx
.send(Ok(vec![
server_feature("network_proxy"),
server_feature("future_feature"),
stable,
server_feature("prevent_idle_sleep"),
]))
.unwrap();
assert!(view.pre_draw_tick(Instant::now()));
assert_eq!(
view.features
.iter()
.map(|item| (item.feature, item.enabled))
.collect::<Vec<_>>(),
vec![
(Some(Feature::NetworkProxy), true),
(None, true),
(Some(Feature::PreventIdleSleep), true),
]
);
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
/*width*/ 100,
view.desired_height(/*width*/ 100),
);
let mut buffer = Buffer::empty(area);
view.render(area, &mut buffer);
insta::assert_snapshot!(
"experimental_features_server_discovery",
buffer_text(&buffer)
);
// Read-only entries retain their configured checkmark when Space is pressed.
view.state.selected_idx = Some(1);
view.toggle_selected();
assert!(view.features[1].enabled);
view.state.selected_idx = Some(0);
view.toggle_selected();
view.state.selected_idx = Some(2);
view.toggle_selected();
view.on_ctrl_c();
let AppEvent::UpdateFeatureFlags { updates } = app_rx.try_recv().unwrap() else {
panic!("expected feature edits");
};
assert_eq!(
updates,
vec![
(Feature::NetworkProxy, false),
(Feature::PreventIdleSleep, false)
]
);
}
#[test]
fn experimental_features_empty_error_and_cancel_remain_usable() {
let mut changed_default = server_feature("network_proxy");
changed_default.default_enabled = true;
for (snapshot, result) in [
("experimental_features_empty", Ok(Vec::new())),
(
"experimental_features_unavailable",
Err("method not found".to_string()),
),
(
"experimental_features_changed_default",
Ok(vec![changed_default]),
),
] {
let (app_tx, mut app_rx) = tokio::sync::mpsc::unbounded_channel();
let (catalog_tx, catalog_rx) = oneshot::channel();
let mut view = ExperimentalFeaturesView::new(
Vec::new(),
Some(catalog_rx),
AppEventSender::new(app_tx),
crate::keymap::RuntimeKeymap::defaults().list,
);
assert!(view.next_frame_delay().is_some());
catalog_tx.send(result).unwrap();
assert!(view.pre_draw_tick(Instant::now()));
assert_eq!(view.next_frame_delay(), None);
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
/*width*/ 80,
view.desired_height(/*width*/ 80),
);
let mut buffer = Buffer::empty(area);
view.render(area, &mut buffer);
insta::assert_snapshot!(snapshot, buffer_text(&buffer));
view.toggle_selected();
view.on_ctrl_c();
assert!(view.is_complete());
assert!(app_rx.try_recv().is_err());
}
let (app_tx, _app_rx) = tokio::sync::mpsc::unbounded_channel();
let (catalog_tx, catalog_rx) = oneshot::channel();
let view = ExperimentalFeaturesView::new(
Vec::new(),
Some(catalog_rx),
AppEventSender::new(app_tx),
crate::keymap::RuntimeKeymap::defaults().list,
);
drop(view);
assert!(catalog_tx.send(Ok(Vec::new())).is_err());
}
fn buffer_text(buffer: &Buffer) -> String {
buffer
.content
.chunks(usize::from(buffer.area.width))
.map(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n")
}
+1
View File
@@ -209,6 +209,7 @@ pub(crate) use chat_composer_history::HistoryEntry;
use crate::status_indicator_widget::StatusDetailsCapitalization;
use crate::status_indicator_widget::StatusIndicatorWidget;
#[cfg(test)]
pub(crate) use experimental_features_view::ExperimentalFeatureItem;
pub(crate) use experimental_features_view::ExperimentalFeaturesView;
pub(crate) use list_selection_view::SELECTION_TOGGLE_BLOCKED_PREFIX;
@@ -0,0 +1,12 @@
---
source: tui/src/bottom_pane/experimental_features_view_tests.rs
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 network_proxy (read-only) Description supplied by the server.
Press space to select or enter to save
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/experimental_features_view_tests.rs
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
@@ -0,0 +1,14 @@
---
source: tui/src/bottom_pane/experimental_features_view_tests.rs
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 network_proxy Description supplied by the server.
[x] Server future_feature (read-only) Description supplied by the server.
[x] Server prevent_idle_sleep 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
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.
Server experiments unavailable. Reopen /experimental to retry; restart this
Codex client if requests remain unanswered.
No experimental features available for now
Press space to select or enter to save
-2
View File
@@ -122,7 +122,6 @@ use codex_config::types::ApprovalsReviewer;
use codex_config::types::Notifications;
use codex_config::types::WindowsSandboxModeToml;
use codex_connectors::AppInfo;
use codex_features::FEATURES;
use codex_features::Feature;
#[cfg(test)]
use codex_git_utils::CommitLogEntry;
@@ -279,7 +278,6 @@ use crate::bottom_pane::CollaborationModeIndicator;
use crate::bottom_pane::ColumnWidthMode;
use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED;
use crate::bottom_pane::ExecApprovalRequest;
use crate::bottom_pane::ExperimentalFeatureItem;
use crate::bottom_pane::ExperimentalFeaturesView;
use crate::bottom_pane::GoalStatusIndicator;
use crate::bottom_pane::HistoryEntry;
+14 -15
View File
@@ -87,22 +87,21 @@ impl ChatWidget {
}
pub(crate) fn open_experimental_popup(&mut self) {
let features: Vec<ExperimentalFeatureItem> = FEATURES
.iter()
.filter_map(|spec| {
let name = spec.stage.experimental_menu_name()?;
let description = spec.stage.experimental_menu_description()?;
Some(ExperimentalFeatureItem {
feature: spec.id,
name: name.to_string(),
description: description.to_string(),
enabled: self.config.features.enabled(spec.id),
})
})
.collect();
let Some(thread_id) = self.thread_id() else {
self.add_info_message(
"Experimental features are unavailable until startup completes.".to_string(),
/*hint*/ None,
);
return;
};
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
self.app_event_tx.send(AppEvent::FetchExperimentalFeatures {
thread_id,
response_tx,
});
let view = ExperimentalFeaturesView::new(
features,
Vec::new(),
Some(response_rx),
self.app_event_tx.clone(),
self.bottom_pane.list_keymap(),
);
@@ -0,0 +1,12 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: "render_bottom_popup(&chat, 80)"
---
Experimental features
Checked features are configured on. Some experimental features take effect
only in new tasks or after restarting the Codex server.
Loading server experiments…
Press space to select or enter to save
@@ -3,11 +3,12 @@ source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Experimental features
Toggle experimental features. Changes are saved to config.toml.
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.
Press space to select or enter to save for next conversation
Press space to select or enter to save
@@ -3,8 +3,9 @@ source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Experimental features
Toggle experimental features. Changes are saved to config.toml.
Checked features are configured on. Some experimental features take effect
only in new tasks or after restarting the Codex server.
› [x] Shell tool Allow the model to run shell commands.
Press space to select or ctrl + x enter to save for next conversation
Press space to select or ctrl + x enter to save
-1
View File
@@ -122,7 +122,6 @@ pub(super) use codex_config::types::ApprovalsReviewer;
pub(super) use codex_config::types::Notifications;
pub(super) use codex_config::types::WindowsSandboxModeToml;
pub(super) use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
pub(super) use codex_features::FEATURES;
pub(super) use codex_features::Feature;
pub(super) use codex_git_utils::CommitLogEntry;
pub(super) use codex_models_manager::test_support::construct_model_info_offline_for_tests;
@@ -1,5 +1,6 @@
use super::*;
use crate::app_event::ConnectorsSnapshot;
use crate::bottom_pane::ExperimentalFeatureItem;
use crate::chatwidget::connectors::ConnectorsCacheState;
use codex_app_server_protocol::HookErrorInfo;
use codex_app_server_protocol::HooksListEntry;
@@ -11,7 +12,6 @@ use codex_app_server_protocol::PluginShareContext;
use codex_app_server_protocol::PluginShareDiscoverability;
use codex_app_server_protocol::PluginSource;
use codex_connectors::AppInfo;
use codex_features::Stage;
use pretty_assertions::assert_eq;
#[tokio::test]
@@ -3000,13 +3000,13 @@ async fn experimental_features_popup_snapshot() {
let features = vec![
ExperimentalFeatureItem {
feature: Feature::JsRepl,
feature: Some(Feature::JsRepl),
name: "JavaScript REPL".to_string(),
description: "Enable a persistent Node-backed JavaScript REPL for interactive website debugging and other inline JavaScript execution capabilities.".to_string(),
enabled: false,
},
ExperimentalFeatureItem {
feature: Feature::ShellTool,
feature: Some(Feature::ShellTool),
name: "Shell tool".to_string(),
description: "Allow the model to run shell commands.".to_string(),
enabled: true,
@@ -3014,6 +3014,7 @@ async fn experimental_features_popup_snapshot() {
];
let view = ExperimentalFeaturesView::new(
features,
/*catalog_rx*/ None,
chat.app_event_tx.clone(),
crate::keymap::RuntimeKeymap::defaults().list,
);
@@ -3030,11 +3031,12 @@ async fn experimental_features_popup_snapshot() {
.expect("valid experimental-feature chord");
let view = ExperimentalFeaturesView::new(
vec![ExperimentalFeatureItem {
feature: Feature::ShellTool,
feature: Some(Feature::ShellTool),
name: "Shell tool".to_string(),
description: "Allow the model to run shell commands.".to_string(),
enabled: true,
}],
/*catalog_rx*/ None,
chat.app_event_tx.clone(),
keymap.list,
);
@@ -3050,11 +3052,12 @@ async fn experimental_features_toggle_saves_on_exit() {
let expected_feature = Feature::JsRepl;
let view = ExperimentalFeaturesView::new(
vec![ExperimentalFeatureItem {
feature: expected_feature,
feature: Some(expected_feature),
name: "JavaScript REPL".to_string(),
description: "Enable a persistent Node-backed JavaScript REPL for interactive website debugging and other inline JavaScript execution capabilities.".to_string(),
enabled: false,
}],
/*catalog_rx*/ None,
chat.app_event_tx.clone(),
crate::keymap::RuntimeKeymap::defaults().list,
);
@@ -3085,23 +3088,29 @@ async fn experimental_features_toggle_saves_on_exit() {
}
#[tokio::test]
async fn experimental_popup_omits_stable_guardian_approval() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let guardian_stage = FEATURES
.iter()
.find(|spec| spec.id == Feature::GuardianApproval)
.map(|spec| spec.stage)
.expect("expected guardian approval feature metadata");
assert_eq!(guardian_stage, Stage::Stable);
async fn experimental_popup_loading_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.open_experimental_popup();
let popup = render_bottom_popup(&chat, /*width*/ 120);
assert!(
!popup.contains("Auto-review"),
"expected stable auto-review feature to be omitted from experimental popup, got:\n{popup}"
assert!(!chat.has_active_view());
let cell = assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(cell)) => cell);
insta::assert_snapshot!(
"experimental_features_startup",
lines_to_single_string(&cell.display_lines(/*width*/ 80))
);
assert!(rx.try_recv().is_err());
chat.thread_id = Some(ThreadId::new());
chat.open_experimental_popup();
let AppEvent::FetchExperimentalFeatures { response_tx, .. } = rx.try_recv().unwrap() else {
panic!("expected experimental discovery request");
};
assert_chatwidget_snapshot!(
"experimental_features_loading",
render_bottom_popup(&chat, /*width*/ 80)
);
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(response_tx.send(Ok(Vec::new())).is_err());
assert!(!chat.has_active_view());
assert!(rx.try_recv().is_err());
}
#[tokio::test]
@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: lines_to_single_string(&cell.display_lines(80))
---
• Experimental features are unavailable until startup completes.
+74
View File
@@ -0,0 +1,74 @@
//! Bounded, popup-scoped experimental-feature discovery through the existing RPC.
//! Dropping a popup stops pagination. One reserved request ID bounds abandoned
//! requests until the server replies or the connection closes.
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ExperimentalFeature;
use codex_app_server_protocol::ExperimentalFeatureListParams;
use codex_app_server_protocol::ExperimentalFeatureListResponse;
use codex_app_server_protocol::RequestId;
use codex_protocol::ThreadId;
use std::collections::HashSet;
use std::time::Duration;
use tokio::sync::oneshot;
pub(crate) fn fetch(
request_handle: AppServerRequestHandle,
thread_id: ThreadId,
mut response_tx: oneshot::Sender<Result<Vec<ExperimentalFeature>, String>>,
) {
tokio::spawn(async move {
let discovery = async {
let mut features = Vec::new();
let mut cursor = None;
let mut cursors = HashSet::new();
let mut names = HashSet::new();
for _ in 0..10 {
let response = request_handle
.request_typed::<ExperimentalFeatureListResponse>(
ClientRequest::ExperimentalFeatureList {
// The client rejects duplicate pending IDs, bounding unanswered
// requests across popup cancellation and timeout/retry cycles.
request_id: RequestId::String("tui-experimental-features".to_string()),
params: ExperimentalFeatureListParams {
cursor,
limit: Some(100),
thread_id: Some(thread_id.to_string()),
},
},
)
.await
.map_err(|_| "Experimental feature request failed".to_string())?;
if response.data.len() > 100 {
return Err("Experimental feature page exceeds requested limit".to_string());
}
features.extend(
response
.data
.into_iter()
.filter(|feature| names.insert(feature.name.clone())),
);
cursor = response.next_cursor;
let Some(next) = cursor.as_ref() else {
return Ok(features);
};
if !cursors.insert(next.clone()) {
return Err("Experimental feature pagination repeated a cursor".to_string());
}
}
Err("Experimental feature discovery exceeded 10 pages".to_string())
};
tokio::select! {
_ = response_tx.closed() => {},
result = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), discovery) => {
let result = result.unwrap_or_else(|_| Err("Experimental feature discovery timed out".to_string()));
let _ = response_tx.send(result);
}
}
});
}
#[cfg(test)]
#[path = "experimental_features_tests.rs"]
mod tests;
@@ -0,0 +1,147 @@
use super::*;
use codex_app_server_client::RemoteAppServerClient;
use codex_app_server_client::RemoteAppServerConnectArgs;
use codex_app_server_client::RemoteAppServerEndpoint;
use codex_app_server_protocol::JSONRPCMessage;
use futures::SinkExt;
use futures::StreamExt;
use pretty_assertions::assert_eq;
use serde_json::json;
use tokio_tungstenite::tungstenite::Message;
#[tokio::test]
async fn experimental_features_rpc_paginates_thread_config_and_bounds_bad_servers() {
for scenario in ["pages", "cycle", "limit", "error", "timeout"] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://{}", listener.local_addr().unwrap());
let thread_id = ThreadId::new();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap();
let mut page = 0;
while let Some(Ok(Message::Text(text))) = socket.next().await {
let JSONRPCMessage::Request(request) = serde_json::from_str(&text).unwrap() else {
continue;
};
let result = if request.method == "initialize" {
json!({"userAgent": "experimental-test"})
} else {
assert_eq!(request.method, "experimentalFeature/list");
assert_eq!(
request.params,
Some(json!({
"cursor": if page == 0 { None } else { Some(page.to_string()) },
"limit": 100, "threadId": thread_id.to_string()
}))
);
page += 1;
if scenario == "timeout" {
// Leave the connection open without answering discovery.
continue;
}
if scenario == "error" {
socket.send(Message::Text(json!({"id": request.id, "error": {"code": -32601, "message": "method not found", "data": "private wire content"}}).to_string().into())).await.unwrap();
continue;
}
json!({"data": [{
"name": format!("feature-{page}"), "stage": "beta", "displayName": "Server experiment",
"description": "Server description", "announcement": null,
"enabled": true, "defaultEnabled": false
}], "nextCursor": match scenario {
"pages" if page == 2 => None,
"cycle" => Some("1".to_string()),
_ => Some(page.to_string()),
}})
};
socket
.send(Message::Text(
json!({"id": request.id, "result": result})
.to_string()
.into(),
))
.await
.unwrap();
}
page
});
let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
endpoint: RemoteAppServerEndpoint::WebSocket {
websocket_url: url,
auth_token: None,
},
client_name: "experimental-test".to_string(),
client_version: "0.0.0".to_string(),
experimental_api: true,
mcp_server_openai_form_elicitation: false,
opt_out_notification_methods: Vec::new(),
channel_capacity: 8,
})
.await
.unwrap();
let (tx, rx) = oneshot::channel();
fetch(
AppServerRequestHandle::Remote(client.request_handle()),
thread_id,
tx,
);
let result = tokio::time::timeout(Duration::from_secs(/*secs*/ 10), rx)
.await
.unwrap()
.unwrap();
if scenario == "pages" {
let features = result.unwrap();
assert_eq!(
features,
["feature-1", "feature-2"]
.into_iter()
.map(|name| ExperimentalFeature {
name: name.to_string(),
stage: codex_app_server_protocol::ExperimentalFeatureStage::Beta,
display_name: Some("Server experiment".to_string()),
description: Some("Server description".to_string()),
announcement: None,
enabled: true,
default_enabled: false,
})
.collect::<Vec<_>>()
);
} else {
let error = result.unwrap_err();
assert_eq!(
error,
match scenario {
"cycle" => "Experimental feature pagination repeated a cursor",
"limit" => "Experimental feature discovery exceeded 10 pages",
"error" => "Experimental feature request failed",
"timeout" => "Experimental feature discovery timed out",
_ => unreachable!(),
}
);
}
if scenario == "timeout" {
// Reopening while the abandoned request is still unanswered cannot
// accumulate more pending requests or forward more calls to the server.
for _ in 0..3 {
let (tx, rx) = oneshot::channel();
fetch(
AppServerRequestHandle::Remote(client.request_handle()),
thread_id,
tx,
);
assert_eq!(
rx.await.unwrap(),
Err("Experimental feature request failed".to_string())
);
}
}
client.shutdown().await.unwrap();
assert_eq!(
server.await.unwrap(),
match scenario {
"pages" | "cycle" => 2,
"limit" => 10,
_ => 1,
}
);
}
}
+1
View File
@@ -119,6 +119,7 @@ mod collaboration_modes;
mod color;
mod config_update;
pub(crate) mod custom_terminal;
mod experimental_features;
mod pets;
pub use custom_terminal::Terminal;
mod auto_review_denials;