[connectors] Support connectors part 2 - slash command and tui (#9728)

- [x] Support `/apps` slash command to browse the apps in tui.
- [x] Support inserting apps to prompt using `$`.
- [x] Lots of simplification/renaming from connectors to apps.
This commit is contained in:
Matthew Zeng authored and GitHub committed 2026-01-28 19:51:58 -08:00
1 parent ecc66f4f52
commit b9cd089d1f
36 files changed
+2025 -362

No files matched your search

+172 -24
View File
@@ -118,6 +118,10 @@ use crate::mcp::effective_mcp_servers;
use crate::mcp::maybe_prompt_and_install_mcp_dependencies;
use crate::mcp::with_codex_apps_mcp;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::mentions::build_connector_slug_counts;
use crate::mentions::build_skill_name_counts;
use crate::mentions::collect_explicit_app_paths;
use crate::mentions::collect_tool_mentions_from_messages;
use crate::model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY;
use crate::project_doc::get_user_instructions;
use crate::protocol::AgentMessageContentDeltaEvent;
@@ -163,6 +167,9 @@ use crate::skills::SkillMetadata;
use crate::skills::SkillsManager;
use crate::skills::build_skill_injections;
use crate::skills::collect_explicit_skill_mentions;
use crate::skills::injection::ToolMentionKind;
use crate::skills::injection::app_id_from_path;
use crate::skills::injection::tool_kind_for_path;
use crate::state::ActiveTurn;
use crate::state::SessionServices;
use crate::state::SessionState;
@@ -2189,7 +2196,7 @@ impl Session {
let config = self.get_config().await;
let mcp_servers = with_codex_apps_mcp(
mcp_servers,
self.features.enabled(Feature::Connectors),
self.features.enabled(Feature::Apps),
auth.as_ref(),
config.as_ref(),
);
@@ -3166,9 +3173,38 @@ pub(crate) async fn run_turn(
.await,
);
let (skill_name_counts, skill_name_counts_lower) = skills_outcome.as_ref().map_or_else(
|| (HashMap::new(), HashMap::new()),
|outcome| build_skill_name_counts(&outcome.skills, &outcome.disabled_paths),
);
let connector_slug_counts = if turn_context.client.config().features.enabled(Feature::Apps) {
let mcp_tools = match sess
.services
.mcp_connection_manager
.read()
.await
.list_all_tools()
.or_cancel(&cancellation_token)
.await
{
Ok(mcp_tools) => mcp_tools,
Err(_) => return None,
};
let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools);
build_connector_slug_counts(&connectors)
} else {
HashMap::new()
};
let mentioned_skills = skills_outcome.as_ref().map_or_else(Vec::new, |outcome| {
collect_explicit_skill_mentions(&input, &outcome.skills, &outcome.disabled_paths)
collect_explicit_skill_mentions(
&input,
&outcome.skills,
&outcome.disabled_paths,
&skill_name_counts,
&connector_slug_counts,
)
});
let explicit_app_paths = collect_explicit_app_paths(&input);
maybe_prompt_and_install_mcp_dependencies(
sess.as_ref(),
@@ -3234,12 +3270,17 @@ pub(crate) async fn run_turn(
})
.map(|user_message| user_message.message())
.collect::<Vec<String>>();
let tool_selection = SamplingRequestToolSelection {
explicit_app_paths: &explicit_app_paths,
skill_name_counts_lower: &skill_name_counts_lower,
};
match run_sampling_request(
Arc::clone(&sess),
Arc::clone(&turn_context),
Arc::clone(&turn_diff_tracker),
&mut client_session,
sampling_request_input,
tool_selection,
cancellation_token.child_token(),
)
.await
@@ -3314,40 +3355,79 @@ async fn run_auto_compact(sess: &Arc<Session>, turn_context: &Arc<TurnContext>)
}
fn filter_connectors_for_input(
connectors: Vec<connectors::ConnectorInfo>,
connectors: Vec<connectors::AppInfo>,
input: &[ResponseItem],
) -> Vec<connectors::ConnectorInfo> {
explicit_app_paths: &[String],
skill_name_counts_lower: &HashMap<String, usize>,
) -> Vec<connectors::AppInfo> {
let user_messages = collect_user_messages(input);
if user_messages.is_empty() {
if user_messages.is_empty() && explicit_app_paths.is_empty() {
return Vec::new();
}
let mentions = collect_tool_mentions_from_messages(&user_messages);
let mention_names_lower = mentions
.plain_names
.iter()
.map(|name| name.to_ascii_lowercase())
.collect::<HashSet<String>>();
let connector_slug_counts = build_connector_slug_counts(&connectors);
let mut allowed_connector_ids: HashSet<String> = HashSet::new();
for path in explicit_app_paths
.iter()
.chain(mentions.paths.iter())
.filter(|path| tool_kind_for_path(path) == ToolMentionKind::App)
{
if let Some(connector_id) = app_id_from_path(path) {
allowed_connector_ids.insert(connector_id.to_string());
}
}
connectors
.into_iter()
.filter(|connector| connector_inserted_in_messages(connector, &user_messages))
.filter(|connector| {
connector_inserted_in_messages(
connector,
&mention_names_lower,
&allowed_connector_ids,
&connector_slug_counts,
skill_name_counts_lower,
)
})
.collect()
}
fn connector_inserted_in_messages(
connector: &connectors::ConnectorInfo,
user_messages: &[String],
connector: &connectors::AppInfo,
mention_names_lower: &HashSet<String>,
allowed_connector_ids: &HashSet<String>,
connector_slug_counts: &HashMap<String, usize>,
skill_name_counts_lower: &HashMap<String, usize>,
) -> bool {
let label = connectors::connector_display_label(connector);
let needle = label.to_lowercase();
let legacy = format!("{label} connector").to_lowercase();
user_messages.iter().any(|message| {
let message = message.to_lowercase();
message.contains(&needle) || message.contains(&legacy)
})
if allowed_connector_ids.contains(&connector.id) {
return true;
}
let mention_slug = connectors::connector_mention_slug(connector);
let connector_count = connector_slug_counts
.get(&mention_slug)
.copied()
.unwrap_or(0);
let skill_count = skill_name_counts_lower
.get(&mention_slug)
.copied()
.unwrap_or(0);
connector_count == 1 && skill_count == 0 && mention_names_lower.contains(&mention_slug)
}
fn filter_codex_apps_mcp_tools(
mut mcp_tools: HashMap<String, crate::mcp_connection_manager::ToolInfo>,
connectors: &[connectors::ConnectorInfo],
connectors: &[connectors::AppInfo],
) -> HashMap<String, crate::mcp_connection_manager::ToolInfo> {
let allowed: HashSet<&str> = connectors
.iter()
.map(|connector| connector.connector_id.as_str())
.map(|connector| connector.id.as_str())
.collect();
mcp_tools.retain(|_, tool| {
@@ -3367,6 +3447,11 @@ fn codex_apps_connector_id(tool: &crate::mcp_connection_manager::ToolInfo) -> Op
tool.connector_id.as_deref()
}
struct SamplingRequestToolSelection<'a> {
explicit_app_paths: &'a [String],
skill_name_counts_lower: &'a HashMap<String, usize>,
}
#[instrument(level = "trace",
skip_all,
fields(
@@ -3381,6 +3466,7 @@ async fn run_sampling_request(
turn_diff_tracker: SharedTurnDiffTracker,
client_session: &mut ModelClientSession,
input: Vec<ResponseItem>,
tool_selection: SamplingRequestToolSelection<'_>,
cancellation_token: CancellationToken,
) -> CodexResult<SamplingRequestResult> {
let mut mcp_tools = sess
@@ -3391,14 +3477,14 @@ async fn run_sampling_request(
.list_all_tools()
.or_cancel(&cancellation_token)
.await?;
let connectors_for_tools = if turn_context
.client
.config()
.features
.enabled(Feature::Connectors)
{
let connectors_for_tools = if turn_context.client.config().features.enabled(Feature::Apps) {
let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools);
Some(filter_connectors_for_input(connectors, &input))
Some(filter_connectors_for_input(
connectors,
&input,
tool_selection.explicit_app_paths,
tool_selection.skill_name_counts_lower,
))
} else {
None
};
@@ -3822,6 +3908,7 @@ mod tests {
use crate::tools::handlers::UnifiedExecHandler;
use crate::tools::registry::ToolHandler;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AuthMode;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
@@ -3843,6 +3930,30 @@ mod tests {
expects_apply_patch_instructions: bool,
}
fn user_message(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
end_turn: None,
}
}
fn make_connector(id: &str, name: &str) -> AppInfo {
AppInfo {
id: id.to_string(),
name: name.to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: None,
is_accessible: true,
}
}
#[tokio::test]
async fn get_base_instructions_no_user_content() {
let prompt_with_apply_patch_instructions =
@@ -3909,6 +4020,43 @@ mod tests {
}
}
#[test]
fn filter_connectors_for_input_skips_duplicate_slug_mentions() {
let connectors = vec![
make_connector("one", "Foo Bar"),
make_connector("two", "Foo-Bar"),
];
let input = vec![user_message("use $foo-bar")];
let explicit_app_paths = Vec::new();
let skill_name_counts_lower = HashMap::new();
let selected = filter_connectors_for_input(
connectors,
&input,
&explicit_app_paths,
&skill_name_counts_lower,
);
assert_eq!(selected, Vec::new());
}
#[test]
fn filter_connectors_for_input_skips_when_skill_name_conflicts() {
let connectors = vec![make_connector("one", "Todoist")];
let input = vec![user_message("use $todoist")];
let explicit_app_paths = Vec::new();
let skill_name_counts_lower = HashMap::from([("todoist".to_string(), 1)]);
let selected = filter_connectors_for_input(
connectors,
&input,
&explicit_app_paths,
&skill_name_counts_lower,
);
assert_eq!(selected, Vec::new());
}
#[tokio::test]
async fn reconstruct_history_matches_live_compactions() {
let (session, turn_context) = make_session_and_context().await;