mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[apps] Add is_enabled to app info. (#11417)
- [x] Add is_enabled to app info and the response of `app/list`. - [x] Update TUI to have Enable/Disable button on the app detail page.
This commit is contained in:
committed by
GitHub
Unverified
parent
8d97b5c246
commit
c37560069a
@@ -193,6 +193,11 @@
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"isEnabled": {
|
||||
"default": true,
|
||||
"description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```",
|
||||
"type": "boolean"
|
||||
},
|
||||
"logoUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -10201,6 +10201,11 @@
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"isEnabled": {
|
||||
"default": true,
|
||||
"description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```",
|
||||
"type": "boolean"
|
||||
},
|
||||
"logoUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"isEnabled": {
|
||||
"default": true,
|
||||
"description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```",
|
||||
"type": "boolean"
|
||||
},
|
||||
"logoUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"isEnabled": {
|
||||
"default": true,
|
||||
"description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```",
|
||||
"type": "boolean"
|
||||
},
|
||||
"logoUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -5,4 +5,13 @@
|
||||
/**
|
||||
* EXPERIMENTAL - app metadata returned by app-list APIs.
|
||||
*/
|
||||
export type AppInfo = { id: string, name: string, description: string | null, logoUrl: string | null, logoUrlDark: string | null, distributionChannel: string | null, installUrl: string | null, isAccessible: boolean, };
|
||||
export type AppInfo = { id: string, name: string, description: string | null, logoUrl: string | null, logoUrlDark: string | null, distributionChannel: string | null, installUrl: string | null, isAccessible: boolean,
|
||||
/**
|
||||
* Whether this app is enabled in config.toml.
|
||||
* Example:
|
||||
* ```toml
|
||||
* [apps.bad_app]
|
||||
* enabled = false
|
||||
* ```
|
||||
*/
|
||||
isEnabled: boolean, };
|
||||
|
||||
@@ -1290,6 +1290,14 @@ pub struct AppInfo {
|
||||
pub install_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_accessible: bool,
|
||||
/// Whether this app is enabled in config.toml.
|
||||
/// Example:
|
||||
/// ```toml
|
||||
/// [apps.bad_app]
|
||||
/// enabled = false
|
||||
/// ```
|
||||
#[serde(default = "default_enabled")]
|
||||
pub is_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
|
||||
@@ -763,7 +763,7 @@ To enable or disable a skill by path:
|
||||
|
||||
## Apps
|
||||
|
||||
Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, and whether it is currently accessible.
|
||||
Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, whether it is currently accessible, and whether it is enabled in config.
|
||||
|
||||
```json
|
||||
{ "method": "app/list", "id": 50, "params": {
|
||||
@@ -782,7 +782,8 @@ Use `app/list` to fetch available apps (connectors). Each entry includes metadat
|
||||
"logoUrlDark": null,
|
||||
"distributionChannel": null,
|
||||
"installUrl": "https://chatgpt.com/apps/demo-app/demo-app",
|
||||
"isAccessible": true
|
||||
"isAccessible": true,
|
||||
"isEnabled": true
|
||||
}
|
||||
],
|
||||
"nextCursor": null
|
||||
@@ -808,7 +809,8 @@ The server also emits `app/list/updated` notifications whenever either source (a
|
||||
"logoUrlDark": null,
|
||||
"distributionChannel": null,
|
||||
"installUrl": "https://chatgpt.com/apps/demo-app/demo-app",
|
||||
"isAccessible": true
|
||||
"isAccessible": true,
|
||||
"isEnabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4603,8 +4603,9 @@ impl CodexMessageProcessor {
|
||||
let _ = accessible_tx.send(AppListLoadResult::Accessible(result));
|
||||
});
|
||||
|
||||
let all_config = config.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = connectors::list_all_connectors_with_options(&config, force_refetch)
|
||||
let result = connectors::list_all_connectors_with_options(&all_config, force_refetch)
|
||||
.await
|
||||
.map_err(|err| format!("failed to list apps: {err}"));
|
||||
let _ = tx.send(AppListLoadResult::Directory(result));
|
||||
@@ -4667,9 +4668,12 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
let merged = Self::merge_loaded_apps(
|
||||
all_connectors.as_deref(),
|
||||
accessible_connectors.as_deref(),
|
||||
let merged = connectors::with_app_enabled_state(
|
||||
Self::merge_loaded_apps(
|
||||
all_connectors.as_deref(),
|
||||
accessible_connectors.as_deref(),
|
||||
),
|
||||
&config,
|
||||
);
|
||||
Self::send_app_list_updated_notification(&outgoing, merged.clone()).await;
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ async fn list_apps_uses_thread_feature_flag_when_thread_id_is_provided() -> Resu
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
}];
|
||||
let tools = vec![connector_tool("beta", "Beta App")?];
|
||||
let (server_url, server_handle) =
|
||||
@@ -173,6 +174,78 @@ connectors = false
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_apps_reports_is_enabled_from_config() -> Result<()> {
|
||||
let connectors = vec![AppInfo {
|
||||
id: "beta".to_string(),
|
||||
name: "Beta".to_string(),
|
||||
description: Some("Beta connector".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
}];
|
||||
let tools = vec![connector_tool("beta", "Beta App")?];
|
||||
let (server_url, server_handle) =
|
||||
start_apps_server_with_delays(connectors, tools, Duration::ZERO, Duration::ZERO).await?;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
format!(
|
||||
r#"
|
||||
chatgpt_base_url = "{server_url}"
|
||||
|
||||
[features]
|
||||
connectors = true
|
||||
|
||||
[apps.beta]
|
||||
enabled = false
|
||||
"#
|
||||
),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_apps_list_request(AppsListParams {
|
||||
limit: None,
|
||||
cursor: None,
|
||||
thread_id: None,
|
||||
force_refetch: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let AppsListResponse {
|
||||
data: response_data,
|
||||
next_cursor,
|
||||
} = to_response(response)?;
|
||||
assert!(next_cursor.is_none());
|
||||
assert_eq!(response_data.len(), 1);
|
||||
assert_eq!(response_data[0].id, "beta");
|
||||
assert!(!response_data[0].is_enabled);
|
||||
|
||||
server_handle.abort();
|
||||
let _ = server_handle.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<()> {
|
||||
let connectors = vec![
|
||||
@@ -185,6 +258,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
AppInfo {
|
||||
id: "beta".to_string(),
|
||||
@@ -195,6 +269,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -239,6 +314,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
}];
|
||||
|
||||
let first_update = read_app_list_updated_notification(&mut mcp).await?;
|
||||
@@ -254,6 +330,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
},
|
||||
AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
@@ -264,6 +341,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -300,6 +378,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
AppInfo {
|
||||
id: "beta".to_string(),
|
||||
@@ -310,6 +389,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -358,6 +438,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
AppInfo {
|
||||
id: "beta".to_string(),
|
||||
@@ -368,6 +449,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
]
|
||||
);
|
||||
@@ -382,6 +464,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
},
|
||||
AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
@@ -392,6 +475,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -423,6 +507,7 @@ async fn list_apps_paginates_results() -> Result<()> {
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
AppInfo {
|
||||
id: "beta".to_string(),
|
||||
@@ -433,6 +518,7 @@ async fn list_apps_paginates_results() -> Result<()> {
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -486,6 +572,7 @@ async fn list_apps_paginates_results() -> Result<()> {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
}];
|
||||
|
||||
assert_eq!(first_page, expected_first);
|
||||
@@ -525,6 +612,7 @@ async fn list_apps_paginates_results() -> Result<()> {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
}];
|
||||
|
||||
assert_eq!(second_page, expected_second);
|
||||
@@ -545,6 +633,7 @@ async fn list_apps_force_refetch_preserves_previous_cache_on_failure() -> Result
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
}];
|
||||
let tools = vec![connector_tool("beta", "Beta App")?];
|
||||
let (server_url, server_handle) =
|
||||
|
||||
@@ -20,6 +20,7 @@ use codex_core::connectors::connector_install_url;
|
||||
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools;
|
||||
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options;
|
||||
use codex_core::connectors::merge_connectors;
|
||||
pub use codex_core::connectors::with_app_enabled_state;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DirectoryListResponse {
|
||||
@@ -72,7 +73,10 @@ pub async fn list_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
|
||||
);
|
||||
let connectors = connectors_result?;
|
||||
let accessible = accessible_result?;
|
||||
Ok(merge_connectors_with_accessible(connectors, accessible))
|
||||
Ok(with_app_enabled_state(
|
||||
merge_connectors_with_accessible(connectors, accessible),
|
||||
config,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn list_all_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
|
||||
@@ -283,6 +287,7 @@ fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo {
|
||||
distribution_channel: app.distribution_channel,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +346,7 @@ mod tests {
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+120
-2
@@ -114,6 +114,7 @@ use crate::client_common::Prompt;
|
||||
use crate::client_common::ResponseEvent;
|
||||
use crate::codex_thread::ThreadConfigSnapshot;
|
||||
use crate::compact::collect_user_messages;
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::config::Config;
|
||||
use crate::config::Constrained;
|
||||
use crate::config::ConstraintResult;
|
||||
@@ -246,6 +247,7 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_readiness::Readiness;
|
||||
use codex_utils_readiness::ReadinessFlag;
|
||||
|
||||
@@ -1765,6 +1767,48 @@ impl Session {
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(crate) async fn reload_user_config_layer(&self) {
|
||||
let config_toml_path = {
|
||||
let state = self.state.lock().await;
|
||||
state
|
||||
.session_configuration
|
||||
.codex_home
|
||||
.join(CONFIG_TOML_FILE)
|
||||
};
|
||||
|
||||
let user_config = match std::fs::read_to_string(&config_toml_path) {
|
||||
Ok(contents) => match toml::from_str::<toml::Value>(&contents) {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
warn!("failed to parse user config while reloading layer: {err}");
|
||||
return;
|
||||
}
|
||||
},
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
toml::Value::Table(Default::default())
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("failed to read user config while reloading layer: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let config_toml_path = match AbsolutePathBuf::try_from(config_toml_path) {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
warn!("failed to resolve user config path while reloading layer: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut state = self.state.lock().await;
|
||||
let mut config = (*state.session_configuration.original_config_do_not_use).clone();
|
||||
config.config_layer_stack = config
|
||||
.config_layer_stack
|
||||
.with_user_config(&config_toml_path, user_config);
|
||||
state.session_configuration.original_config_do_not_use = Arc::new(config);
|
||||
}
|
||||
|
||||
pub(crate) async fn new_default_turn_with_sub_id(&self, sub_id: String) -> Arc<TurnContext> {
|
||||
let session_configuration = {
|
||||
let state = self.state.lock().await;
|
||||
@@ -3050,6 +3094,9 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
||||
Op::RefreshMcpServers { config } => {
|
||||
handlers::refresh_mcp_servers(&sess, config).await;
|
||||
}
|
||||
Op::ReloadUserConfig => {
|
||||
handlers::reload_user_config(&sess).await;
|
||||
}
|
||||
Op::ListCustomPrompts => {
|
||||
handlers::list_custom_prompts(&sess, sub.id.clone()).await;
|
||||
}
|
||||
@@ -3474,6 +3521,10 @@ mod handlers {
|
||||
*guard = Some(refresh_config);
|
||||
}
|
||||
|
||||
pub async fn reload_user_config(sess: &Arc<Session>) {
|
||||
sess.reload_user_config_layer().await;
|
||||
}
|
||||
|
||||
pub async fn list_mcp_tools(sess: &Session, config: &Arc<Config>, sub_id: String) {
|
||||
let mcp_connection_manager = sess.services.mcp_connection_manager.read().await;
|
||||
let auth = sess.services.auth_manager.auth().await;
|
||||
@@ -4114,7 +4165,10 @@ pub(crate) async fn run_turn(
|
||||
Ok(mcp_tools) => mcp_tools,
|
||||
Err(_) => return None,
|
||||
};
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools)
|
||||
connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn_context.config,
|
||||
)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -4464,6 +4518,14 @@ fn filter_connectors_for_input(
|
||||
explicitly_enabled_connectors: &HashSet<String>,
|
||||
skill_name_counts_lower: &HashMap<String, usize>,
|
||||
) -> Vec<connectors::AppInfo> {
|
||||
let connectors = connectors
|
||||
.into_iter()
|
||||
.filter(|connector| connector.is_enabled)
|
||||
.collect::<Vec<_>>();
|
||||
if connectors.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let user_messages = collect_user_messages(input);
|
||||
if user_messages.is_empty() && explicitly_enabled_connectors.is_empty() {
|
||||
return Vec::new();
|
||||
@@ -4710,7 +4772,10 @@ async fn built_tools(
|
||||
let skill_name_counts_lower = skills_outcome.map_or_else(HashMap::new, |outcome| {
|
||||
build_skill_name_counts(&outcome.skills, &outcome.disabled_paths).1
|
||||
});
|
||||
let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools);
|
||||
let connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn_context.config,
|
||||
);
|
||||
Some(filter_connectors_for_input(
|
||||
connectors,
|
||||
input,
|
||||
@@ -5577,6 +5642,7 @@ mod tests {
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5662,6 +5728,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_user_config_layer_updates_effective_apps_config() {
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
let codex_home = session.codex_home().await;
|
||||
std::fs::create_dir_all(&codex_home).expect("create codex home");
|
||||
let config_toml_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
std::fs::write(
|
||||
&config_toml_path,
|
||||
"[apps.calendar]\nenabled = false\ndisabled_reason = \"user\"\n",
|
||||
)
|
||||
.expect("write user config");
|
||||
|
||||
session.reload_user_config_layer().await;
|
||||
|
||||
let config = session.get_config().await;
|
||||
let apps_toml = config
|
||||
.config_layer_stack
|
||||
.effective_config()
|
||||
.as_table()
|
||||
.and_then(|table| table.get("apps"))
|
||||
.cloned()
|
||||
.expect("apps table");
|
||||
let apps = crate::config::types::AppsConfigToml::deserialize(apps_toml)
|
||||
.expect("deserialize apps config");
|
||||
let app = apps
|
||||
.apps
|
||||
.get("calendar")
|
||||
.expect("calendar app config exists");
|
||||
|
||||
assert!(!app.enabled);
|
||||
assert_eq!(
|
||||
app.disabled_reason,
|
||||
Some(crate::config::types::AppDisabledReason::User)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_connectors_for_input_skips_duplicate_slug_mentions() {
|
||||
let connectors = vec![
|
||||
@@ -5699,6 +5801,22 @@ mod tests {
|
||||
assert_eq!(selected, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_connectors_for_input_skips_disabled_connectors() {
|
||||
let mut connector = make_connector("calendar", "Calendar");
|
||||
connector.is_enabled = false;
|
||||
let input = vec![user_message("use $calendar")];
|
||||
let explicitly_enabled_connectors = HashSet::new();
|
||||
let selected = filter_connectors_for_input(
|
||||
vec![connector],
|
||||
&input,
|
||||
&explicitly_enabled_connectors,
|
||||
&HashMap::new(),
|
||||
);
|
||||
|
||||
assert_eq!(selected, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_app_ids_from_skill_items_includes_linked_mentions() {
|
||||
let connectors = vec![make_connector("calendar", "Calendar")];
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::time::Instant;
|
||||
use async_channel::unbounded;
|
||||
pub use codex_app_server_protocol::AppInfo;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use serde::Deserialize;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::warn;
|
||||
|
||||
@@ -16,6 +17,7 @@ use crate::AuthManager;
|
||||
use crate::CodexAuth;
|
||||
use crate::SandboxState;
|
||||
use crate::config::Config;
|
||||
use crate::config::types::AppsConfigToml;
|
||||
use crate::features::Feature;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
@@ -265,6 +267,22 @@ pub fn merge_connectors(
|
||||
merged
|
||||
}
|
||||
|
||||
pub fn with_app_enabled_state(mut connectors: Vec<AppInfo>, config: &Config) -> Vec<AppInfo> {
|
||||
let apps = read_apps_config(config).map(|apps_config| apps_config.apps);
|
||||
for connector in &mut connectors {
|
||||
if let Some(app) = apps.as_ref().and_then(|apps| apps.get(&connector.id)) {
|
||||
connector.is_enabled = app.enabled;
|
||||
}
|
||||
}
|
||||
connectors
|
||||
}
|
||||
|
||||
fn read_apps_config(config: &Config) -> Option<AppsConfigToml> {
|
||||
let effective_config = config.config_layer_stack.effective_config();
|
||||
let apps_config = effective_config.as_table()?.get("apps")?.clone();
|
||||
AppsConfigToml::deserialize(apps_config).ok()
|
||||
}
|
||||
|
||||
fn collect_accessible_connectors<I>(tools: I) -> Vec<AppInfo>
|
||||
where
|
||||
I: IntoIterator<Item = (String, Option<String>)>,
|
||||
@@ -291,6 +309,7 @@ where
|
||||
distribution_channel: None,
|
||||
install_url: Some(connector_install_url(&connector_name, &connector_id)),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
})
|
||||
.collect();
|
||||
accessible.sort_by(|left, right| {
|
||||
|
||||
@@ -2,11 +2,17 @@ use async_trait::async_trait;
|
||||
use bm25::Document;
|
||||
use bm25::Language;
|
||||
use bm25::SearchEngineBuilder;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::connectors;
|
||||
use crate::features::Feature;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
@@ -76,7 +82,10 @@ impl ToolHandler for SearchToolBm25Handler {
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
payload, session, ..
|
||||
payload,
|
||||
session,
|
||||
turn,
|
||||
..
|
||||
} = invocation;
|
||||
|
||||
let arguments = match payload {
|
||||
@@ -111,6 +120,15 @@ impl ToolHandler for SearchToolBm25Handler {
|
||||
.await
|
||||
.list_all_tools()
|
||||
.await;
|
||||
let mcp_tools = if turn.config.features.enabled(Feature::Apps) {
|
||||
let connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn.config,
|
||||
);
|
||||
filter_codex_apps_mcp_tools(mcp_tools, &connectors)
|
||||
} else {
|
||||
mcp_tools
|
||||
};
|
||||
|
||||
let mut entries: Vec<ToolEntry> = mcp_tools
|
||||
.into_iter()
|
||||
@@ -178,6 +196,28 @@ impl ToolHandler for SearchToolBm25Handler {
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_codex_apps_mcp_tools(
|
||||
mut mcp_tools: HashMap<String, ToolInfo>,
|
||||
connectors: &[AppInfo],
|
||||
) -> HashMap<String, ToolInfo> {
|
||||
let enabled_connectors: HashSet<&str> = connectors
|
||||
.iter()
|
||||
.filter(|connector| connector.is_enabled)
|
||||
.map(|connector| connector.id.as_str())
|
||||
.collect();
|
||||
|
||||
mcp_tools.retain(|_, tool| {
|
||||
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
|
||||
return true;
|
||||
}
|
||||
|
||||
tool.connector_id
|
||||
.as_deref()
|
||||
.is_some_and(|connector_id| enabled_connectors.contains(connector_id))
|
||||
});
|
||||
mcp_tools
|
||||
}
|
||||
|
||||
fn build_search_text(name: &str, info: &ToolInfo, input_keys: &[String]) -> String {
|
||||
let mut parts = vec![
|
||||
name.to_string(),
|
||||
@@ -215,3 +255,112 @@ fn build_search_text(name: &str, info: &ToolInfo, input_keys: &[String]) -> Stri
|
||||
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::model::JsonObject;
|
||||
use rmcp::model::Tool;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn make_connector(id: &str, enabled: bool) -> AppInfo {
|
||||
AppInfo {
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
is_enabled: enabled,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_tool(
|
||||
qualified_name: &str,
|
||||
server_name: &str,
|
||||
tool_name: &str,
|
||||
connector_id: Option<&str>,
|
||||
) -> (String, ToolInfo) {
|
||||
(
|
||||
qualified_name.to_string(),
|
||||
ToolInfo {
|
||||
server_name: server_name.to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
tool: Tool {
|
||||
name: tool_name.to_string().into(),
|
||||
title: None,
|
||||
description: Some(format!("Test tool: {tool_name}").into()),
|
||||
input_schema: Arc::new(JsonObject::default()),
|
||||
output_schema: None,
|
||||
annotations: None,
|
||||
execution: None,
|
||||
icons: None,
|
||||
meta: None,
|
||||
},
|
||||
connector_id: connector_id.map(str::to_string),
|
||||
connector_name: connector_id.map(str::to_string),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_codex_apps_mcp_tools_keeps_non_apps_and_enabled_apps() {
|
||||
let mcp_tools = HashMap::from([
|
||||
make_tool(
|
||||
"mcp__codex_apps__calendar_create_event",
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"calendar_create_event",
|
||||
Some("calendar"),
|
||||
),
|
||||
make_tool(
|
||||
"mcp__codex_apps__drive_search",
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"drive_search",
|
||||
Some("drive"),
|
||||
),
|
||||
make_tool("mcp__rmcp__echo", "rmcp", "echo", None),
|
||||
]);
|
||||
let connectors = vec![
|
||||
make_connector("calendar", false),
|
||||
make_connector("drive", true),
|
||||
];
|
||||
|
||||
let mut filtered: Vec<String> = filter_codex_apps_mcp_tools(mcp_tools, &connectors)
|
||||
.into_keys()
|
||||
.collect();
|
||||
filtered.sort();
|
||||
|
||||
assert_eq!(
|
||||
filtered,
|
||||
vec![
|
||||
"mcp__codex_apps__drive_search".to_string(),
|
||||
"mcp__rmcp__echo".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_codex_apps_mcp_tools_drops_apps_without_connector_id() {
|
||||
let mcp_tools = HashMap::from([
|
||||
make_tool(
|
||||
"mcp__codex_apps__unknown",
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"unknown",
|
||||
None,
|
||||
),
|
||||
make_tool("mcp__rmcp__echo", "rmcp", "echo", None),
|
||||
]);
|
||||
|
||||
let mut filtered: Vec<String> =
|
||||
filter_codex_apps_mcp_tools(mcp_tools, &[make_connector("calendar", true)])
|
||||
.into_keys()
|
||||
.collect();
|
||||
filtered.sort();
|
||||
|
||||
assert_eq!(filtered, vec!["mcp__rmcp__echo".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +260,12 @@ pub enum Op {
|
||||
/// Request MCP servers to reinitialize and refresh cached tool lists.
|
||||
RefreshMcpServers { config: McpServerRefreshConfig },
|
||||
|
||||
/// Reload user config layer overrides for the active session.
|
||||
///
|
||||
/// This updates runtime config-derived behavior (for example app
|
||||
/// enable/disable state) without restarting the thread.
|
||||
ReloadUserConfig,
|
||||
|
||||
/// Request the list of available custom prompts.
|
||||
ListCustomPrompts,
|
||||
|
||||
|
||||
+126
-7
@@ -626,6 +626,13 @@ impl App {
|
||||
.wrap_err_with(|| format!("Failed to rebuild config for cwd {cwd_display}"))
|
||||
}
|
||||
|
||||
async fn refresh_in_memory_config_from_disk(&mut self) -> Result<()> {
|
||||
let mut config = self.rebuild_config_for_cwd(self.config.cwd.clone()).await?;
|
||||
self.apply_runtime_policy_overrides(&mut config);
|
||||
self.config = config;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_runtime_policy_overrides(&mut self, config: &mut Config) {
|
||||
if let Some(policy) = self.runtime_approval_policy_override.as_ref()
|
||||
&& let Err(err) = config.permissions.approval_policy.set(*policy)
|
||||
@@ -1598,19 +1605,24 @@ impl App {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::OpenAppLink {
|
||||
app_id,
|
||||
title,
|
||||
description,
|
||||
instructions,
|
||||
url,
|
||||
is_installed,
|
||||
is_enabled,
|
||||
} => {
|
||||
self.chat_widget.open_app_link_view(
|
||||
title,
|
||||
description,
|
||||
instructions,
|
||||
url,
|
||||
is_installed,
|
||||
);
|
||||
self.chat_widget
|
||||
.open_app_link_view(crate::bottom_pane::AppLinkViewParams {
|
||||
app_id,
|
||||
title,
|
||||
description,
|
||||
instructions,
|
||||
url,
|
||||
is_installed,
|
||||
is_enabled,
|
||||
});
|
||||
}
|
||||
AppEvent::OpenUrlInBrowser { url } => {
|
||||
self.open_url_in_browser(url);
|
||||
@@ -2286,6 +2298,12 @@ impl App {
|
||||
{
|
||||
Ok(()) => {
|
||||
self.chat_widget.update_skill_enabled(path.clone(), enabled);
|
||||
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"failed to refresh config after skill toggle"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let path_display = path.display();
|
||||
@@ -2295,6 +2313,55 @@ 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(),
|
||||
],
|
||||
},
|
||||
]
|
||||
} 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(),
|
||||
},
|
||||
]
|
||||
};
|
||||
match ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.with_edits(edits)
|
||||
.apply()
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
self.chat_widget.update_connector_enabled(&id, enabled);
|
||||
if 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(Op::ReloadUserConfig);
|
||||
}
|
||||
Err(err) => {
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to update app config for {id}: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::OpenPermissionsPopup => {
|
||||
self.chat_widget.open_permissions_popup();
|
||||
}
|
||||
@@ -2929,6 +2996,19 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
fn app_enabled_in_effective_config(config: &Config, app_id: &str) -> Option<bool> {
|
||||
config
|
||||
.config_layer_stack
|
||||
.effective_config()
|
||||
.as_table()
|
||||
.and_then(|table| table.get("apps"))
|
||||
.and_then(TomlValue::as_table)
|
||||
.and_then(|apps| apps.get(app_id))
|
||||
.and_then(TomlValue::as_table)
|
||||
.and_then(|app| app.get("enabled"))
|
||||
.and_then(TomlValue::as_bool)
|
||||
}
|
||||
|
||||
fn all_model_presets() -> Vec<ModelPreset> {
|
||||
codex_core::test_support::all_model_presets().clone()
|
||||
}
|
||||
@@ -3108,6 +3188,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_in_memory_config_from_disk_loads_latest_apps_state() -> Result<()> {
|
||||
let mut app = make_test_app().await;
|
||||
let codex_home = tempdir()?;
|
||||
app.config.codex_home = codex_home.path().to_path_buf();
|
||||
let app_id = "connector_1".to_string();
|
||||
|
||||
assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None);
|
||||
|
||||
ConfigEditsBuilder::new(&app.config.codex_home)
|
||||
.with_edits([
|
||||
ConfigEdit::SetPath {
|
||||
segments: vec!["apps".to_string(), app_id.clone(), "enabled".to_string()],
|
||||
value: false.into(),
|
||||
},
|
||||
ConfigEdit::SetPath {
|
||||
segments: vec![
|
||||
"apps".to_string(),
|
||||
app_id.clone(),
|
||||
"disabled_reason".to_string(),
|
||||
],
|
||||
value: "user".into(),
|
||||
},
|
||||
])
|
||||
.apply()
|
||||
.await
|
||||
.expect("persist app toggle");
|
||||
|
||||
assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None);
|
||||
|
||||
app.refresh_in_memory_config_from_disk().await?;
|
||||
|
||||
assert_eq!(
|
||||
app_enabled_in_effective_config(&app.config, &app_id),
|
||||
Some(false)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backtrack_selection_with_duplicate_history_targets_unique_turn() {
|
||||
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
||||
|
||||
@@ -107,11 +107,13 @@ pub(crate) enum AppEvent {
|
||||
|
||||
/// Open the app link view in the bottom pane.
|
||||
OpenAppLink {
|
||||
app_id: String,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
instructions: String,
|
||||
url: String,
|
||||
is_installed: bool,
|
||||
is_enabled: bool,
|
||||
},
|
||||
|
||||
/// Open the provided URL in the user's browser.
|
||||
@@ -297,6 +299,12 @@ pub(crate) enum AppEvent {
|
||||
enabled: bool,
|
||||
},
|
||||
|
||||
/// Enable or disable an app by connector ID.
|
||||
SetAppEnabled {
|
||||
id: String,
|
||||
enabled: bool,
|
||||
},
|
||||
|
||||
/// Notify that the manage skills popup was closed.
|
||||
ManageSkillsClosed,
|
||||
|
||||
|
||||
@@ -32,12 +32,24 @@ enum AppLinkScreen {
|
||||
InstallConfirmation,
|
||||
}
|
||||
|
||||
pub(crate) struct AppLinkViewParams {
|
||||
pub(crate) app_id: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) instructions: String,
|
||||
pub(crate) url: String,
|
||||
pub(crate) is_installed: bool,
|
||||
pub(crate) is_enabled: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct AppLinkView {
|
||||
app_id: String,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
instructions: String,
|
||||
url: String,
|
||||
is_installed: bool,
|
||||
is_enabled: bool,
|
||||
app_event_tx: AppEventSender,
|
||||
screen: AppLinkScreen,
|
||||
selected_action: usize,
|
||||
@@ -45,20 +57,24 @@ pub(crate) struct AppLinkView {
|
||||
}
|
||||
|
||||
impl AppLinkView {
|
||||
pub(crate) fn new(
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
instructions: String,
|
||||
url: String,
|
||||
is_installed: bool,
|
||||
app_event_tx: AppEventSender,
|
||||
) -> Self {
|
||||
Self {
|
||||
pub(crate) fn new(params: AppLinkViewParams, app_event_tx: AppEventSender) -> Self {
|
||||
let AppLinkViewParams {
|
||||
app_id,
|
||||
title,
|
||||
description,
|
||||
instructions,
|
||||
url,
|
||||
is_installed,
|
||||
is_enabled,
|
||||
} = params;
|
||||
Self {
|
||||
app_id,
|
||||
title,
|
||||
description,
|
||||
instructions,
|
||||
url,
|
||||
is_installed,
|
||||
is_enabled,
|
||||
app_event_tx,
|
||||
screen: AppLinkScreen::Link,
|
||||
selected_action: 0,
|
||||
@@ -66,16 +82,24 @@ impl AppLinkView {
|
||||
}
|
||||
}
|
||||
|
||||
fn action_labels(&self) -> [&'static str; 2] {
|
||||
fn action_labels(&self) -> Vec<&'static str> {
|
||||
match self.screen {
|
||||
AppLinkScreen::Link => {
|
||||
if self.is_installed {
|
||||
["Manage on ChatGPT", "Back"]
|
||||
vec![
|
||||
"Manage on ChatGPT",
|
||||
if self.is_enabled {
|
||||
"Disable app"
|
||||
} else {
|
||||
"Enable app"
|
||||
},
|
||||
"Back",
|
||||
]
|
||||
} else {
|
||||
["Install on ChatGPT", "Back"]
|
||||
vec!["Install on ChatGPT", "Back"]
|
||||
}
|
||||
}
|
||||
AppLinkScreen::InstallConfirmation => ["I already Installed it", "Back"],
|
||||
AppLinkScreen::InstallConfirmation => vec!["I already Installed it", "Back"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,42 +111,47 @@ impl AppLinkView {
|
||||
self.selected_action = (self.selected_action + 1).min(self.action_labels().len() - 1);
|
||||
}
|
||||
|
||||
fn handle_primary_action(&mut self) {
|
||||
match self.screen {
|
||||
AppLinkScreen::Link => {
|
||||
self.app_event_tx.send(AppEvent::OpenUrlInBrowser {
|
||||
url: self.url.clone(),
|
||||
});
|
||||
if !self.is_installed {
|
||||
self.screen = AppLinkScreen::InstallConfirmation;
|
||||
self.selected_action = 0;
|
||||
}
|
||||
}
|
||||
AppLinkScreen::InstallConfirmation => {
|
||||
self.app_event_tx.send(AppEvent::RefreshConnectors {
|
||||
force_refetch: true,
|
||||
});
|
||||
self.complete = true;
|
||||
}
|
||||
fn open_chatgpt_link(&mut self) {
|
||||
self.app_event_tx.send(AppEvent::OpenUrlInBrowser {
|
||||
url: self.url.clone(),
|
||||
});
|
||||
if !self.is_installed {
|
||||
self.screen = AppLinkScreen::InstallConfirmation;
|
||||
self.selected_action = 0;
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_secondary_action(&mut self) {
|
||||
match self.screen {
|
||||
AppLinkScreen::Link => {
|
||||
self.complete = true;
|
||||
}
|
||||
AppLinkScreen::InstallConfirmation => {
|
||||
self.screen = AppLinkScreen::Link;
|
||||
self.selected_action = 0;
|
||||
}
|
||||
}
|
||||
fn refresh_connectors_and_close(&mut self) {
|
||||
self.app_event_tx.send(AppEvent::RefreshConnectors {
|
||||
force_refetch: true,
|
||||
});
|
||||
self.complete = true;
|
||||
}
|
||||
|
||||
fn back_to_link_screen(&mut self) {
|
||||
self.screen = AppLinkScreen::Link;
|
||||
self.selected_action = 0;
|
||||
}
|
||||
|
||||
fn toggle_enabled(&mut self) {
|
||||
self.is_enabled = !self.is_enabled;
|
||||
self.app_event_tx.send(AppEvent::SetAppEnabled {
|
||||
id: self.app_id.clone(),
|
||||
enabled: self.is_enabled,
|
||||
});
|
||||
}
|
||||
|
||||
fn activate_selected_action(&mut self) {
|
||||
match self.selected_action {
|
||||
0 => self.handle_primary_action(),
|
||||
_ => self.handle_secondary_action(),
|
||||
match self.screen {
|
||||
AppLinkScreen::Link => match self.selected_action {
|
||||
0 => self.open_chatgpt_link(),
|
||||
1 if self.is_installed => self.toggle_enabled(),
|
||||
_ => self.complete = true,
|
||||
},
|
||||
AppLinkScreen::InstallConfirmation => match self.selected_action {
|
||||
0 => self.refresh_connectors_and_close(),
|
||||
_ => self.back_to_link_screen(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,20 +337,19 @@ impl BottomPaneView for AppLinkView {
|
||||
..
|
||||
} => self.move_selection_next(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('1'),
|
||||
code: KeyCode::Char(c),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
self.selected_action = 0;
|
||||
self.activate_selected_action();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('2'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
self.selected_action = 1;
|
||||
self.activate_selected_action();
|
||||
if let Some(index) = c
|
||||
.to_digit(10)
|
||||
.and_then(|digit| digit.checked_sub(1))
|
||||
.map(|index| index as usize)
|
||||
&& index < self.action_labels().len()
|
||||
{
|
||||
self.selected_action = index;
|
||||
self.activate_selected_action();
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
@@ -402,3 +430,67 @@ impl crate::render::renderable::Renderable for AppLinkView {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
#[test]
|
||||
fn installed_app_has_toggle_action() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = AppLinkView::new(
|
||||
AppLinkViewParams {
|
||||
app_id: "connector_1".to_string(),
|
||||
title: "Notion".to_string(),
|
||||
description: None,
|
||||
instructions: "Manage app".to_string(),
|
||||
url: "https://example.test/notion".to_string(),
|
||||
is_installed: true,
|
||||
is_enabled: true,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
view.action_labels(),
|
||||
vec!["Manage on ChatGPT", "Disable app", "Back"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_action_sends_set_app_enabled_and_updates_label() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut view = AppLinkView::new(
|
||||
AppLinkViewParams {
|
||||
app_id: "connector_1".to_string(),
|
||||
title: "Notion".to_string(),
|
||||
description: None,
|
||||
instructions: "Manage app".to_string(),
|
||||
url: "https://example.test/notion".to_string(),
|
||||
is_installed: true,
|
||||
is_enabled: true,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
|
||||
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::SetAppEnabled { id, enabled }) => {
|
||||
assert_eq!(id, "connector_1");
|
||||
assert!(!enabled);
|
||||
}
|
||||
Ok(other) => panic!("unexpected app event: {other:?}"),
|
||||
Err(err) => panic!("missing app event: {err}"),
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
view.action_labels(),
|
||||
vec!["Manage on ChatGPT", "Enable app", "Back"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3099,7 +3099,7 @@ impl ChatComposer {
|
||||
&& let Some(snapshot) = self.connectors_snapshot.as_ref()
|
||||
{
|
||||
for connector in &snapshot.connectors {
|
||||
if !connector.is_accessible {
|
||||
if !connector.is_accessible || !connector.is_enabled {
|
||||
continue;
|
||||
}
|
||||
let display_name = connectors::connector_display_label(connector);
|
||||
@@ -4342,6 +4342,7 @@ mod tests {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
}];
|
||||
composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors }));
|
||||
|
||||
@@ -4355,6 +4356,36 @@ mod tests {
|
||||
assert_eq!(mention.path, Some("app://connector_1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_connector_mentions_excludes_disabled_apps_from_mention_popup() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_connectors_enabled(true);
|
||||
composer.set_text_content("$".to_string(), Vec::new(), Vec::new());
|
||||
|
||||
let 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,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: false,
|
||||
}];
|
||||
composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors }));
|
||||
|
||||
assert!(matches!(composer.active_popup, ActivePopup::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_overlay_persists_while_task_running() {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
@@ -44,6 +44,7 @@ mod multi_select_picker;
|
||||
mod request_user_input;
|
||||
mod status_line_setup;
|
||||
pub(crate) use app_link_view::AppLinkView;
|
||||
pub(crate) use app_link_view::AppLinkViewParams;
|
||||
pub(crate) use approval_overlay::ApprovalOverlay;
|
||||
pub(crate) use approval_overlay::ApprovalRequest;
|
||||
pub(crate) use request_user_input::RequestUserInputOverlay;
|
||||
|
||||
@@ -1141,22 +1141,8 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn open_app_link_view(
|
||||
&mut self,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
instructions: String,
|
||||
url: String,
|
||||
is_installed: bool,
|
||||
) {
|
||||
let view = crate::bottom_pane::AppLinkView::new(
|
||||
title,
|
||||
description,
|
||||
instructions,
|
||||
url,
|
||||
is_installed,
|
||||
self.app_event_tx.clone(),
|
||||
);
|
||||
pub(crate) fn open_app_link_view(&mut self, params: crate::bottom_pane::AppLinkViewParams) {
|
||||
let view = crate::bottom_pane::AppLinkView::new(params, self.app_event_tx.clone());
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -3781,7 +3767,7 @@ impl ChatWidget {
|
||||
if !selected_app_ids.insert(app_id.to_string()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(app) = apps.iter().find(|app| app.id == app_id) {
|
||||
if let Some(app) = apps.iter().find(|app| app.id == app_id && app.is_enabled) {
|
||||
items.push(UserInput::Mention {
|
||||
name: app.name.clone(),
|
||||
path: binding.path.clone(),
|
||||
@@ -6414,6 +6400,7 @@ impl ChatWidget {
|
||||
let connector_title = connector_label.clone();
|
||||
let link_description = Self::connector_description(connector);
|
||||
let description = Self::connector_brief_description(connector);
|
||||
let status_label = Self::connector_status_label(connector);
|
||||
let search_value = format!("{connector_label} {}", connector.id);
|
||||
let mut item = SelectionItem {
|
||||
name: connector_label,
|
||||
@@ -6422,42 +6409,47 @@ impl ChatWidget {
|
||||
..Default::default()
|
||||
};
|
||||
let is_installed = connector.is_accessible;
|
||||
let (selected_label, missing_label, instructions) = if connector.is_accessible {
|
||||
(
|
||||
"Press Enter to view the app link.",
|
||||
"App link unavailable.",
|
||||
"Manage this app in your browser.",
|
||||
let selected_label = if is_installed {
|
||||
format!(
|
||||
"{status_label}. Press Enter to open the app page to install, manage, or enable/disable this app."
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"Press Enter to view the install link.",
|
||||
"Install link unavailable.",
|
||||
"Install this app in your browser, then reload Codex.",
|
||||
)
|
||||
format!("{status_label}. Press Enter to open the app page to install this app.")
|
||||
};
|
||||
let missing_label = format!("{status_label}. App link unavailable.");
|
||||
let instructions = if connector.is_accessible {
|
||||
"Manage this app in your browser."
|
||||
} else {
|
||||
"Install this app in your browser, then reload Codex."
|
||||
};
|
||||
if let Some(install_url) = connector.install_url.clone() {
|
||||
let app_id = connector.id.clone();
|
||||
let is_enabled = connector.is_enabled;
|
||||
let title = connector_title.clone();
|
||||
let instructions = instructions.to_string();
|
||||
let description = link_description.clone();
|
||||
item.actions = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenAppLink {
|
||||
app_id: app_id.clone(),
|
||||
title: title.clone(),
|
||||
description: description.clone(),
|
||||
instructions: instructions.clone(),
|
||||
url: install_url.clone(),
|
||||
is_installed,
|
||||
is_enabled,
|
||||
});
|
||||
})];
|
||||
item.dismiss_on_select = true;
|
||||
item.selected_description = Some(selected_label.to_string());
|
||||
item.selected_description = Some(selected_label);
|
||||
} else {
|
||||
let missing_label_for_action = missing_label.clone();
|
||||
item.actions = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_info_event(missing_label.to_string(), None),
|
||||
history_cell::new_info_event(missing_label_for_action.clone(), None),
|
||||
)));
|
||||
})];
|
||||
item.dismiss_on_select = true;
|
||||
item.selected_description = Some(missing_label.to_string());
|
||||
item.selected_description = Some(missing_label);
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
@@ -6490,17 +6482,25 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn connector_brief_description(connector: &connectors::AppInfo) -> String {
|
||||
let status_label = if connector.is_accessible {
|
||||
"Connected"
|
||||
} else {
|
||||
"Can be installed"
|
||||
};
|
||||
let status_label = Self::connector_status_label(connector);
|
||||
match Self::connector_description(connector) {
|
||||
Some(description) => format!("{status_label} · {description}"),
|
||||
None => status_label.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn connector_status_label(connector: &connectors::AppInfo) -> &'static str {
|
||||
if connector.is_accessible {
|
||||
if connector.is_enabled {
|
||||
"Installed"
|
||||
} else {
|
||||
"Installed · Disabled"
|
||||
}
|
||||
} else {
|
||||
"Can be installed"
|
||||
}
|
||||
}
|
||||
|
||||
fn connector_description(connector: &connectors::AppInfo) -> Option<String> {
|
||||
connector
|
||||
.description
|
||||
@@ -6727,7 +6727,27 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(snapshot) => {
|
||||
Ok(mut snapshot) => {
|
||||
if !is_final {
|
||||
snapshot.connectors = connectors::merge_connectors_with_accessible(
|
||||
Vec::new(),
|
||||
snapshot.connectors,
|
||||
);
|
||||
}
|
||||
snapshot.connectors =
|
||||
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
|
||||
.iter()
|
||||
.map(|connector| (connector.id.as_str(), connector.is_enabled))
|
||||
.collect();
|
||||
for connector in &mut snapshot.connectors {
|
||||
if let Some(is_enabled) = enabled_by_id.get(connector.id.as_str()) {
|
||||
connector.is_enabled = *is_enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.refresh_connectors_popup_if_open(&snapshot.connectors);
|
||||
if is_final || !matches!(self.connectors_cache, ConnectorsCacheState::Ready(_)) {
|
||||
self.connectors_cache = ConnectorsCacheState::Ready(snapshot.clone());
|
||||
@@ -6746,6 +6766,29 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn update_connector_enabled(&mut self, connector_id: &str, enabled: bool) {
|
||||
let ConnectorsCacheState::Ready(mut snapshot) = self.connectors_cache.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut changed = false;
|
||||
for connector in &mut snapshot.connectors {
|
||||
if connector.id == connector_id {
|
||||
changed = connector.is_enabled != enabled;
|
||||
connector.is_enabled = enabled;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
|
||||
self.refresh_connectors_popup_if_open(&snapshot.connectors);
|
||||
self.connectors_cache = ConnectorsCacheState::Ready(snapshot.clone());
|
||||
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
|
||||
}
|
||||
|
||||
pub(crate) fn open_review_popup(&mut self) {
|
||||
let mut items: Vec<SelectionItem> = Vec::new();
|
||||
|
||||
|
||||
@@ -267,12 +267,12 @@ pub(crate) fn find_app_mentions(
|
||||
}
|
||||
|
||||
let mut slug_counts: HashMap<String, usize> = HashMap::new();
|
||||
for app in apps {
|
||||
for app in apps.iter().filter(|app| app.is_enabled) {
|
||||
let slug = connector_mention_slug(app);
|
||||
*slug_counts.entry(slug).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
for app in apps {
|
||||
for app in apps.iter().filter(|app| app.is_enabled) {
|
||||
let slug = connector_mention_slug(app);
|
||||
let slug_count = slug_counts.get(&slug).copied().unwrap_or(0);
|
||||
if mentions.names.contains(&slug)
|
||||
@@ -285,7 +285,7 @@ pub(crate) fn find_app_mentions(
|
||||
}
|
||||
|
||||
apps.iter()
|
||||
.filter(|app| selected_ids.contains(&app.id))
|
||||
.filter(|app| app.is_enabled && selected_ids.contains(&app.id))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -3878,6 +3878,7 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
}],
|
||||
}),
|
||||
false,
|
||||
@@ -3893,6 +3894,10 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() {
|
||||
before.contains("Installed 1 of 1 available apps."),
|
||||
"expected initial apps popup snapshot, got:\n{before}"
|
||||
);
|
||||
assert!(
|
||||
before.contains("Installed. Press Enter to open the app page"),
|
||||
"expected selected app description to explain the app page action, got:\n{before}"
|
||||
);
|
||||
|
||||
chat.on_connectors_loaded(
|
||||
Ok(ConnectorsSnapshot {
|
||||
@@ -3906,6 +3911,7 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
},
|
||||
codex_chatgpt::connectors::AppInfo {
|
||||
id: "connector_2".to_string(),
|
||||
@@ -3916,6 +3922,7 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/linear".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -3949,6 +3956,7 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
},
|
||||
codex_chatgpt::connectors::AppInfo {
|
||||
id: "connector_2".to_string(),
|
||||
@@ -3959,6 +3967,7 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/linear".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
];
|
||||
chat.on_connectors_loaded(
|
||||
@@ -3979,6 +3988,7 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
}],
|
||||
}),
|
||||
false,
|
||||
@@ -3998,6 +4008,265 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.features.enable(Feature::Apps);
|
||||
chat.bottom_pane.set_connectors_enabled(true);
|
||||
|
||||
let full_connectors = vec![
|
||||
codex_chatgpt::connectors::AppInfo {
|
||||
id: "unit_test_connector_1".to_string(),
|
||||
name: "Notion".to_string(),
|
||||
description: Some("Workspace docs".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
},
|
||||
codex_chatgpt::connectors::AppInfo {
|
||||
id: "unit_test_connector_2".to_string(),
|
||||
name: "Linear".to_string(),
|
||||
description: Some("Project tracking".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/linear".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
];
|
||||
chat.on_connectors_loaded(
|
||||
Ok(ConnectorsSnapshot {
|
||||
connectors: full_connectors.clone(),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
chat.add_connectors_output();
|
||||
|
||||
chat.on_connectors_loaded(
|
||||
Ok(ConnectorsSnapshot {
|
||||
connectors: vec![
|
||||
codex_chatgpt::connectors::AppInfo {
|
||||
id: "unit_test_connector_1".to_string(),
|
||||
name: "Notion".to_string(),
|
||||
description: Some("Workspace docs".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
},
|
||||
codex_chatgpt::connectors::AppInfo {
|
||||
id: "connector_openai_hidden".to_string(),
|
||||
name: "Hidden OpenAI".to_string(),
|
||||
description: Some("Should be filtered".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/hidden-openai".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert_matches!(
|
||||
&chat.connectors_cache,
|
||||
ConnectorsCacheState::Ready(snapshot) if snapshot.connectors == full_connectors
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, 80);
|
||||
assert!(
|
||||
popup.contains("Installed 1 of 1 available apps."),
|
||||
"expected partial refresh popup to use filtered connectors, got:\n{popup}"
|
||||
);
|
||||
assert!(
|
||||
!popup.contains("Hidden OpenAI"),
|
||||
"expected disallowed connector to be filtered from partial refresh popup, got:\n{popup}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_popup_shows_disabled_status_for_installed_but_disabled_apps() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.features.enable(Feature::Apps);
|
||||
chat.bottom_pane.set_connectors_enabled(true);
|
||||
|
||||
chat.on_connectors_loaded(
|
||||
Ok(ConnectorsSnapshot {
|
||||
connectors: vec![codex_chatgpt::connectors::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,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: false,
|
||||
}],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
chat.add_connectors_output();
|
||||
let popup = render_bottom_popup(&chat, 80);
|
||||
assert!(
|
||||
popup.contains("Installed · Disabled. Press Enter to open the app page"),
|
||||
"expected selected app description to include disabled status, got:\n{popup}"
|
||||
);
|
||||
assert!(
|
||||
popup.contains("enable/disable this app."),
|
||||
"expected selected app description to mention enable/disable action, got:\n{popup}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_initial_load_applies_enabled_state_from_config() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.features.enable(Feature::Apps);
|
||||
chat.bottom_pane.set_connectors_enabled(true);
|
||||
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let config_toml_path =
|
||||
AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path");
|
||||
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![codex_chatgpt::connectors::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,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
}],
|
||||
}),
|
||||
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_refresh_preserves_toggled_enabled_state() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.features.enable(Feature::Apps);
|
||||
chat.bottom_pane.set_connectors_enabled(true);
|
||||
|
||||
chat.on_connectors_loaded(
|
||||
Ok(ConnectorsSnapshot {
|
||||
connectors: vec![codex_chatgpt::connectors::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,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
}],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
chat.update_connector_enabled("connector_1", false);
|
||||
|
||||
chat.on_connectors_loaded(
|
||||
Ok(ConnectorsSnapshot {
|
||||
connectors: vec![codex_chatgpt::connectors::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,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
}],
|
||||
}),
|
||||
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, 80);
|
||||
assert!(
|
||||
popup.contains("Installed · Disabled. Press Enter to open the app page"),
|
||||
"expected disabled status to persist after reload, got:\n{popup}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_popup_for_not_installed_app_uses_install_only_selected_description() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.features.enable(Feature::Apps);
|
||||
chat.bottom_pane.set_connectors_enabled(true);
|
||||
|
||||
chat.on_connectors_loaded(
|
||||
Ok(ConnectorsSnapshot {
|
||||
connectors: vec![codex_chatgpt::connectors::AppInfo {
|
||||
id: "connector_2".to_string(),
|
||||
name: "Linear".to_string(),
|
||||
description: Some("Project tracking".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/linear".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
}],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
chat.add_connectors_output();
|
||||
let popup = render_bottom_popup(&chat, 80);
|
||||
assert!(
|
||||
popup.contains("Can be installed. Press Enter to open the app page to install"),
|
||||
"expected selected app description to be install-only for not-installed apps, got:\n{popup}"
|
||||
);
|
||||
assert!(
|
||||
!popup.contains("enable/disable this app."),
|
||||
"did not expect enable/disable text for not-installed apps, got:\n{popup}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn experimental_features_popup_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
Reference in New Issue
Block a user