Discover TUI collaboration modes from the app server (#42401)

## What changed

- Fetch `collaborationMode/list` during TUI startup and reconnect, and use the
  returned catalog for visible collaboration-mode selection.
- Keep collaboration-mode discovery optional with a two-second timeout so an
  unavailable, invalid, or unsupported response does not block bootstrap.
- Preserve discovered modes when the model catalog refreshes, clear restored
  developer-instruction overrides when selecting a server mode, and retain a
  `/plan` draft when plan mode is unavailable.

## Testing

- Cover optional discovery failures and catalog refresh on reconnect.
- Cover server mode selection, model refreshes, restored settings, and
  unavailable plan mode.

GitOrigin-RevId: a6e35c14aab2e38526606164091ac64c500151bb
This commit is contained in:
Eric Traut
2026-09-02 23:14:26 +00:00
committed by copyberry
parent 54a4077c8b
commit d4dc882998
15 changed files with 292 additions and 22 deletions

View File

@@ -258,7 +258,10 @@ impl App {
)));
self.file_search =
FileSearchManager::new(self.config.cwd.to_path_buf(), self.app_event_tx.clone());
self.model_catalog = Arc::new(ModelCatalog::new(bootstrap.available_models));
self.model_catalog = Arc::new(
ModelCatalog::new(bootstrap.available_models)
.with_collaboration_modes(bootstrap.collaboration_modes),
);
self.pending_app_server_requests.clear();
self.pending_primary_events.clear();
self.pending_plugin_enabled_writes.clear();

View File

@@ -212,7 +212,10 @@ impl App {
{
tracing::warn!(%error, "TUI task delegation is unavailable without its MCP server");
}
let model_catalog = Arc::new(ModelCatalog::new(available_models.clone()));
let model_catalog = Arc::new(
ModelCatalog::new(available_models.clone())
.with_collaboration_modes(bootstrap.collaboration_modes),
);
let feedback_audience = bootstrap.feedback_audience;
let auth_mode = bootstrap.auth_mode;
let has_chatgpt_account = bootstrap.has_chatgpt_account;

View File

@@ -53,6 +53,7 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() ->
/*developer_instructions*/ None,
);
let expected_submitted_mode = expected_mode.clone();
assert!(!app.model_catalog.collaboration_modes.is_empty());
if edit_offline {
app.chat_widget
.restore_user_message_to_composer("unacknowledged prompt".into());
@@ -260,6 +261,13 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() ->
app.chat_widget.effective_collaboration_mode(),
expected_mode
);
assert!(app.model_catalog.collaboration_modes.is_empty());
assert!(
app.chat_widget
.model_catalog()
.collaboration_modes
.is_empty()
);
assert_eq!(
app.chat_widget.composer_text_with_pending(),
if edit_offline {

View File

@@ -269,6 +269,7 @@ where
Some(json!({"result": {"account": null, "requiresOpenaiAuth": false}}))
}
"model/list" => Some(json!({"result": {"data": [], "nextCursor": null}})),
"collaborationMode/list" => Some(json!({"result": {"data": []}})),
"configRequirements/read" => Some(json!({"result": {"requirements": null}})),
_ => respond(request).await,
};

View File

@@ -42,7 +42,7 @@ pub(super) fn configure_reserve_catalog(app: &mut App) {
let mut high = original.supported_reasoning_efforts[0].clone();
high.effort = ReasoningEffortConfig::High;
original.supported_reasoning_efforts.push(high);
app.model_catalog = Arc::new(ModelCatalog::new(models));
Arc::make_mut(&mut app.model_catalog).models = models;
let mut tui = crate::tui::test_support::make_test_tui().unwrap();
let mut init = app.chatwidget_init_for_forked_or_resumed_thread(
&mut tui,

View File

@@ -8,6 +8,10 @@ mod history;
mod models;
mod rollout_history;
#[cfg(test)]
#[path = "app_server_session/collaboration_catalog_tests.rs"]
mod collaboration_catalog_tests;
pub(crate) use history::HISTORY_ITEM_PAGE_LIMIT;
pub(crate) use history::HISTORY_ITEM_SCAN_LIMIT;
pub(crate) use history::HistoryHydrationScope;
@@ -290,6 +294,7 @@ pub(crate) struct AppServerBootstrap {
pub(crate) feedback_audience: FeedbackAudience,
pub(crate) has_chatgpt_account: bool,
pub(crate) available_models: Vec<ModelPreset>,
pub(crate) collaboration_modes: Vec<codex_protocol::config_types::CollaborationModeMask>,
}
pub(crate) struct AppServerSession {
@@ -560,7 +565,7 @@ impl AppServerSession {
// requirements together so an uncached model fetch can overlap both config requests.
let model_request_id = self.next_request_id();
let requirements_request_id = self.next_request_id();
let (models, requirements) = tokio::try_join!(
let (models, requirements, collaboration_modes) = tokio::try_join!(
async {
self.client
.request_typed::<ModelListResponse>(ClientRequest::ModelList {
@@ -592,6 +597,7 @@ impl AppServerSession {
)
})
},
async { Ok(crate::collaboration_modes::list(self.request_handle()).await) },
)?;
self.managed_new_thread_defaults = requirements
.requirements
@@ -669,6 +675,7 @@ impl AppServerSession {
feedback_audience,
has_chatgpt_account,
available_models,
collaboration_modes,
})
}

View File

@@ -0,0 +1,92 @@
//! Optional catalog discovery uses the public RPC and never prevents ordinary bootstrap.
use super::*;
use crate::legacy_core::config::ConfigBuilder;
use codex_app_server_protocol::JSONRPCMessage;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::ModeKind;
use codex_protocol::openai_models::ReasoningEffort;
use futures::SinkExt;
use futures::StreamExt;
use pretty_assertions::assert_eq;
use serde_json::json;
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::Message;
#[tokio::test]
async fn collaboration_catalog_is_optional_and_refetched_on_bootstrap() -> Result<()> {
for initial_reply in [
Some(json!({"error": {"code": -32601, "message": "Method not found"}})),
Some(json!({"error": {"code": -32600, "message": "Experimental API unavailable"}})),
Some(json!({"error": {"code": -32603, "message": "Discovery failed"}})),
Some(json!({"result": {"data": []}})),
Some(json!({"result": {"data": "invalid"}})),
None,
] {
let codex_home = tempfile::tempdir()?;
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await?;
config.model = Some("task-model".into());
let listener = TcpListener::bind("127.0.0.1:0").await?;
let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?;
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await?;
let mut socket = tokio_tungstenite::accept_async(stream).await?;
let mut catalog_requests = 0;
while let Some(Ok(Message::Text(text))) = socket.next().await {
let JSONRPCMessage::Request(request) = serde_json::from_str(&text)? else {
continue;
};
let mut reply = match request.method.as_str() {
"initialize" => json!({"result": {"userAgent": "catalog-test/1.0.0"}}),
"account/read" => {
json!({"result": {"account": null, "requiresOpenaiAuth": false}})
}
"model/list" => json!({"result": {"data": [], "nextCursor": null}}),
"configRequirements/read" => json!({"result": {"requirements": null}}),
"collaborationMode/list" => {
assert_eq!(request.params, Some(json!({})));
catalog_requests += 1;
if catalog_requests == 1 {
let Some(reply) = initial_reply.clone() else {
continue;
};
reply
} else {
json!({"result": {"data": [{"name": "Server plan", "mode": "plan", "model": "server-model", "reasoning_effort": "high"}]}})
}
}
method => panic!("unexpected request: {method}"),
};
reply["id"] = json!(request.id);
socket.send(Message::Text(reply.to_string().into())).await?;
}
Ok::<_, color_eyre::Report>(catalog_requests)
});
let mut session = AppServerSession::new(
crate::connect_remote_app_server(endpoint).await?,
ThreadParamsMode::Remote,
);
let initial = session.bootstrap(&config).await?;
assert_eq!(
(initial.default_model, initial.collaboration_modes),
("task-model".into(), vec![])
);
let refreshed = session.bootstrap(&config).await?;
assert_eq!(
refreshed.collaboration_modes,
vec![CollaborationModeMask {
name: "Server plan".into(),
mode: Some(ModeKind::Plan),
model: Some("server-model".into()),
reasoning_effort: Some(Some(ReasoningEffort::High)),
developer_instructions: Some(None),
}]
);
session.shutdown().await?;
assert_eq!(server.await??, 2);
}
Ok(())
}

View File

@@ -36,7 +36,7 @@ impl ChatWidget {
{
return false;
}
self.model_catalog = Arc::new(ModelCatalog::new(presets));
Arc::make_mut(&mut self.model_catalog).models = presets;
self.refresh_effective_service_tier();
self.refresh_model_dependent_surfaces();
true

View File

@@ -801,9 +801,7 @@ impl ChatWidget {
});
}
SlashCommand::Plan if !trimmed.is_empty() => {
if !self.apply_plan_slash_command() {
return;
}
let plan_available = self.apply_plan_slash_command();
let mut user_message = self.prepared_inline_user_message(
args,
text_elements,
@@ -812,7 +810,8 @@ impl ChatWidget {
mention_bindings,
source,
);
if !self.is_session_configured()
if !plan_available
|| !self.is_session_configured()
|| self.current_model().trim().is_empty()
|| (!self.current_model_supports_images()
&& (!user_message.local_images.is_empty()
@@ -825,6 +824,10 @@ impl ChatWidget {
element.byte_range.end += PLAN_PREFIX.len();
}
}
if !plan_available {
self.restore_user_message_to_composer(user_message);
return;
}
if self.is_session_configured() {
self.reasoning_buffer.clear();
self.reasoning_header = None;

View File

@@ -235,6 +235,8 @@ mod app_server;
mod approval_requests;
#[path = "tests/backend_banners_tests.rs"]
mod backend_banners_tests;
#[path = "tests/collaboration_catalog_tests.rs"]
mod collaboration_catalog_tests;
#[path = "tests/compaction_tests.rs"]
mod compaction_tests;
mod composer_submission;

View File

@@ -0,0 +1,93 @@
//! Server choices are independent of model refresh and restored task settings.
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn collaboration_catalog_selection_clears_restored_prompt_and_survives_model_refresh() {
let (mut chat, _events, _ops) = make_chatwidget_manual(Some("gpt-5.2")).await;
let restored = CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model: "gpt-5.2".into(),
reasoning_effort: Some(ReasoningEffortConfig::Low),
developer_instructions: Some("stale restored prompt".into()),
},
};
chat.set_effective_collaboration_mode(restored.clone());
let plan = CollaborationModeMask {
name: "Server plan".into(),
mode: Some(ModeKind::Plan),
model: None,
reasoning_effort: Some(Some(ReasoningEffortConfig::High)),
developer_instructions: Some(None),
};
Arc::make_mut(&mut chat.model_catalog).collaboration_modes = vec![plan.clone()];
let mut models = chat.model_catalog.try_list_models().unwrap();
models[0].description = "Refreshed model description".into();
let request_id = uuid::Uuid::new_v4();
chat.model_popup_request_id = Some(request_id);
assert!(chat.on_models_loaded(request_id, Ok(models)));
assert_eq!(chat.model_catalog.collaboration_modes, vec![plan]);
assert_eq!(chat.effective_collaboration_mode(), restored);
assert!(collaboration_modes::default_mode_mask(&chat.model_catalog).is_none());
chat.cycle_collaboration_mode();
assert_eq!(
chat.effective_collaboration_mode(),
CollaborationMode {
mode: ModeKind::Plan,
settings: Settings {
model: "gpt-5.2".into(),
reasoning_effort: Some(ReasoningEffortConfig::High),
developer_instructions: None,
},
}
);
chat.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::Ultra));
chat.cycle_collaboration_mode();
assert_eq!(
chat.effective_reasoning_effort(),
Some(ReasoningEffortConfig::Ultra)
);
chat.set_plan_mode_reasoning_effort(/*effort*/ None);
assert_eq!(
chat.effective_reasoning_effort(),
Some(ReasoningEffortConfig::High)
);
}
#[tokio::test]
async fn collaboration_catalog_unavailable_preserves_task_and_plan_input() {
let (mut chat, _events, _ops) = make_chatwidget_manual(Some("gpt-5.2")).await;
Arc::make_mut(&mut chat.model_catalog)
.collaboration_modes
.clear();
for mode in [ModeKind::Default, ModeKind::Plan] {
let restored = CollaborationMode {
mode,
settings: Settings {
model: "task-model".into(),
reasoning_effort: Some(ReasoningEffortConfig::Ultra),
developer_instructions: Some("restored prompt".into()),
},
};
chat.set_effective_collaboration_mode(restored.clone());
chat.cycle_collaboration_mode();
assert_eq!(chat.effective_collaboration_mode(), restored);
}
chat.set_effective_collaboration_mode(CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model: "task-model".into(),
reasoning_effort: Some(ReasoningEffortConfig::Low),
developer_instructions: None,
},
});
chat.thread_id = Some(ThreadId::new());
chat.bottom_pane
.set_composer_text("/plan keep my draft".into(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_eq!(chat.bottom_pane.composer_text(), "/plan keep my draft");
assert_eq!(chat.active_mode_kind(), ModeKind::Default);
}

View File

@@ -140,9 +140,12 @@ pub(super) fn test_session_telemetry(config: &Config, model: &str) -> SessionTel
}
pub(super) fn test_model_catalog(_config: &Config) -> Arc<ModelCatalog> {
Arc::new(ModelCatalog::new(
crate::test_support::TEST_MODEL_PRESETS.clone(),
))
Arc::new(
ModelCatalog::new(crate::test_support::TEST_MODEL_PRESETS.clone())
.with_collaboration_modes(
codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets(),
),
)
}
// --- Helpers for tests that need direct construction and event draining ---
@@ -309,7 +312,7 @@ pub(crate) fn set_fast_mode_test_catalog(chat: &mut ChatWidget) {
.map(Into::into)
.collect();
chat.model_catalog = Arc::new(ModelCatalog::new(models));
Arc::make_mut(&mut chat.model_catalog).models = models;
}
pub(crate) async fn make_chatwidget_manual_with_sender() -> (

View File

@@ -1483,7 +1483,7 @@ async fn plan_slash_command_with_hidden_shell_paste_rejected_image_remains_liter
.expect("current model")
.input_modalities
.retain(|modality| *modality != InputModality::Image);
chat.model_catalog = Arc::new(ModelCatalog::new(models));
Arc::make_mut(&mut chat.model_catalog).models = models;
let payload = paste_hidden_plan_shell_payload(&mut chat);
chat.set_remote_image_urls(vec!["https://example.com/image.png".to_string()]);
@@ -1558,7 +1558,7 @@ async fn rejected_initial_image_does_not_submit_later_queued_prompt() {
.expect("current model")
.input_modalities
.retain(|modality| *modality != InputModality::Image);
chat.model_catalog = Arc::new(ModelCatalog::new(models));
Arc::make_mut(&mut chat.model_catalog).models = models;
let mut initial_message = UserMessage::from("initial prompt");
initial_message.remote_image_urls = vec!["https://example.com/image.png".to_string()];
chat.initial_user_message = Some(initial_message);

View File

@@ -1,13 +1,22 @@
use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets;
//! Server collaboration-mode discovery and TUI-visible selection. Missing discovery adds no presets.
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::CollaborationModeListParams;
use codex_app_server_protocol::CollaborationModeListResponse;
use codex_app_server_protocol::RequestId;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::ModeKind;
use std::time::Duration;
use crate::model_catalog::ModelCatalog;
fn filtered_presets(_model_catalog: &ModelCatalog) -> Vec<CollaborationModeMask> {
builtin_collaboration_mode_presets()
.into_iter()
fn filtered_presets(model_catalog: &ModelCatalog) -> Vec<CollaborationModeMask> {
model_catalog
.collaboration_modes
.iter()
.filter(|mask| mask.mode.is_some_and(ModeKind::is_tui_visible))
.cloned()
.collect()
}
@@ -56,3 +65,39 @@ pub(crate) fn default_mode_mask(model_catalog: &ModelCatalog) -> Option<Collabor
pub(crate) fn plan_mask(model_catalog: &ModelCatalog) -> Option<CollaborationModeMask> {
mask_for_kind(model_catalog, ModeKind::Plan)
}
/// Discovery is optional even on servers that accept the rest of TUI bootstrap.
pub(crate) async fn list(request_handle: AppServerRequestHandle) -> Vec<CollaborationModeMask> {
let response = tokio::time::timeout(
Duration::from_secs(/*secs*/ 2),
request_handle.request_typed::<CollaborationModeListResponse>(
ClientRequest::CollaborationModeList {
request_id: RequestId::String(format!(
"collaboration-mode-list-{}",
uuid::Uuid::new_v4()
)),
params: CollaborationModeListParams::default(),
},
),
)
.await;
match response {
Ok(Ok(response)) => response
.data
.into_iter()
.map(|mask| CollaborationModeMask {
name: mask.name,
mode: mask.mode,
model: mask.model,
reasoning_effort: mask.reasoning_effort,
// The RPC intentionally omits prompts. Clear restored overrides so the server
// supplies its current instructions when this mode is selected.
developer_instructions: Some(None),
})
.collect(),
_ => {
tracing::warn!("optional collaborationMode/list discovery unavailable");
Vec::new()
}
}
}

View File

@@ -1,5 +1,6 @@
//! TUI model inventory and user-facing labels; wire requests retain their original model slugs.
//! TUI model and collaboration inventories; refreshing models preserves the server mode catalog.
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::openai_models::ModelPreset;
use std::convert::Infallible;
@@ -16,12 +17,21 @@ pub(crate) fn model_display_name(model: &str) -> &str {
#[derive(Debug, Clone)]
pub(crate) struct ModelCatalog {
models: Vec<ModelPreset>,
pub(crate) models: Vec<ModelPreset>,
pub(crate) collaboration_modes: Vec<CollaborationModeMask>,
}
impl ModelCatalog {
pub(crate) fn new(models: Vec<ModelPreset>) -> Self {
Self { models }
Self {
models,
collaboration_modes: Vec::new(),
}
}
pub(crate) fn with_collaboration_modes(mut self, modes: Vec<CollaborationModeMask>) -> Self {
self.collaboration_modes = modes;
self
}
pub(crate) fn try_list_models(&self) -> Result<Vec<ModelPreset>, Infallible> {