Compare commits

..

3 Commits

Author SHA1 Message Date
pash-openai
3eb9115cef [tui] Update fast mode plan usage copy (#13515)
## Summary
- update the /fast slash command description from 3X to 2X plan usage

## Testing
- not run (copy-only change)
2026-03-05 04:23:20 +00:00
pash-openai
3284bde48e [tui] rotate paid promo tips to include fast mode (#13438)
- rotate the paid-plan startup promo slot 50/50 between the existing
Codex App promo and a new Fast mode promo
- keep the Fast mode call to action platform-neutral so Windows can show
the same tip
- add a focused unit test to ensure the paid promo pool actually rotates

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-04 20:06:44 -08:00
pash-openai
394e538640 [core] Enable fast mode by default (#13450)
Co-authored-by: Codex <noreply@openai.com>
2026-03-04 20:06:35 -08:00
14 changed files with 207 additions and 814 deletions

View File

@@ -201,12 +201,9 @@ use crate::memories;
use crate::mentions::build_connector_slug_counts;
use crate::mentions::build_skill_name_counts;
use crate::mentions::collect_explicit_app_ids;
use crate::mentions::collect_explicit_plugin_mentions;
use crate::mentions::collect_tool_mentions_from_messages;
use crate::network_policy_decision::execpolicy_network_rule_amendment;
use crate::plugins::PluginCapabilitySummary;
use crate::plugins::PluginsManager;
use crate::plugins::render_explicit_plugin_instructions;
use crate::project_doc::get_user_instructions;
use crate::protocol::AgentMessageContentDeltaEvent;
use crate::protocol::AgentReasoningSectionBreakEvent;
@@ -1527,7 +1524,7 @@ impl Session {
tool_approvals: Mutex::new(ApprovalStore::default()),
execve_session_approvals: RwLock::new(HashMap::new()),
skills_manager,
plugins_manager: Arc::clone(&plugins_manager),
plugins_manager,
mcp_manager,
file_watcher,
agent_control,
@@ -1612,10 +1609,6 @@ impl Session {
.map(|(name, _)| name.clone())
.collect();
required_mcp_servers.sort();
let tool_plugin_provenance = plugins_manager
.plugins_for_config(config.as_ref())
.tool_plugin_provenance()
.clone();
{
let mut cancel_guard = sess.services.mcp_startup_cancellation_token.lock().await;
cancel_guard.cancel();
@@ -1630,7 +1623,6 @@ impl Session {
sandbox_state,
config.codex_home.clone(),
codex_apps_tools_cache_key(auth),
tool_plugin_provenance,
)
.await;
{
@@ -2186,12 +2178,11 @@ impl Session {
&per_turn_config,
)
.await;
// Reuse the session's resolved config for turn-time skills so plugin-backed
// skill/plugin availability stays aligned with the session-start view.
let skills_outcome = Arc::new(
self.services
.skills_manager
.skills_for_config(&per_turn_config),
.skills_for_cwd(&session_configuration.cwd, false)
.await,
);
let mut turn_context: TurnContext = Self::make_turn_context(
Some(Arc::clone(&self.services.auth_manager)),
@@ -3577,12 +3568,6 @@ impl Session {
) {
let auth = self.services.auth_manager.auth().await;
let config = self.get_config().await;
let tool_plugin_provenance = self
.services
.plugins_manager
.plugins_for_config(config.as_ref())
.tool_plugin_provenance()
.clone();
let mcp_servers = with_codex_apps_mcp(
mcp_servers,
self.features.enabled(Feature::Apps),
@@ -3610,7 +3595,6 @@ impl Session {
sandbox_state,
config.codex_home.clone(),
codex_apps_tools_cache_key(auth.as_ref()),
tool_plugin_provenance,
)
.await;
{
@@ -4937,57 +4921,25 @@ pub(crate) async fn run_turn(
sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref())
.await;
let loaded_plugins = sess
.services
.plugins_manager
.plugins_for_config(&turn_context.config);
// Plain-text @plugin mentions are resolved from the current session's
// enabled plugins, then converted into turn-scoped guidance below.
let mentioned_plugins =
collect_explicit_plugin_mentions(&input, loaded_plugins.capability_summaries());
info!(
plugin_input_texts = ?input
.iter()
.filter_map(|item| match item {
UserInput::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>(),
available_plugins = ?loaded_plugins
.capability_summaries()
.iter()
.map(|plugin| plugin.display_name.as_str())
.collect::<Vec<_>>(),
mentioned_plugins = ?mentioned_plugins
.iter()
.map(|plugin| plugin.display_name.as_str())
.collect::<Vec<_>>(),
"resolved explicit plugin mentions for turn"
);
let mcp_tools =
if turn_context.config.features.enabled(Feature::Apps) || !mentioned_plugins.is_empty() {
// Plugin mentions need raw MCP/app inventory even when app tools
// are normally hidden so we can describe the plugin's currently
// usable capabilities for this turn.
match sess
.services
.mcp_connection_manager
.read()
.await
.list_all_tools()
.or_cancel(&cancellation_token)
.await
{
Ok(mcp_tools) => mcp_tools,
Err(_) if turn_context.config.features.enabled(Feature::Apps) => return None,
Err(_) => HashMap::new(),
}
} else {
HashMap::new()
};
let available_connectors = if turn_context.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 plugin_apps = sess
.services
.plugins_manager
.plugins_for_config(&turn_context.config);
let connectors = connectors::merge_plugin_apps_with_accessible(
loaded_plugins.effective_apps(),
plugin_apps.effective_apps(),
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
);
connectors::with_app_enabled_state(connectors, &turn_context.config)
@@ -5048,29 +5000,12 @@ pub(crate) async fn run_turn(
.await;
}
let plugin_items =
build_plugin_injections(&mentioned_plugins, &mcp_tools, &available_connectors);
let mut explicitly_enabled_connectors = collect_explicit_app_ids(&input);
explicitly_enabled_connectors.extend(collect_explicit_app_ids_from_skill_items(
&skill_items,
&available_connectors,
&skill_name_counts_lower,
));
// Explicit @plugin mentions can make a plugin's enabled apps callable for
// this turn without persisting those connectors as sticky user selections.
let mut turn_enabled_connectors = explicitly_enabled_connectors.clone();
turn_enabled_connectors.extend(
mentioned_plugins
.iter()
.flat_map(|plugin| plugin.app_connector_ids.iter())
.map(|connector_id| connector_id.0.clone())
.filter(|connector_id| {
available_connectors
.iter()
.any(|connector| connector.is_enabled && connector.id == *connector_id)
}),
);
let connector_names_by_id = available_connectors
.iter()
.map(|connector| (connector.id.as_str(), connector.name.as_str()))
@@ -5108,10 +5043,6 @@ pub(crate) async fn run_turn(
sess.record_conversation_items(&turn_context, &skill_items)
.await;
}
if !plugin_items.is_empty() {
sess.record_conversation_items(&turn_context, &plugin_items)
.await;
}
sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token())
.await;
@@ -5180,7 +5111,7 @@ pub(crate) async fn run_turn(
&mut client_session,
turn_metadata_header.as_deref(),
sampling_request_input,
&turn_enabled_connectors,
&explicitly_enabled_connectors,
skills_outcome,
&mut server_model_warning_emitted_for_turn,
cancellation_token.child_token(),
@@ -5726,17 +5657,17 @@ async fn built_tools(
.or_cancel(cancellation_token)
.await?;
drop(mcp_connection_manager);
let loaded_plugins = sess
.services
.plugins_manager
.plugins_for_config(&turn_context.config);
let mut effective_explicitly_enabled_connectors = explicitly_enabled_connectors.clone();
effective_explicitly_enabled_connectors.extend(sess.get_connector_selection().await);
let connectors = if turn_context.features.enabled(Feature::Apps) {
let plugin_apps = sess
.services
.plugins_manager
.plugins_for_config(&turn_context.config);
let connectors = connectors::merge_plugin_apps_with_accessible(
loaded_plugins.effective_apps(),
plugin_apps.effective_apps(),
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
);
Some(connectors::with_app_enabled_state(
@@ -5747,8 +5678,6 @@ async fn built_tools(
None
};
// Keep the connector-grouped app view around for the router even though
// app tools only become prompt-visible after explicit selection/discovery.
let app_tools = connectors.as_ref().map(|connectors| {
filter_codex_apps_mcp_tools(&mcp_tools, connectors, &turn_context.config)
});
@@ -5793,55 +5722,6 @@ async fn built_tools(
)))
}
fn build_plugin_injections(
mentioned_plugins: &[PluginCapabilitySummary],
mcp_tools: &HashMap<String, crate::mcp_connection_manager::ToolInfo>,
available_connectors: &[connectors::AppInfo],
) -> Vec<ResponseItem> {
if mentioned_plugins.is_empty() {
return Vec::new();
}
let visible_mcp_server_names = mcp_tools
.values()
.filter(|tool| tool.server_name != CODEX_APPS_MCP_SERVER_NAME)
.map(|tool| tool.server_name.clone())
.collect::<HashSet<String>>();
let enabled_connectors_by_id = available_connectors
.iter()
.filter(|connector| connector.is_enabled)
.map(|connector| {
(
connector.id.as_str(),
connectors::connector_display_label(connector),
)
})
.collect::<HashMap<&str, String>>();
// Turn each explicit @plugin mention into a developer hint that points the
// model at the plugin's visible MCP servers, enabled apps, and skill prefix.
mentioned_plugins
.iter()
.filter_map(|plugin| {
let available_mcp_servers = plugin
.mcp_server_names
.iter()
.filter(|server_name| visible_mcp_server_names.contains(server_name.as_str()))
.cloned()
.collect::<Vec<_>>();
let available_apps = plugin
.app_connector_ids
.iter()
.filter_map(|connector_id| enabled_connectors_by_id.get(connector_id.0.as_str()))
.cloned()
.collect::<Vec<_>>();
render_explicit_plugin_instructions(plugin, &available_mcp_servers, &available_apps)
.map(DeveloperInstructions::new)
.map(ResponseItem::from)
})
.collect()
}
#[derive(Debug)]
struct SamplingRequestResult {
needs_follow_up: bool,

View File

@@ -31,7 +31,6 @@ use crate::mcp::with_codex_apps_mcp;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
use crate::plugins::AppConnectorId;
use crate::plugins::ToolPluginProvenance;
use crate::token_data::TokenData;
pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600);
@@ -163,7 +162,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
sandbox_state,
config.codex_home.clone(),
codex_apps_tools_cache_key(auth.as_ref()),
ToolPluginProvenance::default(),
)
.await;

View File

@@ -708,8 +708,8 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::FastMode,
key: "fast_mode",
stage: Stage::UnderDevelopment,
default_enabled: false,
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::VoiceTranscription,

View File

@@ -217,12 +217,7 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent
config.cli_auth_credentials_store_mode,
);
let auth = auth_manager.auth().await;
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone()));
let tool_plugin_provenance = plugins_manager
.plugins_for_config(config)
.tool_plugin_provenance()
.clone();
let mcp_manager = McpManager::new(plugins_manager);
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
let mcp_servers = mcp_manager.effective_servers(config, auth.as_ref());
if mcp_servers.is_empty() {
return McpListToolsResponseEvent {
@@ -256,7 +251,6 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent
sandbox_state,
config.codex_home.clone(),
codex_apps_tools_cache_key(auth.as_ref()),
tool_plugin_provenance,
)
.await;

View File

@@ -6,7 +6,6 @@
//! in a single aggregated map using the fully-qualified tool name
//! `"<server><MCP_TOOL_NAME_DELIMITER><tool>"` as the key.
use std::borrow::Cow;
use std::collections::HashMap;
use std::collections::HashSet;
use std::env;
@@ -80,7 +79,6 @@ use crate::codex::INITIAL_SUBMIT_ID;
use crate::config::types::McpServerConfig;
use crate::config::types::McpServerTransportConfig;
use crate::connectors::is_connector_id_allowed;
use crate::plugins::ToolPluginProvenance;
/// Delimiter used to separate the server name from the tool name in a fully
/// qualified tool name.
@@ -343,7 +341,6 @@ struct ManagedClient {
tool_timeout: Option<Duration>,
server_supports_sandbox_state_capability: bool,
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
tool_plugin_provenance: Arc<ToolPluginProvenance>,
}
impl ManagedClient {
@@ -353,17 +350,12 @@ impl ManagedClient {
&& let CachedCodexAppsToolsLoad::Hit(tools) =
load_cached_codex_apps_tools(cache_context)
{
// Keep the disk cache raw because plugin provenance is session/config-scoped,
// while the codex_apps cache is only keyed per user account.
emit_duration(
MCP_TOOLS_LIST_DURATION_METRIC,
total_start.elapsed(),
&[("cache", "hit")],
);
return annotate_tools_with_plugin_sources(
filter_tools(tools, &self.tool_filter),
self.tool_plugin_provenance.as_ref(),
);
return filter_tools(tools, &self.tool_filter);
}
if self.codex_apps_tools_cache_context.is_some() {
@@ -402,9 +394,6 @@ struct AsyncManagedClient {
}
impl AsyncManagedClient {
// Keep this constructor flat so the startup inputs remain readable at the
// single call site instead of introducing a one-off params wrapper.
#[allow(clippy::too_many_arguments)]
fn new(
server_name: String,
config: McpServerConfig,
@@ -413,23 +402,16 @@ impl AsyncManagedClient {
tx_event: Sender<Event>,
elicitation_requests: ElicitationRequestManager,
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
tool_plugin_provenance: Arc<ToolPluginProvenance>,
) -> Self {
let tool_filter = ToolFilter::from_config(&config);
let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot(
&server_name,
codex_apps_tools_cache_context.as_ref(),
)
.map(|tools| {
annotate_tools_with_plugin_sources(
filter_tools(tools, &tool_filter),
tool_plugin_provenance.as_ref(),
)
});
.map(|tools| filter_tools(tools, &tool_filter));
let startup_tool_filter = tool_filter;
let startup_complete = Arc::new(AtomicBool::new(false));
let startup_complete_for_fut = Arc::clone(&startup_complete);
let startup_tool_plugin_provenance = Arc::clone(&tool_plugin_provenance);
let fut = async move {
let outcome = async {
if let Err(error) = validate_mcp_server_name(&server_name) {
@@ -450,7 +432,6 @@ impl AsyncManagedClient {
tx_event,
elicitation_requests,
codex_apps_tools_cache_context,
tool_plugin_provenance: startup_tool_plugin_provenance,
},
)
.or_cancel(&cancel_token)
@@ -571,14 +552,12 @@ impl McpConnectionManager {
initial_sandbox_state: SandboxState,
codex_home: PathBuf,
codex_apps_tools_cache_key: CodexAppsToolsCacheKey,
tool_plugin_provenance: ToolPluginProvenance,
) -> (Self, CancellationToken) {
let cancel_token = CancellationToken::new();
let mut clients = HashMap::new();
let mut server_origins = HashMap::new();
let mut join_set = JoinSet::new();
let elicitation_requests = ElicitationRequestManager::new(approval_policy.value());
let tool_plugin_provenance = Arc::new(tool_plugin_provenance);
let mcp_servers = mcp_servers.clone();
for (server_name, cfg) in mcp_servers.into_iter().filter(|(_, cfg)| cfg.enabled) {
if let Some(origin) = transport_origin(&cfg.transport) {
@@ -609,7 +588,6 @@ impl McpConnectionManager {
tx_event.clone(),
elicitation_requests.clone(),
codex_apps_tools_cache_context,
Arc::clone(&tool_plugin_provenance),
);
clients.insert(server_name.clone(), async_managed_client.clone());
let tx_event = tx_event.clone();
@@ -1157,55 +1135,6 @@ pub(crate) fn filter_mcp_tools_by_name(
.collect()
}
fn annotate_tools_with_plugin_sources(
mut tools: Vec<ToolInfo>,
tool_plugin_provenance: &ToolPluginProvenance,
) -> Vec<ToolInfo> {
for tool in &mut tools {
let plugin_names = match tool.connector_id.as_deref() {
Some(connector_id) => {
tool_plugin_provenance.plugin_display_names_for_connector_id(connector_id)
}
None => tool_plugin_provenance
.plugin_display_names_for_mcp_server_name(tool.server_name.as_str()),
};
if plugin_names.is_empty() {
continue;
}
let plugin_source_note = if plugin_names.len() == 1 {
format!("This tool is part of plugin `{}`.", plugin_names[0])
} else {
format!(
"This tool is part of plugins {}.",
plugin_names
.iter()
.map(|plugin_name| format!("`{plugin_name}`"))
.collect::<Vec<_>>()
.join(", ")
)
};
let description = tool
.tool
.description
.as_deref()
.map(str::trim)
.unwrap_or("");
let annotated_description = if description.is_empty() {
plugin_source_note
} else if matches!(description.chars().last(), Some('.' | '!' | '?')) {
format!("{description} {plugin_source_note}")
} else {
format!("{description}. {plugin_source_note}")
};
tool.tool.description = Some(Cow::Owned(annotated_description));
}
tools
}
fn normalize_codex_apps_tool_title(
server_name: &str,
connector_name: Option<&str>,
@@ -1304,7 +1233,6 @@ async fn start_server_task(
tx_event,
elicitation_requests,
codex_apps_tools_cache_context,
tool_plugin_provenance,
} = params;
let elicitation = elicitation_capability_for_server(&server_name);
let params = InitializeRequestParams {
@@ -1357,10 +1285,7 @@ async fn start_server_task(
&[("cache", "miss")],
);
}
let tools = annotate_tools_with_plugin_sources(
filter_tools(tools, &tool_filter),
tool_plugin_provenance.as_ref(),
);
let tools = filter_tools(tools, &tool_filter);
let server_supports_sandbox_state_capability = initialize_result
.capabilities
@@ -1376,7 +1301,6 @@ async fn start_server_task(
tool_filter,
server_supports_sandbox_state_capability,
codex_apps_tools_cache_context,
tool_plugin_provenance,
};
Ok(managed)
@@ -1389,7 +1313,6 @@ struct StartServerTaskParams {
tx_event: Sender<Event>,
elicitation_requests: ElicitationRequestManager,
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
tool_plugin_provenance: Arc<ToolPluginProvenance>,
}
async fn make_rmcp_client(

View File

@@ -5,7 +5,6 @@ use std::path::PathBuf;
use codex_protocol::user_input::UserInput;
use crate::connectors;
use crate::plugins::PluginCapabilitySummary;
use crate::skills::SkillMetadata;
use crate::skills::injection::ToolMentionKind;
use crate::skills::injection::app_id_from_path;
@@ -49,103 +48,6 @@ pub(crate) fn collect_explicit_app_ids(input: &[UserInput]) -> HashSet<String> {
.collect()
}
/// Collect explicit plain-text `@plugin` mentions from user text.
///
/// This is currently the core-side fallback path for plugin mentions. It
/// matches unambiguous plugin `display_name`s from the filtered capability
/// index, case-insensitively, by scanning for exact `@display name` matches.
///
/// It is hand-rolled because core only has a `$...` / `[$...](...)` mention
/// parser today, and the existing TUI `@...` logic is file-autocomplete, not
/// turn-time parsing.
///
/// Long term, explicit plugin picks should come through structured
/// `plugin://...` mentions, likely via `UserInput::Mention`, once clients can list
/// plugins and the UI has plugin-mention support (likely a plugins/list app-server
/// endpoint). Even then, this may stay as a text fallback, similar to skills/apps.
pub(crate) fn collect_explicit_plugin_mentions(
input: &[UserInput],
plugins: &[PluginCapabilitySummary],
) -> Vec<PluginCapabilitySummary> {
if plugins.is_empty() {
return Vec::new();
}
let mut display_name_counts = HashMap::new();
for plugin in plugins {
*display_name_counts
.entry(plugin.display_name.to_lowercase())
.or_insert(0) += 1;
}
let mut display_names = display_name_counts.keys().cloned().collect::<Vec<_>>();
display_names.sort_by_key(|display_name| std::cmp::Reverse(display_name.len()));
let mut mentioned_display_names = HashSet::new();
for text in input.iter().filter_map(|item| match item {
UserInput::Text { text, .. } => Some(text.as_str()),
_ => None,
}) {
let text = text.to_lowercase();
let mut index = 0;
while let Some(relative_at_sign) = text[index..].find('@') {
let at_sign = index + relative_at_sign;
if text[..at_sign]
.chars()
.next_back()
.is_some_and(is_plugin_mention_body_char)
{
index = at_sign + 1;
continue;
}
let Some((matched_display_name, matched_len)) =
display_names.iter().find_map(|display_name| {
text[at_sign + 1..].starts_with(display_name).then(|| {
let end = at_sign + 1 + display_name.len();
text[end..]
.chars()
.next()
.is_none_or(|ch| !is_plugin_mention_body_char(ch))
.then_some((display_name, display_name.len()))
})?
})
else {
index = at_sign + 1;
continue;
};
if display_name_counts
.get(matched_display_name)
.copied()
.unwrap_or(0)
== 1
{
mentioned_display_names.insert(matched_display_name.clone());
}
index = at_sign + 1 + matched_len;
}
}
if mentioned_display_names.is_empty() {
return Vec::new();
}
let mut selected = Vec::new();
let mut seen_display_names = HashSet::new();
for plugin in plugins {
let display_name = plugin.display_name.to_lowercase();
if !mentioned_display_names.contains(&display_name) {
continue;
}
if seen_display_names.insert(display_name) {
selected.push(plugin.clone());
}
}
selected
}
pub(crate) fn build_skill_name_counts(
skills: &[SkillMetadata],
disabled_paths: &HashSet<PathBuf>,
@@ -175,10 +77,6 @@ pub(crate) fn build_connector_slug_counts(
counts
}
fn is_plugin_mention_body_char(ch: char) -> bool {
ch.is_alphanumeric() || matches!(ch, '_' | '-' | ':')
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
@@ -187,8 +85,6 @@ mod tests {
use pretty_assertions::assert_eq;
use super::collect_explicit_app_ids;
use super::collect_explicit_plugin_mentions;
use crate::plugins::PluginCapabilitySummary;
fn text_input(text: &str) -> UserInput {
UserInput::Text {
@@ -197,16 +93,6 @@ mod tests {
}
}
fn plugin(display_name: &str) -> PluginCapabilitySummary {
PluginCapabilitySummary {
config_name: format!("{display_name}@test"),
display_name: display_name.to_string(),
has_skills: true,
mcp_server_names: Vec::new(),
app_connector_ids: Vec::new(),
}
}
#[test]
fn collect_explicit_app_ids_from_linked_text_mentions() {
let input = vec![text_input("use [$calendar](app://calendar)")];
@@ -255,70 +141,4 @@ mod tests {
assert_eq!(app_ids, HashSet::<String>::new());
}
#[test]
fn collect_explicit_plugin_mentions_resolves_unique_display_names() {
let plugins = vec![plugin("sample"), plugin("other")];
let mentioned = collect_explicit_plugin_mentions(&[text_input("use @sample")], &plugins);
assert_eq!(mentioned, vec![plugin("sample")]);
}
#[test]
fn collect_explicit_plugin_mentions_resolves_non_slug_display_names() {
let spaced_plugins = vec![plugin("Google Calendar")];
let spaced_mentioned = collect_explicit_plugin_mentions(
&[text_input("use @Google Calendar")],
&spaced_plugins,
);
assert_eq!(spaced_mentioned, vec![plugin("Google Calendar")]);
let unicode_plugins = vec![plugin("Café")];
let unicode_mentioned =
collect_explicit_plugin_mentions(&[text_input("use @Café")], &unicode_plugins);
assert_eq!(unicode_mentioned, vec![plugin("Café")]);
}
#[test]
fn collect_explicit_plugin_mentions_prefers_longer_display_names() {
let plugins = vec![plugin("Google"), plugin("Google Calendar")];
let mentioned =
collect_explicit_plugin_mentions(&[text_input("use @Google Calendar")], &plugins);
assert_eq!(mentioned, vec![plugin("Google Calendar")]);
}
#[test]
fn collect_explicit_plugin_mentions_does_not_fall_back_from_ambiguous_longer_name() {
let plugins = vec![
plugin("Google"),
PluginCapabilitySummary {
config_name: "calendar-1@test".to_string(),
..plugin("Google Calendar")
},
PluginCapabilitySummary {
config_name: "calendar-2@test".to_string(),
..plugin("Google Calendar")
},
];
let mentioned =
collect_explicit_plugin_mentions(&[text_input("use @Google Calendar")], &plugins);
assert_eq!(mentioned, Vec::<PluginCapabilitySummary>::new());
}
#[test]
fn collect_explicit_plugin_mentions_ignores_embedded_at_signs() {
let plugins = vec![plugin("sample")];
let mentioned = collect_explicit_plugin_mentions(
&[text_input("contact sample@openai.com, do not use plugins")],
&plugins,
);
assert_eq!(mentioned, Vec::<PluginCapabilitySummary>::new());
}
}

View File

@@ -30,7 +30,6 @@ use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::sync::RwLock;
use tracing::info;
use tracing::warn;
const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
@@ -101,33 +100,10 @@ impl PluginCapabilitySummary {
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ToolPluginProvenance {
plugin_display_names_by_connector_id: HashMap<String, Vec<String>>,
plugin_display_names_by_mcp_server_name: HashMap<String, Vec<String>>,
}
impl ToolPluginProvenance {
pub fn plugin_display_names_for_connector_id(&self, connector_id: &str) -> &[String] {
self.plugin_display_names_by_connector_id
.get(connector_id)
.map(Vec::as_slice)
.unwrap_or(&[])
}
pub fn plugin_display_names_for_mcp_server_name(&self, server_name: &str) -> &[String] {
self.plugin_display_names_by_mcp_server_name
.get(server_name)
.map(Vec::as_slice)
.unwrap_or(&[])
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PluginLoadOutcome {
plugins: Vec<LoadedPlugin>,
capability_summaries: Vec<PluginCapabilitySummary>,
tool_plugin_provenance: ToolPluginProvenance,
}
impl Default for PluginLoadOutcome {
@@ -142,42 +118,9 @@ impl PluginLoadOutcome {
.iter()
.filter_map(PluginCapabilitySummary::from_plugin)
.collect::<Vec<_>>();
let mut tool_plugin_provenance = ToolPluginProvenance::default();
for plugin in &capability_summaries {
for connector_id in &plugin.app_connector_ids {
tool_plugin_provenance
.plugin_display_names_by_connector_id
.entry(connector_id.0.clone())
.or_default()
.push(plugin.display_name.clone());
}
for server_name in &plugin.mcp_server_names {
tool_plugin_provenance
.plugin_display_names_by_mcp_server_name
.entry(server_name.clone())
.or_default()
.push(plugin.display_name.clone());
}
}
for plugin_names in tool_plugin_provenance
.plugin_display_names_by_connector_id
.values_mut()
.chain(
tool_plugin_provenance
.plugin_display_names_by_mcp_server_name
.values_mut(),
)
{
plugin_names.sort_unstable();
plugin_names.dedup();
}
Self {
plugins,
capability_summaries,
tool_plugin_provenance,
}
}
@@ -224,10 +167,6 @@ impl PluginLoadOutcome {
&self.capability_summaries
}
pub fn tool_plugin_provenance(&self) -> &ToolPluginProvenance {
&self.tool_plugin_provenance
}
pub fn plugins(&self) -> &[LoadedPlugin] {
&self.plugins
}
@@ -259,7 +198,6 @@ impl PluginsManager {
force_reload: bool,
) -> PluginLoadOutcome {
if !plugins_feature_enabled_from_stack(config_layer_stack) {
info!(cwd = ?cwd, "plugins disabled in config layer stack");
let mut cache = match self.cache_by_cwd.write() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
@@ -269,29 +207,11 @@ impl PluginsManager {
}
if !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(cwd) {
info!(
cwd = ?cwd,
plugin_display_names = ?outcome
.capability_summaries()
.iter()
.map(|plugin| plugin.display_name.as_str())
.collect::<Vec<_>>(),
"plugins cache hit"
);
return outcome;
}
let outcome = load_plugins_from_layer_stack(config_layer_stack, &self.store);
log_plugin_load_errors(&outcome);
info!(
cwd = ?cwd,
plugin_display_names = ?outcome
.capability_summaries()
.iter()
.map(|plugin| plugin.display_name.as_str())
.collect::<Vec<_>>(),
"plugins loaded from config layer stack"
);
let mut cache = match self.cache_by_cwd.write() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
@@ -968,19 +888,6 @@ mod tests {
AppConnectorId("connector_gmail".to_string()),
]
);
assert_eq!(
outcome.tool_plugin_provenance(),
&ToolPluginProvenance {
plugin_display_names_by_connector_id: HashMap::from([
(
"connector_example".to_string(),
vec!["plugin-a".to_string(), "plugin-b".to_string()],
),
("connector_gmail".to_string(), vec!["plugin-b".to_string()],),
]),
plugin_display_names_by_mcp_server_name: HashMap::new(),
}
);
}
#[test]
@@ -1070,25 +977,6 @@ mod tests {
},
]
);
assert_eq!(
outcome.tool_plugin_provenance(),
&ToolPluginProvenance {
plugin_display_names_by_connector_id: HashMap::from([
(
"connector_example".to_string(),
vec!["alpha-plugin".to_string(), "beta-plugin".to_string()],
),
(
"connector_gmail".to_string(),
vec!["beta-plugin".to_string()],
),
]),
plugin_display_names_by_mcp_server_name: HashMap::from([
("alpha".to_string(), vec!["alpha-plugin".to_string()]),
("beta".to_string(), vec!["beta-plugin".to_string()]),
]),
}
);
}
#[test]

View File

@@ -11,11 +11,9 @@ pub use manager::PluginInstallError;
pub use manager::PluginInstallRequest;
pub use manager::PluginLoadOutcome;
pub use manager::PluginsManager;
pub use manager::ToolPluginProvenance;
pub(crate) use manager::plugin_namespace_for_skill_path;
pub(crate) use manifest::load_plugin_manifest;
pub(crate) use manifest::plugin_manifest_name;
pub(crate) use render::render_explicit_plugin_instructions;
pub(crate) use render::render_plugins_section;
pub use store::PluginId;
pub use store::PluginInstallResult;

View File

@@ -30,54 +30,6 @@ pub(crate) fn render_plugins_section(plugins: &[PluginCapabilitySummary]) -> Opt
Some(lines.join("\n"))
}
pub(crate) fn render_explicit_plugin_instructions(
plugin: &PluginCapabilitySummary,
available_mcp_servers: &[String],
available_apps: &[String],
) -> Option<String> {
let mut lines = vec![format!(
"Capabilities from the `{}` plugin:",
plugin.display_name
)];
if plugin.has_skills {
lines.push(format!(
"- Skills from this plugin are prefixed with `{}:`.",
plugin.display_name
));
}
if !available_mcp_servers.is_empty() {
lines.push(format!(
"- MCP servers from this plugin available in this session: {}.",
available_mcp_servers
.iter()
.map(|server| format!("`{server}`"))
.collect::<Vec<_>>()
.join(", ")
));
}
if !available_apps.is_empty() {
lines.push(format!(
"- Apps from this plugin available in this session: {}.",
available_apps
.iter()
.map(|app| format!("`{app}`"))
.collect::<Vec<_>>()
.join(", ")
));
}
if lines.len() == 1 {
return None;
}
lines.push("Use these plugin-associated capabilities to help solve the task.".to_string());
Some(lines.join("\n"))
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -14,6 +14,7 @@ use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
@@ -21,48 +22,41 @@ use core_test_support::stdio_server_bin;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_with_timeout;
use dunce::canonicalize as normalize_path;
use tempfile::TempDir;
use wiremock::MockServer;
const SAMPLE_PLUGIN_CONFIG_NAME: &str = "sample@test";
const SAMPLE_PLUGIN_DISPLAY_NAME: &str = "sample";
fn sample_plugin_root(home: &TempDir) -> std::path::PathBuf {
home.path().join("plugins/cache/test/sample/local")
}
fn write_sample_plugin_manifest_and_config(home: &TempDir) -> std::path::PathBuf {
let plugin_root = sample_plugin_root(home);
fn write_plugin_skill_plugin(home: &TempDir) -> std::path::PathBuf {
let plugin_root = home.path().join("plugins/cache/test/sample/local");
let skill_dir = plugin_root.join("skills/sample-search");
std::fs::create_dir_all(skill_dir.as_path()).expect("create plugin skill dir");
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
format!(r#"{{"name":"{SAMPLE_PLUGIN_DISPLAY_NAME}"}}"#),
r#"{"name":"sample"}"#,
)
.expect("write plugin manifest");
std::fs::write(
home.path().join("config.toml"),
format!(
"[features]\nplugins = true\n\n[plugins.\"{SAMPLE_PLUGIN_CONFIG_NAME}\"]\nenabled = true\n"
),
)
.expect("write config");
plugin_root
}
fn write_plugin_skill_plugin(home: &TempDir) -> std::path::PathBuf {
let plugin_root = write_sample_plugin_manifest_and_config(home);
let skill_dir = plugin_root.join("skills/sample-search");
std::fs::create_dir_all(skill_dir.as_path()).expect("create plugin skill dir");
std::fs::write(
skill_dir.join("SKILL.md"),
"---\ndescription: inspect sample data\n---\n\n# body\n",
)
.expect("write plugin skill");
std::fs::write(
home.path().join("config.toml"),
"[features]\nplugins = true\n\n[plugins.\"sample@test\"]\nenabled = true\n",
)
.expect("write config");
skill_dir.join("SKILL.md")
}
fn write_plugin_mcp_plugin(home: &TempDir, command: &str) {
let plugin_root = write_sample_plugin_manifest_and_config(home);
let plugin_root = home.path().join("plugins/cache/test/sample/local");
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)
.expect("write plugin manifest");
std::fs::write(
plugin_root.join(".mcp.json"),
format!(
@@ -76,10 +70,21 @@ fn write_plugin_mcp_plugin(home: &TempDir, command: &str) {
),
)
.expect("write plugin mcp config");
std::fs::write(
home.path().join("config.toml"),
"[features]\nplugins = true\n\n[plugins.\"sample@test\"]\nenabled = true\n",
)
.expect("write config");
}
fn write_plugin_app_plugin(home: &TempDir) {
let plugin_root = write_sample_plugin_manifest_and_config(home);
let plugin_root = home.path().join("plugins/sample");
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)
.expect("write plugin manifest");
std::fs::write(
plugin_root.join(".app.json"),
r#"{
@@ -91,6 +96,14 @@ fn write_plugin_app_plugin(home: &TempDir) {
}"#,
)
.expect("write plugin app config");
std::fs::write(
home.path().join("config.toml"),
format!(
"[features]\nplugins = true\n\n[plugins.sample]\nenabled = true\npath = \"{}\"\n",
plugin_root.display()
),
)
.expect("write config");
}
async fn build_plugin_test_codex(
@@ -107,32 +120,6 @@ async fn build_plugin_test_codex(
.codex)
}
async fn build_apps_enabled_plugin_test_codex(
server: &MockServer,
codex_home: Arc<TempDir>,
chatgpt_base_url: String,
) -> Result<Arc<codex_core::CodexThread>> {
let mut builder = test_codex()
.with_home(codex_home)
.with_auth(CodexAuth::from_api_key("Test API Key"))
.with_config(move |config| {
config
.features
.enable(Feature::Apps)
.expect("test config should allow feature update");
config
.features
.disable(Feature::AppsMcpGateway)
.expect("test config should allow feature update");
config.chatgpt_base_url = chatgpt_base_url;
});
Ok(builder
.build(server)
.await
.expect("create new conversation")
.codex)
}
fn tool_names(body: &serde_json::Value) -> Vec<String> {
body.get("tools")
.and_then(serde_json::Value::as_array)
@@ -150,22 +137,6 @@ fn tool_names(body: &serde_json::Value) -> Vec<String> {
.unwrap_or_default()
}
fn tool_description(body: &serde_json::Value, tool_name: &str) -> Option<String> {
body.get("tools")
.and_then(serde_json::Value::as_array)
.and_then(|tools| {
tools.iter().find_map(|tool| {
if tool.get("name").and_then(serde_json::Value::as_str) == Some(tool_name) {
tool.get("description")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
} else {
None
}
})
})
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn plugin_skills_append_to_instructions() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -178,7 +149,7 @@ async fn plugin_skills_append_to_instructions() -> Result<()> {
.await;
let codex_home = Arc::new(TempDir::new()?);
write_plugin_skill_plugin(codex_home.as_ref());
let skill_path = write_plugin_skill_plugin(codex_home.as_ref());
let codex = build_plugin_test_codex(&server, Arc::clone(&codex_home)).await?;
codex
@@ -203,48 +174,76 @@ async fn plugin_skills_append_to_instructions() -> Result<()> {
"expected plugins section present"
);
assert!(
instructions_text.contains("`sample`"),
"expected enabled plugin name in instructions"
instructions_text.contains("### Available plugins\n- `sample`"),
"expected enabled plugin list in instructions"
);
assert!(
instructions_text.contains("### How to use plugins"),
"expected plugin usage guidance heading"
);
assert!(
instructions_text.contains("## Skills"),
"expected skills section present"
);
assert!(
instructions_text.contains("sample:sample-search: inspect sample data"),
"expected namespaced plugin skill summary"
);
let expected_path = normalize_path(skill_path)?;
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
assert!(
instructions_text.contains(&expected_path_str),
"expected path {expected_path_str} in instructions"
);
assert!(
instructions_text.find("## Plugins") < instructions_text.find("## Skills"),
"expected plugins section before skills section"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> {
async fn plugin_apps_expose_tools_after_canonical_name_mention() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?;
let mock = mount_sse_once(
let mock = mount_sse_sequence(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
vec![
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
],
)
.await;
let codex_home = Arc::new(TempDir::new()?);
let rmcp_test_server_bin = match stdio_server_bin() {
Ok(bin) => bin,
Err(err) => {
eprintln!("test_stdio_server binary not available, skipping test: {err}");
return Ok(());
}
};
write_plugin_skill_plugin(codex_home.as_ref());
write_plugin_mcp_plugin(codex_home.as_ref(), &rmcp_test_server_bin);
write_plugin_app_plugin(codex_home.as_ref());
let codex =
build_apps_enabled_plugin_test_codex(&server, codex_home, apps_server.chatgpt_base_url)
.await?;
#[allow(clippy::expect_used)]
let mut builder = test_codex()
.with_home(codex_home)
.with_auth(CodexAuth::from_api_key("Test API Key"))
.with_config(move |config| {
config
.features
.enable(Feature::Apps)
.expect("test config should allow feature update");
config
.features
.disable(Feature::AppsMcpGateway)
.expect("test config should allow feature update");
config.chatgpt_base_url = apps_server.chatgpt_base_url;
});
let codex = builder
.build(&server)
.await
.expect("create new conversation")
.codex;
codex
.submit(Op::UserInput {
items: vec![codex_protocol::user_input::UserInput::Text {
text: "Use @sample for this task.".into(),
text: "hello".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
@@ -252,46 +251,40 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> {
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let request = mock.single_request();
let developer_messages = request.message_input_texts("developer");
codex
.submit(Op::UserInput {
items: vec![codex_protocol::user_input::UserInput::Text {
text: "Use $google-calendar and then call tools.".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let requests = mock.requests();
assert_eq!(requests.len(), 2, "expected two model requests");
let first_tools = tool_names(&requests[0].body_json());
assert!(
developer_messages
.iter()
.any(|text| text.contains("Skills from this plugin")),
"expected plugin skills guidance: {developer_messages:?}"
);
assert!(
developer_messages
.iter()
.any(|text| text.contains("MCP servers from this plugin")),
"expected visible plugin MCP guidance: {developer_messages:?}"
);
assert!(
developer_messages
.iter()
.any(|text| text.contains("Apps from this plugin")),
"expected visible plugin app guidance: {developer_messages:?}"
);
let request_body = request.body_json();
let request_tools = tool_names(&request_body);
assert!(
request_tools
!first_tools
.iter()
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
"expected plugin app tools to become visible for this turn: {request_tools:?}"
"app tools should stay hidden before plugin app mention: {first_tools:?}"
);
let echo_description = tool_description(&request_body, "mcp__sample__echo")
.expect("plugin MCP tool description should be present");
let second_tools = tool_names(&requests[1].body_json());
assert!(
echo_description.contains("This tool is part of plugin `sample`."),
"expected plugin MCP provenance in tool description: {echo_description:?}"
second_tools
.iter()
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
"calendar create tool should be available after plugin app mention: {second_tools:?}"
);
let calendar_description =
tool_description(&request_body, "mcp__codex_apps__calendar_create_event")
.expect("plugin app tool description should be present");
assert!(
calendar_description.contains("This tool is part of plugin `sample`."),
"expected plugin app provenance in tool description: {calendar_description:?}"
second_tools
.iter()
.any(|name| name == "mcp__codex_apps__calendar_list_events"),
"calendar list tool should be available after plugin app mention: {second_tools:?}"
);
Ok(())

View File

@@ -361,110 +361,6 @@ async fn explicit_app_mentions_expose_apps_tools_without_search() -> Result<()>
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn search_tool_results_match_plugin_names_and_annotate_descriptions() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?;
let call_id = "tool-search";
let args = json!({
"query": "sample",
"limit": 2,
});
let mock = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(
call_id,
SEARCH_TOOL_BM25_TOOL_NAME,
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
],
)
.await;
let codex_home = Arc::new(tempfile::TempDir::new()?);
let plugin_root = codex_home.path().join("plugins/cache/test/sample/local");
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)
.expect("write plugin manifest");
std::fs::write(
plugin_root.join(".app.json"),
r#"{
"apps": {
"calendar": {
"id": "calendar"
}
}
}"#,
)
.expect("write plugin app config");
std::fs::write(
codex_home.path().join("config.toml"),
"[features]\nplugins = true\n\n[plugins.\"sample@test\"]\nenabled = true\n",
)
.expect("write config");
let mut builder =
configured_builder(apps_server.chatgpt_base_url.clone(), None).with_home(codex_home);
let test = builder.build(&server).await?;
test.submit_turn_with_policies(
"find sample plugin tools",
AskForApproval::Never,
SandboxPolicy::DangerFullAccess,
)
.await?;
let requests = mock.requests();
assert_eq!(
requests.len(),
2,
"expected 2 requests, got {}",
requests.len()
);
let search_output_payload = search_tool_output_payload(&requests[1], call_id);
let result_tools = search_result_tools(&search_output_payload);
assert_eq!(result_tools.len(), 2, "expected 2 search results");
assert!(
result_tools.iter().all(|tool| {
tool.get("description")
.and_then(Value::as_str)
.is_some_and(|description| {
description.contains("This tool is part of plugin `sample`.")
})
}),
"expected plugin provenance in search result descriptions: {search_output_payload:?}"
);
assert!(
result_tools
.iter()
.any(|tool| { tool.get("name").and_then(Value::as_str) == Some(CALENDAR_CREATE_TOOL) }),
"expected calendar create tool in search results: {search_output_payload:?}"
);
assert!(
result_tools
.iter()
.any(|tool| { tool.get("name").and_then(Value::as_str) == Some(CALENDAR_LIST_TOOL) }),
"expected calendar list tool in search results: {search_output_payload:?}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn search_tool_selection_persists_across_turns() -> Result<()> {
skip_if_no_network!(Ok(()));

View File

@@ -44,6 +44,7 @@ use codex_core::plugins::PluginsManager;
use codex_core::web_search::web_search_detail;
use codex_otel::RuntimeMetricsSummary;
use codex_protocol::account::PlanType;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::mcp::Resource;
use codex_protocol::mcp::ResourceTemplate;
use codex_protocol::models::WebSearchAction;
@@ -1099,7 +1100,12 @@ pub(crate) fn new_session_info(
} else {
if config.show_tooltips
&& let Some(tooltips) = tooltip_override
.or_else(|| tooltips::get_tooltip(auth_plan))
.or_else(|| {
tooltips::get_tooltip(
auth_plan,
matches!(config.service_tier, Some(ServiceTier::Fast)),
)
})
.map(TooltipHistoryCell::new)
{
parts.push(Box::new(tooltips));

View File

@@ -90,7 +90,7 @@ impl SlashCommand {
SlashCommand::MemoryDrop => "DO NOT USE",
SlashCommand::MemoryUpdate => "DO NOT USE",
SlashCommand::Model => "choose what model and reasoning effort to use",
SlashCommand::Fast => "toggle Fast mode to enable fastest inference at 3X plan usage",
SlashCommand::Fast => "toggle Fast mode to enable fastest inference at 2X plan usage",
SlashCommand::Personality => "choose a communication style for Codex",
SlashCommand::Realtime => "toggle realtime voice mode (experimental)",
SlashCommand::Settings => "configure realtime microphone/speaker",

View File

@@ -7,9 +7,12 @@ const ANNOUNCEMENT_TIP_URL: &str =
"https://raw.githubusercontent.com/openai/codex/main/announcement_tip.toml";
const IS_MACOS: bool = cfg!(target_os = "macos");
const IS_WINDOWS: bool = cfg!(target_os = "windows");
const PAID_TOOLTIP: &str = "*New* Try the **Codex App** with 2x rate limits until *April 2nd*. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true";
const PAID_TOOLTIP_WINDOWS: &str = "*New* Try the **Codex App**, now available on **Windows**, with 2x rate limits until *April 2nd*. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true";
const PAID_TOOLTIP_NON_MAC: &str = "*New* 2x rate limits until *April 2nd*.";
const FAST_TOOLTIP: &str = "*New* Use **/fast** to enable our fastest inference at 2X plan usage.";
const OTHER_TOOLTIP: &str = "*New* Build faster with the **Codex App**. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true";
const OTHER_TOOLTIP_NON_MAC: &str = "*New* Build faster with Codex.";
const FREE_GO_TOOLTIP: &str =
@@ -25,7 +28,7 @@ lazy_static! {
if line.is_empty() || line.starts_with('#') {
return false;
}
if !IS_MACOS && line.contains("codex app") {
if !IS_MACOS && !IS_WINDOWS && line.contains("codex app") {
return false;
}
true
@@ -47,7 +50,7 @@ fn experimental_tooltips() -> Vec<&'static str> {
}
/// Pick a random tooltip to show to the user when starting Codex.
pub(crate) fn get_tooltip(plan: Option<PlanType>) -> Option<String> {
pub(crate) fn get_tooltip(plan: Option<PlanType>, fast_mode_enabled: bool) -> Option<String> {
let mut rng = rand::rng();
if let Some(announcement) = announcement::fetch_announcement_tip() {
@@ -62,12 +65,7 @@ pub(crate) fn get_tooltip(plan: Option<PlanType>) -> Option<String> {
| Some(PlanType::Team)
| Some(PlanType::Enterprise)
| Some(PlanType::Pro) => {
let tooltip = if IS_MACOS {
PAID_TOOLTIP
} else {
PAID_TOOLTIP_NON_MAC
};
return Some(tooltip.to_string());
return Some(pick_paid_tooltip(&mut rng, fast_mode_enabled).to_string());
}
Some(PlanType::Go) | Some(PlanType::Free) => {
return Some(FREE_GO_TOOLTIP.to_string());
@@ -86,6 +84,28 @@ pub(crate) fn get_tooltip(plan: Option<PlanType>) -> Option<String> {
pick_tooltip(&mut rng).map(str::to_string)
}
fn paid_app_tooltip() -> &'static str {
if IS_MACOS {
PAID_TOOLTIP
} else if IS_WINDOWS {
PAID_TOOLTIP_WINDOWS
} else {
PAID_TOOLTIP_NON_MAC
}
}
/// Paid users spend most startup sessions in a dedicated promo slot rather than the
/// generic random tip pool. Keep this business logic explicit: we currently split
/// that slot between the app promo and Fast mode, but suppress the Fast promo once
/// the user already has Fast mode enabled.
fn pick_paid_tooltip<R: Rng + ?Sized>(rng: &mut R, fast_mode_enabled: bool) -> &'static str {
if fast_mode_enabled || rng.random_bool(0.5) {
paid_app_tooltip()
} else {
FAST_TOOLTIP
}
}
fn pick_tooltip<R: Rng + ?Sized>(rng: &mut R) -> Option<&'static str> {
if ALL_TOOLTIPS.is_empty() {
None
@@ -264,6 +284,31 @@ mod tests {
assert_eq!(expected, pick_tooltip(&mut rng));
}
#[test]
fn paid_tooltip_pool_rotates_between_promos() {
let mut seen = std::collections::BTreeSet::new();
for seed in 0..32 {
let mut rng = StdRng::seed_from_u64(seed);
seen.insert(pick_paid_tooltip(&mut rng, false));
}
let expected = std::collections::BTreeSet::from([paid_app_tooltip(), FAST_TOOLTIP]);
assert_eq!(seen, expected);
}
#[test]
fn paid_tooltip_pool_skips_fast_when_fast_mode_is_enabled() {
let mut seen = std::collections::BTreeSet::new();
for seed in 0..8 {
let mut rng = StdRng::seed_from_u64(seed);
seen.insert(pick_paid_tooltip(&mut rng, true));
}
let expected = std::collections::BTreeSet::from([paid_app_tooltip()]);
assert_eq!(seen, expected);
assert!(!seen.contains(&FAST_TOOLTIP));
}
#[test]
fn announcement_tip_toml_picks_last_matching() {
let toml = r#"