mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[2 of 4] tui: route app and skill enablement through app server (#22914)
## Why App and skill toggles are user config mutations too. When the TUI is attached to a remote app server, writing those toggles into the local `config.toml` makes the UI report success without updating the server that actually owns the session. This is **[2 of 4]** in a stacked series that moves TUI-owned config mutations onto app-server APIs. ## What changed - Routed app enable/disable persistence through app-server config batch writes. - Routed skill enable/disable persistence through `skills/config/write`. - Avoided refreshing local config from disk after these writes when the TUI is connected to a remote app server. ## Config keys affected - `apps.<app_id>.enabled` - `apps.<app_id>.disabled_reason` - `[[skills.config]]` entries keyed by `path`, with `enabled = false` used for persisted disables ## Suggested manual validation - Connect the TUI to a remote app server, disable an app, reconnect, and confirm the app remains disabled from remote config rather than local disk state. - Re-enable the same app and confirm both `apps.<app_id>.enabled` and `apps.<app_id>.disabled_reason` are cleared remotely. - Disable a skill in the manage-skills UI and confirm a remote `[[skills.config]]` disable entry appears. - Re-enable that skill and confirm the disable entry is removed and the effective enabled state updates without relying on local config reloads. ## Stack 1. [#22913](https://github.com/openai/codex/pull/22913) `[1 of 4]` primary settings writes 2. [#22914](https://github.com/openai/codex/pull/22914) `[2 of 4]` app and skill enablement 3. [#22915](https://github.com/openai/codex/pull/22915) `[3 of 4]` feature and memory toggles 4. [#22916](https://github.com/openai/codex/pull/22916) `[4 of 4]` startup and onboarding bookkeeping
This commit is contained in:
committed by
GitHub
Unverified
parent
f0663fd4fd
commit
ae10708ae0
@@ -275,6 +275,7 @@ use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::NetworkProxyAuditMetadata;
|
||||
use codex_core::config::edit::ConfigEdit;
|
||||
use codex_core::config::edit::ConfigEditsBuilder;
|
||||
use codex_core::connectors::AccessibleConnectorsStatus;
|
||||
use codex_core::exec::ExecCapturePolicy;
|
||||
use codex_core::exec::ExecExpiration;
|
||||
use codex_core::exec::ExecParams;
|
||||
|
||||
@@ -41,11 +41,19 @@ impl AppsRequestProcessor {
|
||||
request_id: &ConnectionRequestId,
|
||||
params: AppsListParams,
|
||||
) -> Result<Option<AppsListResponse>, JSONRPCErrorError> {
|
||||
let mut config = self.load_latest_config(/*fallback_cwd*/ None).await?;
|
||||
|
||||
if let Some(thread_id) = params.thread_id.as_deref() {
|
||||
let (_, thread) = self.load_thread(thread_id).await?;
|
||||
let thread = if let Some(thread_id) = params.thread_id.as_deref() {
|
||||
let (_, loaded_thread) = self.load_thread(thread_id).await?;
|
||||
Some(loaded_thread)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let fallback_cwd = match thread.as_ref() {
|
||||
Some(thread) => Some(thread.config_snapshot().await.cwd.to_path_buf()),
|
||||
None => None,
|
||||
};
|
||||
let mut config = self.load_latest_config(fallback_cwd).await?;
|
||||
|
||||
if let Some(thread) = thread {
|
||||
let _ = config
|
||||
.features
|
||||
.set_enabled(Feature::Apps, thread.enabled(Feature::Apps));
|
||||
@@ -88,8 +96,31 @@ impl AppsRequestProcessor {
|
||||
config: Config,
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
) {
|
||||
let retry_params = params.clone();
|
||||
let retry_config = config.clone();
|
||||
let retry_environment_manager = Arc::clone(&environment_manager);
|
||||
let result = Self::apps_list_response(&outgoing, params, config, environment_manager).await;
|
||||
outgoing.send_result(request_id, result).await;
|
||||
let should_retry = result
|
||||
.as_ref()
|
||||
.is_ok_and(|(_, codex_apps_ready)| !codex_apps_ready);
|
||||
outgoing
|
||||
.send_result(request_id, result.map(|(response, _)| response))
|
||||
.await;
|
||||
|
||||
if should_retry && !retry_params.force_refetch {
|
||||
let mut retry_params = retry_params;
|
||||
retry_params.force_refetch = true;
|
||||
if let Err(err) = Self::apps_list_response(
|
||||
&outgoing,
|
||||
retry_params,
|
||||
retry_config,
|
||||
retry_environment_manager,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("failed to refresh app list after codex-apps readiness retry: {err:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn apps_list_response(
|
||||
@@ -97,7 +128,7 @@ impl AppsRequestProcessor {
|
||||
params: AppsListParams,
|
||||
config: Config,
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
) -> Result<AppsListResponse, JSONRPCErrorError> {
|
||||
) -> Result<(AppsListResponse, bool), JSONRPCErrorError> {
|
||||
let AppsListParams {
|
||||
cursor,
|
||||
limit,
|
||||
@@ -130,7 +161,6 @@ impl AppsRequestProcessor {
|
||||
&environment_manager,
|
||||
)
|
||||
.await
|
||||
.map(|status| status.connectors)
|
||||
.map_err(|err| format!("failed to load accessible apps: {err}"));
|
||||
let _ = accessible_tx.send(AppListLoadResult::Accessible(result));
|
||||
});
|
||||
@@ -146,6 +176,7 @@ impl AppsRequestProcessor {
|
||||
let app_list_deadline = tokio::time::Instant::now() + APP_LIST_LOAD_TIMEOUT;
|
||||
let mut accessible_loaded = false;
|
||||
let mut all_loaded = false;
|
||||
let mut codex_apps_ready = true;
|
||||
let mut last_notified_apps = None;
|
||||
|
||||
if accessible_connectors.is_some() || all_connectors.is_some() {
|
||||
@@ -178,9 +209,10 @@ impl AppsRequestProcessor {
|
||||
};
|
||||
|
||||
match result {
|
||||
AppListLoadResult::Accessible(Ok(connectors)) => {
|
||||
accessible_connectors = Some(connectors);
|
||||
AppListLoadResult::Accessible(Ok(status)) => {
|
||||
accessible_connectors = Some(status.connectors);
|
||||
accessible_loaded = true;
|
||||
codex_apps_ready = status.codex_apps_ready;
|
||||
}
|
||||
AppListLoadResult::Accessible(Err(err)) => {
|
||||
return Err(internal_error(err));
|
||||
@@ -222,7 +254,8 @@ impl AppsRequestProcessor {
|
||||
}
|
||||
|
||||
if accessible_loaded && all_loaded {
|
||||
return paginate_apps(merged.as_slice(), start, limit);
|
||||
let response = paginate_apps(merged.as_slice(), start, limit)?;
|
||||
return Ok((response, codex_apps_ready));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,7 +312,7 @@ impl AppsRequestProcessor {
|
||||
const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
|
||||
enum AppListLoadResult {
|
||||
Accessible(Result<Vec<AppInfo>, String>),
|
||||
Accessible(Result<AccessibleConnectorsStatus, String>),
|
||||
Directory(Result<Vec<AppInfo>, String>),
|
||||
}
|
||||
|
||||
|
||||
@@ -615,7 +615,6 @@ impl App {
|
||||
) -> crate::chatwidget::ChatWidgetInit {
|
||||
crate::chatwidget::ChatWidgetInit {
|
||||
config: cfg,
|
||||
environment_manager: self.environment_manager.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: self.app_event_tx.clone(),
|
||||
workspace_command_runner: self.workspace_command_runner.clone(),
|
||||
@@ -788,7 +787,6 @@ impl App {
|
||||
.await;
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: config.clone(),
|
||||
environment_manager: environment_manager.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
workspace_command_runner: Some(workspace_command_runner.clone()),
|
||||
@@ -825,7 +823,6 @@ impl App {
|
||||
})?;
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: config.clone(),
|
||||
environment_manager: environment_manager.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
workspace_command_runner: Some(workspace_command_runner.clone()),
|
||||
@@ -867,7 +864,6 @@ impl App {
|
||||
})?;
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: config.clone(),
|
||||
environment_manager: environment_manager.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
workspace_command_runner: Some(workspace_command_runner.clone()),
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::app_server_event_targets::server_notification_thread_target;
|
||||
use super::app_server_event_targets::server_request_thread_id;
|
||||
use crate::app_command::AppCommand;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::ConnectorsSnapshot;
|
||||
use crate::app_server_session::AppServerSession;
|
||||
use crate::app_server_session::status_account_display_from_auth_mode;
|
||||
use codex_app_server_client::AppServerEvent;
|
||||
@@ -106,6 +107,15 @@ impl App {
|
||||
self.fetch_plugins_list(app_server_client, cwd);
|
||||
return;
|
||||
}
|
||||
ServerNotification::AppListUpdated(notification) => {
|
||||
self.chat_widget.on_connectors_loaded(
|
||||
Ok(ConnectorsSnapshot {
|
||||
connectors: notification.data.clone(),
|
||||
}),
|
||||
/*is_final*/ false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
use super::plugin_mentions::fetch_plugin_mentions;
|
||||
use super::*;
|
||||
use crate::app_event::ConnectorsSnapshot;
|
||||
use codex_app_server_protocol::AppsListParams;
|
||||
use codex_app_server_protocol::AppsListResponse;
|
||||
use codex_app_server_protocol::MarketplaceAddParams;
|
||||
use codex_app_server_protocol::MarketplaceAddResponse;
|
||||
use codex_app_server_protocol::MarketplaceRemoveParams;
|
||||
@@ -92,6 +95,27 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn fetch_connectors_list(
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
force_refetch: bool,
|
||||
) {
|
||||
let request_handle = app_server.request_handle();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
let thread_id = self
|
||||
.current_displayed_thread_id()
|
||||
.map(|thread_id| thread_id.to_string());
|
||||
tokio::spawn(async move {
|
||||
let result = fetch_connectors_list(request_handle, force_refetch, thread_id)
|
||||
.await
|
||||
.map_err(|err| err.to_string());
|
||||
app_event_tx.send(AppEvent::ConnectorsLoaded {
|
||||
result,
|
||||
is_final: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn fetch_plugins_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) {
|
||||
let request_handle = app_server.request_handle();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
@@ -634,6 +658,29 @@ pub(super) async fn fetch_skills_list(
|
||||
.wrap_err("skills/list failed in TUI")
|
||||
}
|
||||
|
||||
pub(super) async fn fetch_connectors_list(
|
||||
request_handle: AppServerRequestHandle,
|
||||
force_refetch: bool,
|
||||
thread_id: Option<String>,
|
||||
) -> Result<ConnectorsSnapshot> {
|
||||
let request_id = RequestId::String(format!("apps-list-{}", Uuid::new_v4()));
|
||||
let response: AppsListResponse = request_handle
|
||||
.request_typed(ClientRequest::AppsList {
|
||||
request_id,
|
||||
params: AppsListParams {
|
||||
cursor: None,
|
||||
limit: None,
|
||||
thread_id,
|
||||
force_refetch,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("app/list failed in TUI")?;
|
||||
Ok(ConnectorsSnapshot {
|
||||
connectors: response.data,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn fetch_plugins_list(
|
||||
request_handle: AppServerRequestHandle,
|
||||
cwd: PathBuf,
|
||||
|
||||
@@ -589,6 +589,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::app::test_support::app_enabled_in_effective_config;
|
||||
use crate::app::test_support::make_test_app;
|
||||
use crate::legacy_core::config::edit::ConfigEdit;
|
||||
use crate::test_support::PathBufExt;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
@@ -420,6 +420,9 @@ impl App {
|
||||
AppEvent::RefreshConnectors { force_refetch } => {
|
||||
self.chat_widget.refresh_connectors(force_refetch);
|
||||
}
|
||||
AppEvent::FetchConnectorsList { force_refetch } => {
|
||||
self.fetch_connectors_list(app_server, force_refetch);
|
||||
}
|
||||
AppEvent::PluginInstallAuthAdvance { refresh_connectors } => {
|
||||
if refresh_connectors {
|
||||
self.chat_widget.refresh_connectors(/*force_refetch*/ true);
|
||||
@@ -1668,18 +1671,18 @@ impl App {
|
||||
self.chat_widget.open_manage_skills_popup();
|
||||
}
|
||||
AppEvent::SetSkillEnabled { path, enabled } => {
|
||||
let edits = [ConfigEdit::SetSkillConfig {
|
||||
path: path.to_path_buf(),
|
||||
match crate::config_update::write_skill_enabled(
|
||||
app_server.request_handle(),
|
||||
path.clone(),
|
||||
enabled,
|
||||
}];
|
||||
match ConfigEditsBuilder::for_config(&self.config)
|
||||
.with_edits(edits)
|
||||
.apply()
|
||||
.await
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
self.chat_widget.update_skill_enabled(path, enabled);
|
||||
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
||||
if !app_server.is_remote()
|
||||
&& let Err(err) = self.refresh_in_memory_config_from_disk().await
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"failed to refresh config after skill toggle"
|
||||
@@ -1697,44 +1700,35 @@ impl App {
|
||||
AppEvent::SetAppEnabled { id, enabled } => {
|
||||
let edits = if enabled {
|
||||
vec![
|
||||
ConfigEdit::ClearPath {
|
||||
segments: vec!["apps".to_string(), id.clone(), "enabled".to_string()],
|
||||
},
|
||||
ConfigEdit::ClearPath {
|
||||
segments: vec![
|
||||
"apps".to_string(),
|
||||
id.clone(),
|
||||
"disabled_reason".to_string(),
|
||||
],
|
||||
},
|
||||
crate::config_update::clear_config_value(
|
||||
crate::config_update::app_scoped_key_path(&id, "enabled"),
|
||||
),
|
||||
crate::config_update::clear_config_value(
|
||||
crate::config_update::app_scoped_key_path(&id, "disabled_reason"),
|
||||
),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
ConfigEdit::SetPath {
|
||||
segments: vec!["apps".to_string(), id.clone(), "enabled".to_string()],
|
||||
value: false.into(),
|
||||
},
|
||||
ConfigEdit::SetPath {
|
||||
segments: vec![
|
||||
"apps".to_string(),
|
||||
id.clone(),
|
||||
"disabled_reason".to_string(),
|
||||
],
|
||||
value: "user".into(),
|
||||
},
|
||||
crate::config_update::replace_config_value(
|
||||
crate::config_update::app_scoped_key_path(&id, "enabled"),
|
||||
serde_json::json!(false),
|
||||
),
|
||||
crate::config_update::replace_config_value(
|
||||
crate::config_update::app_scoped_key_path(&id, "disabled_reason"),
|
||||
serde_json::json!("user"),
|
||||
),
|
||||
]
|
||||
};
|
||||
match ConfigEditsBuilder::for_config(&self.config)
|
||||
.with_edits(edits)
|
||||
.apply()
|
||||
match crate::config_update::write_config_batch(app_server.request_handle(), edits)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
Ok(_) => {
|
||||
self.chat_widget.update_connector_enabled(&id, enabled);
|
||||
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
||||
if !app_server.is_remote()
|
||||
&& let Err(err) = self.refresh_in_memory_config_from_disk().await
|
||||
{
|
||||
tracing::warn!(error = %err, "failed to refresh config after app toggle");
|
||||
}
|
||||
self.chat_widget.submit_op(AppCommand::reload_user_config());
|
||||
}
|
||||
Err(err) => {
|
||||
self.chat_widget.add_error_message(format!(
|
||||
|
||||
@@ -265,7 +265,6 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm
|
||||
let model = crate::legacy_core::test_support::get_model_offline(config.model.as_deref());
|
||||
app.chat_widget = ChatWidget::new_with_app_event(ChatWidgetInit {
|
||||
config,
|
||||
environment_manager: app.environment_manager.clone(),
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
app_event_tx: app.app_event_tx.clone(),
|
||||
workspace_command_runner: None,
|
||||
@@ -4842,7 +4841,6 @@ async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() {
|
||||
|
||||
let replacement = ChatWidget::new_with_app_event(ChatWidgetInit {
|
||||
config: app.config.clone(),
|
||||
environment_manager: app.environment_manager.clone(),
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
app_event_tx: app.app_event_tx.clone(),
|
||||
workspace_command_runner: None,
|
||||
|
||||
@@ -355,6 +355,11 @@ pub(crate) enum AppEvent {
|
||||
force_refetch: bool,
|
||||
},
|
||||
|
||||
/// Fetch app connector state from the app server after the widget accepts a refresh request.
|
||||
FetchConnectorsList {
|
||||
force_refetch: bool,
|
||||
},
|
||||
|
||||
/// Fetch plugin marketplace state for the provided working directory.
|
||||
FetchPluginsList {
|
||||
cwd: PathBuf,
|
||||
|
||||
@@ -128,7 +128,6 @@ use codex_config::types::ApprovalsReviewer;
|
||||
use codex_config::types::Notifications;
|
||||
use codex_config::types::WindowsSandboxModeToml;
|
||||
use codex_core_skills::model::SkillMetadata;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_features::FEATURES;
|
||||
use codex_features::Feature;
|
||||
#[cfg(test)]
|
||||
@@ -464,7 +463,6 @@ const MAX_AGENT_COPY_HISTORY: usize = 32;
|
||||
/// Common initialization parameters shared by all `ChatWidget` constructors.
|
||||
pub(crate) struct ChatWidgetInit {
|
||||
pub(crate) config: Config,
|
||||
pub(crate) environment_manager: Arc<EnvironmentManager>,
|
||||
pub(crate) frame_requester: FrameRequester,
|
||||
pub(crate) app_event_tx: AppEventSender,
|
||||
/// App-server-backed runner used by status surfaces for workspace metadata probes.
|
||||
@@ -516,7 +514,6 @@ pub(crate) struct ChatWidget {
|
||||
bottom_pane: BottomPane,
|
||||
transcript: TranscriptState,
|
||||
config: Config,
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
raw_output_mode: bool,
|
||||
/// Runtime value resolved by core. `config.service_tier` remains the explicit user choice.
|
||||
effective_service_tier: Option<String>,
|
||||
|
||||
@@ -22,86 +22,36 @@ pub(super) struct ConnectorsState {
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn refresh_connectors(&mut self, force_refetch: bool) {
|
||||
self.prefetch_connectors_with_options(force_refetch);
|
||||
self.queue_connectors_refresh(force_refetch);
|
||||
}
|
||||
|
||||
pub(super) fn prefetch_connectors(&mut self) {
|
||||
self.prefetch_connectors_with_options(/*force_refetch*/ false);
|
||||
self.queue_connectors_refresh(/*force_refetch*/ false);
|
||||
}
|
||||
|
||||
fn prefetch_connectors_with_options(&mut self, force_refetch: bool) {
|
||||
fn queue_connectors_refresh(&mut self, force_refetch: bool) {
|
||||
if self.begin_connectors_refresh(force_refetch) {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::FetchConnectorsList { force_refetch });
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_connectors_refresh(&mut self, force_refetch: bool) -> bool {
|
||||
if !self.connectors_enabled() {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if self.connectors.prefetch_in_flight {
|
||||
if force_refetch {
|
||||
self.connectors.force_refetch_pending = true;
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
self.connectors.prefetch_in_flight = true;
|
||||
if !matches!(self.connectors.cache, ConnectorsCacheState::Ready(_)) {
|
||||
self.connectors.cache = ConnectorsCacheState::Loading;
|
||||
}
|
||||
|
||||
let config = self.config.clone();
|
||||
let environment_manager = Arc::clone(&self.environment_manager);
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let accessible_result =
|
||||
match chatgpt_connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
|
||||
&config,
|
||||
force_refetch,
|
||||
&environment_manager,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(connectors) => connectors,
|
||||
Err(err) => {
|
||||
app_event_tx.send(AppEvent::ConnectorsLoaded {
|
||||
result: Err(format!("Failed to load apps: {err}")),
|
||||
is_final: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
let should_schedule_force_refetch =
|
||||
!force_refetch && !accessible_result.codex_apps_ready;
|
||||
let accessible_connectors = accessible_result.connectors;
|
||||
|
||||
app_event_tx.send(AppEvent::ConnectorsLoaded {
|
||||
result: Ok(ConnectorsSnapshot {
|
||||
connectors: accessible_connectors.clone(),
|
||||
}),
|
||||
is_final: false,
|
||||
});
|
||||
|
||||
let result: Result<ConnectorsSnapshot, String> = async {
|
||||
let all_connectors =
|
||||
chatgpt_connectors::list_all_connectors_with_options(&config, force_refetch)
|
||||
.await?;
|
||||
let connectors = chatgpt_connectors::merge_connectors_with_accessible(
|
||||
all_connectors,
|
||||
accessible_connectors,
|
||||
/*all_connectors_loaded*/ true,
|
||||
);
|
||||
Ok(ConnectorsSnapshot { connectors })
|
||||
}
|
||||
.await
|
||||
.map_err(|err: anyhow::Error| format!("Failed to load apps: {err}"));
|
||||
|
||||
app_event_tx.send(AppEvent::ConnectorsLoaded {
|
||||
result,
|
||||
is_final: true,
|
||||
});
|
||||
|
||||
if should_schedule_force_refetch {
|
||||
app_event_tx.send(AppEvent::RefreshConnectors {
|
||||
force_refetch: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn connectors_enabled(&self) -> bool {
|
||||
@@ -135,7 +85,7 @@ impl ChatWidget {
|
||||
let connectors_cache = self.connectors.cache.clone();
|
||||
let should_force_refetch = !self.connectors.prefetch_in_flight
|
||||
|| matches!(connectors_cache, ConnectorsCacheState::Ready(_));
|
||||
self.prefetch_connectors_with_options(should_force_refetch);
|
||||
self.queue_connectors_refresh(should_force_refetch);
|
||||
|
||||
match connectors_cache {
|
||||
ConnectorsCacheState::Ready(snapshot) => {
|
||||
@@ -360,8 +310,6 @@ impl ChatWidget {
|
||||
/*all_connectors_loaded*/ false,
|
||||
);
|
||||
}
|
||||
snapshot.connectors =
|
||||
chatgpt_connectors::with_app_enabled_state(snapshot.connectors, &self.config);
|
||||
if let ConnectorsCacheState::Ready(existing_snapshot) = &self.connectors.cache {
|
||||
let enabled_by_id: HashMap<&str, bool> = existing_snapshot
|
||||
.connectors
|
||||
@@ -404,7 +352,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
if trigger_pending_force_refetch {
|
||||
self.prefetch_connectors_with_options(/*force_refetch*/ true);
|
||||
self.queue_connectors_refresh(/*force_refetch*/ true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ impl ChatWidget {
|
||||
) -> Self {
|
||||
let ChatWidgetInit {
|
||||
config,
|
||||
environment_manager,
|
||||
frame_requester,
|
||||
app_event_tx,
|
||||
workspace_command_runner,
|
||||
@@ -104,7 +103,6 @@ impl ChatWidget {
|
||||
transcript: TranscriptState::new(active_cell),
|
||||
raw_output_mode: config.tui_raw_output_mode,
|
||||
config,
|
||||
environment_manager,
|
||||
effective_service_tier,
|
||||
skills_all: Vec::new(),
|
||||
skills_initial_state: None,
|
||||
|
||||
@@ -119,11 +119,7 @@ pub(super) use codex_app_server_protocol::TurnStatus as AppServerTurnStatus;
|
||||
pub(super) use codex_app_server_protocol::UserInput;
|
||||
pub(super) use codex_app_server_protocol::UserInput as AppServerUserInput;
|
||||
pub(super) use codex_app_server_protocol::WarningNotification;
|
||||
pub(super) use codex_config::AppRequirementToml;
|
||||
pub(super) use codex_config::AppsRequirementsToml;
|
||||
pub(super) use codex_config::ConfigLayerStack;
|
||||
pub(super) use codex_config::ConfigRequirements;
|
||||
pub(super) use codex_config::ConfigRequirementsToml;
|
||||
pub(super) use codex_config::RequirementSource;
|
||||
pub(super) use codex_config::types::ApprovalsReviewer;
|
||||
pub(super) use codex_config::types::Notifications;
|
||||
@@ -178,7 +174,6 @@ pub(super) use insta::assert_snapshot;
|
||||
pub(super) use serde_json::json;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(super) use serial_test::serial;
|
||||
pub(super) use std::collections::BTreeMap;
|
||||
pub(super) use std::collections::HashMap;
|
||||
pub(super) use std::path::PathBuf;
|
||||
pub(super) use tempfile::NamedTempFile;
|
||||
|
||||
@@ -161,7 +161,6 @@ pub(super) async fn make_chatwidget_manual(
|
||||
let model_catalog = test_model_catalog(&cfg);
|
||||
let common = ChatWidgetInit {
|
||||
config: cfg,
|
||||
environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
app_event_tx,
|
||||
workspace_command_runner: None,
|
||||
|
||||
@@ -1481,7 +1481,6 @@ async fn make_startup_chat_with_cli_overrides(
|
||||
let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str());
|
||||
let init = ChatWidgetInit {
|
||||
config: cfg.clone(),
|
||||
environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
app_event_tx: AppEventSender::new(unbounded_channel::<AppEvent>().0),
|
||||
workspace_command_runner: None,
|
||||
|
||||
@@ -72,7 +72,6 @@ async fn experimental_mode_plan_is_ignored_on_startup() {
|
||||
let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str());
|
||||
let init = ChatWidgetInit {
|
||||
config: cfg.clone(),
|
||||
environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
app_event_tx: AppEventSender::new(unbounded_channel::<AppEvent>().0),
|
||||
workspace_command_runner: None,
|
||||
@@ -1859,198 +1858,6 @@ async fn apps_popup_shows_disabled_status_for_installed_but_disabled_apps() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_initial_load_applies_enabled_state_from_config() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.config
|
||||
.features
|
||||
.enable(Feature::Apps)
|
||||
.expect("test config should allow feature update");
|
||||
chat.bottom_pane.set_connectors_enabled(/*enabled*/ true);
|
||||
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let config_toml_path = temp.path().join("config.toml").abs();
|
||||
let user_config = toml::from_str::<TomlValue>(
|
||||
"[apps.connector_1]\nenabled = false\ndisabled_reason = \"user\"\n",
|
||||
)
|
||||
.expect("apps config");
|
||||
chat.config.config_layer_stack = chat
|
||||
.config
|
||||
.config_layer_stack
|
||||
.with_user_config(&config_toml_path, user_config);
|
||||
|
||||
chat.on_connectors_loaded(
|
||||
Ok(ConnectorsSnapshot {
|
||||
connectors: vec![AppInfo {
|
||||
id: "connector_1".to_string(),
|
||||
name: "Notion".to_string(),
|
||||
description: Some("Workspace docs".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}],
|
||||
}),
|
||||
/*is_final*/ true,
|
||||
);
|
||||
|
||||
assert_matches!(
|
||||
&chat.connectors.cache,
|
||||
ConnectorsCacheState::Ready(snapshot)
|
||||
if snapshot
|
||||
.connectors
|
||||
.iter()
|
||||
.find(|connector| connector.id == "connector_1")
|
||||
.is_some_and(|connector| !connector.is_enabled)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_initial_load_applies_enabled_state_from_requirements_with_user_override() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.config
|
||||
.features
|
||||
.enable(Feature::Apps)
|
||||
.expect("test config should allow feature update");
|
||||
chat.bottom_pane.set_connectors_enabled(/*enabled*/ true);
|
||||
|
||||
let requirements = ConfigRequirementsToml {
|
||||
apps: Some(AppsRequirementsToml {
|
||||
apps: BTreeMap::from([(
|
||||
"connector_1".to_string(),
|
||||
AppRequirementToml {
|
||||
enabled: Some(false),
|
||||
tools: None,
|
||||
},
|
||||
)]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let config_toml_path = temp.path().join("config.toml").abs();
|
||||
chat.config.config_layer_stack =
|
||||
ConfigLayerStack::new(Vec::new(), ConfigRequirements::default(), requirements)
|
||||
.expect("requirements stack")
|
||||
.with_user_config(
|
||||
&config_toml_path,
|
||||
toml::from_str::<TomlValue>(
|
||||
"[apps.connector_1]\nenabled = true\ndisabled_reason = \"user\"\n",
|
||||
)
|
||||
.expect("apps config"),
|
||||
);
|
||||
|
||||
chat.on_connectors_loaded(
|
||||
Ok(ConnectorsSnapshot {
|
||||
connectors: vec![AppInfo {
|
||||
id: "connector_1".to_string(),
|
||||
name: "Notion".to_string(),
|
||||
description: Some("Workspace docs".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}],
|
||||
}),
|
||||
/*is_final*/ true,
|
||||
);
|
||||
|
||||
assert_matches!(
|
||||
&chat.connectors.cache,
|
||||
ConnectorsCacheState::Ready(snapshot)
|
||||
if snapshot
|
||||
.connectors
|
||||
.iter()
|
||||
.find(|connector| connector.id == "connector_1")
|
||||
.is_some_and(|connector| !connector.is_enabled)
|
||||
);
|
||||
|
||||
chat.add_connectors_output();
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert!(
|
||||
popup.contains("Installed · Disabled. Press Enter to open the app page"),
|
||||
"expected requirements-disabled connector to render as disabled, got:\n{popup}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_initial_load_applies_enabled_state_from_requirements_without_user_entry() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.config
|
||||
.features
|
||||
.enable(Feature::Apps)
|
||||
.expect("test config should allow feature update");
|
||||
chat.bottom_pane.set_connectors_enabled(/*enabled*/ true);
|
||||
|
||||
let requirements = ConfigRequirementsToml {
|
||||
apps: Some(AppsRequirementsToml {
|
||||
apps: BTreeMap::from([(
|
||||
"connector_1".to_string(),
|
||||
AppRequirementToml {
|
||||
enabled: Some(false),
|
||||
tools: None,
|
||||
},
|
||||
)]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
chat.config.config_layer_stack =
|
||||
ConfigLayerStack::new(Vec::new(), ConfigRequirements::default(), requirements)
|
||||
.expect("requirements stack");
|
||||
|
||||
chat.on_connectors_loaded(
|
||||
Ok(ConnectorsSnapshot {
|
||||
connectors: vec![AppInfo {
|
||||
id: "connector_1".to_string(),
|
||||
name: "Notion".to_string(),
|
||||
description: Some("Workspace docs".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}],
|
||||
}),
|
||||
/*is_final*/ true,
|
||||
);
|
||||
|
||||
assert_matches!(
|
||||
&chat.connectors.cache,
|
||||
ConnectorsCacheState::Ready(snapshot)
|
||||
if snapshot
|
||||
.connectors
|
||||
.iter()
|
||||
.find(|connector| connector.id == "connector_1")
|
||||
.is_some_and(|connector| !connector.is_enabled)
|
||||
);
|
||||
|
||||
chat.add_connectors_output();
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert!(
|
||||
popup.contains("Installed · Disabled. Press Enter to open the app page"),
|
||||
"expected requirements-disabled connector to render as disabled, got:\n{popup}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_refresh_preserves_toggled_enabled_state() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -413,7 +413,6 @@ async fn configured_pet_load_is_deferred_until_after_construction() {
|
||||
let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str());
|
||||
let init = ChatWidgetInit {
|
||||
config: cfg.clone(),
|
||||
environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
app_event_tx: tx,
|
||||
workspace_command_runner: None,
|
||||
|
||||
@@ -11,6 +11,9 @@ use codex_app_server_protocol::ConfigEdit;
|
||||
use codex_app_server_protocol::ConfigWriteResponse;
|
||||
use codex_app_server_protocol::MergeStrategy;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::SkillsConfigWriteParams;
|
||||
use codex_app_server_protocol::SkillsConfigWriteResponse;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use color_eyre::eyre::Result;
|
||||
use color_eyre::eyre::WrapErr;
|
||||
use serde_json::Value as JsonValue;
|
||||
@@ -37,6 +40,11 @@ pub(crate) fn profile_scoped_key_path(profile: Option<&str>, key_path: &str) ->
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn app_scoped_key_path(app_id: &str, key_path: &str) -> String {
|
||||
let app_id = serde_json::Value::String(app_id.to_string()).to_string();
|
||||
format!("apps.{app_id}.{key_path}")
|
||||
}
|
||||
|
||||
pub(crate) fn build_model_selection_edits(
|
||||
profile: Option<&str>,
|
||||
model: &str,
|
||||
@@ -109,6 +117,26 @@ pub(crate) async fn write_config_batch(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn write_skill_enabled(
|
||||
request_handle: AppServerRequestHandle,
|
||||
path: AbsolutePathBuf,
|
||||
enabled: bool,
|
||||
) -> Result<()> {
|
||||
let request_id = RequestId::String(format!("tui-skill-config-write-{}", Uuid::new_v4()));
|
||||
let _: SkillsConfigWriteResponse = request_handle
|
||||
.request_typed(ClientRequest::SkillsConfigWrite {
|
||||
request_id,
|
||||
params: SkillsConfigWriteParams {
|
||||
path: Some(path),
|
||||
name: None,
|
||||
enabled,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("skills/config/write failed in TUI")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -121,4 +149,12 @@ mod tests {
|
||||
"profiles.\"team.prod\".model"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_scoped_key_path_quotes_dotted_app_ids() {
|
||||
assert_eq!(
|
||||
app_scoped_key_path("plugin.linear", "enabled"),
|
||||
"apps.\"plugin.linear\".enabled"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user