diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4f9cb7c344..c5532cc513 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -212,7 +212,7 @@ Example with notification opt-out: - `plugin/uninstall` — uninstall a local plugin by `pluginId` in `@` form by removing its cached files and clearing its user-level config entry, or uninstall a remote ChatGPT plugin by backend `pluginId` by forwarding the uninstall to the ChatGPT plugin backend and removing any downloaded remote-plugin cache (**under development; do not call from production clients yet**). - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. - `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental). -- `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. +- `config/mcpServer/reload` — reload MCP server config from disk/cache, clear plugin and skill caches, and queue an MCP refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` or local plugin state without restarting the server. - `mcpServerStatus/list` — enumerate configured MCP servers with their tools and auth status, plus resources/resource templates for `full` detail; supports cursor+limit pagination. If `detail` is omitted, the server defaults to `full`. - `mcpServer/resource/read` — read a resource from a configured MCP server by optional `threadId`, `server`, and `uri`, returning text/blob resource `contents`. If `threadId` is omitted, the server reads from the latest MCP config directly. - `mcpServer/tool/call` — call a tool on a thread's configured MCP server by `threadId`, `server`, `tool`, optional `arguments`, and optional `_meta`, returning the MCP tool result. diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index f7d32b2ea8..e8ee0a18be 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -9,10 +9,16 @@ use std::io; use std::sync::Arc; use tracing::warn; +fn clear_plugin_related_caches(thread_manager: &ThreadManager) { + thread_manager.plugins_manager().clear_cache(); + thread_manager.skills_manager().clear_cache(); +} + pub(crate) async fn queue_strict_refresh( thread_manager: &Arc, config_manager: &ConfigManager, ) -> io::Result<()> { + clear_plugin_related_caches(thread_manager.as_ref()); config_manager .load_latest_config(/*fallback_cwd*/ None) .await?; @@ -36,6 +42,7 @@ pub(crate) async fn queue_best_effort_refresh( thread_manager: &Arc, config_manager: &ConfigManager, ) { + clear_plugin_related_caches(thread_manager.as_ref()); for thread_id in thread_manager.list_thread_ids().await { let thread = match thread_manager.get_thread(thread_id).await { Ok(thread) => thread, diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index d270bb53ce..3cd9d8b313 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -685,6 +685,12 @@ impl App { self.refresh_in_memory_config_from_disk().await?; Ok(true) } + AppCommand::ReloadPlugins => { + app_server.reload_user_config().await?; + app_server.reload_mcp_servers().await?; + self.refresh_in_memory_config_from_disk().await?; + Ok(true) + } AppCommand::OverrideTurnContext { .. } => Ok(true), AppCommand::ApproveGuardianDeniedAction { event } => { app_server diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0b6484d015..ab17da6071 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -88,6 +88,7 @@ pub(crate) enum AppCommand { response: RequestPermissionsResponse, }, ReloadUserConfig, + ReloadPlugins, ListSkills { cwds: Vec, force_reload: bool, @@ -243,6 +244,10 @@ impl AppCommand { Self::ReloadUserConfig } + pub(crate) fn reload_plugins() -> Self { + Self::ReloadPlugins + } + pub(crate) fn list_skills(cwds: Vec, force_reload: bool) -> Self { Self::ListSkills { cwds, force_reload } } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 2620f80d53..6a1951f5ce 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -30,6 +30,7 @@ use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::GetAccountResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::LogoutAccountResponse; +use codex_app_server_protocol::McpServerRefreshResponse; use codex_app_server_protocol::MemoryResetResponse; use codex_app_server_protocol::Model as ApiModel; use codex_app_server_protocol::ModelListParams; @@ -922,6 +923,19 @@ impl AppServerSession { Ok(()) } + pub(crate) async fn reload_mcp_servers(&mut self) -> Result<()> { + let request_id = self.next_request_id(); + let _: McpServerRefreshResponse = self + .client + .request_typed(ClientRequest::McpServerRefresh { + request_id, + params: None, + }) + .await + .wrap_err("config/mcpServer/reload failed in TUI")?; + Ok(()) + } + pub(crate) async fn thread_realtime_start( &mut self, thread_id: ThreadId, diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index e907b6c5e5..c0aec71a7e 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -74,7 +74,10 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st .filter(|(_, cmd)| flags.allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) .filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Plan) .filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Apps) - .filter(|(_, cmd)| flags.plugins_command_enabled || *cmd != SlashCommand::Plugins) + .filter(|(_, cmd)| { + flags.plugins_command_enabled + || !matches!(cmd, SlashCommand::Plugins | SlashCommand::ReloadPlugins) + }) .filter(|(_, cmd)| flags.goal_command_enabled || *cmd != SlashCommand::Goal) .filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality) .filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime) diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 9d8d49a77c..00641a4554 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -420,6 +420,16 @@ impl ChatWidget { SlashCommand::Plugins => { self.add_plugins_output(); } + SlashCommand::ReloadPlugins => { + self.add_info_message( + "Plugin reload requested.".to_string(), + Some( + "Active threads will refresh MCP tools on the next turn. Skills already loaded into existing threads are not reread.".to_string(), + ), + ); + self.submit_op(AppCommand::reload_plugins()); + self.refresh_skills_for_current_cwd(/*force_reload*/ true); + } SlashCommand::Rollout => { if let Some(path) = self.rollout_path() { self.add_info_message( @@ -935,6 +945,7 @@ impl ChatWidget { | SlashCommand::Mcp | SlashCommand::Apps | SlashCommand::Plugins + | SlashCommand::ReloadPlugins | SlashCommand::Rollout | SlashCommand::Copy | SlashCommand::Raw diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_reload_plugins_info_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_reload_plugins_info_message.snap new file mode 100644 index 0000000000..12281a2f62 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_reload_plugins_info_message.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +assertion_line: 1752 +expression: rendered +--- +• Plugins reload requested. Loaded threads queue a best-effort MCP tools refresh on the next turn; plugin instructions and skills already loaded into an older thread stay as-is, so updated plugin context is visible in new threads. diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index e4865e64f3..c7d4097ea6 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1737,6 +1737,53 @@ async fn slash_mcp_requests_inventory_via_app_server() { assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); } +#[tokio::test] +async fn slash_reload_plugins_requests_reload_and_explains_thread_boundary() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::ReloadPlugins); + + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|cell| lines_to_single_string(cell)) + .collect::>() + .join("\n"); + assert_chatwidget_snapshot!("slash_reload_plugins_info_message", rendered); + assert!( + rendered.contains("Plugins reload requested."), + "expected reload request message, got: {rendered:?}" + ); + assert_matches!(op_rx.try_recv(), Ok(Op::ReloadPlugins)); + assert_matches!( + op_rx.try_recv(), + Ok(Op::ListSkills { + force_reload: true, + .. + }) + ); +} + +#[tokio::test] +async fn slash_reload_plugins_is_disabled_while_task_running() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane.set_task_running(/*running*/ true); + + chat.dispatch_command(SlashCommand::ReloadPlugins); + + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|cell| lines_to_single_string(cell)) + .collect::>() + .join("\n"); + assert!( + rendered.contains("'/reload-plugins' is disabled while a task is in progress."), + "expected disabled-command message, got: {rendered:?}" + ); + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + #[tokio::test] async fn slash_mcp_verbose_requests_full_inventory_via_app_server() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 970d1cbd5a..78dcb2baf8 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -52,6 +52,7 @@ pub enum SlashCommand { Mcp, Apps, Plugins, + ReloadPlugins, Logout, Quit, Exit, @@ -128,6 +129,7 @@ impl SlashCommand { SlashCommand::Mcp => "list configured MCP tools; use /mcp verbose for details", SlashCommand::Apps => "manage apps", SlashCommand::Plugins => "browse plugins", + SlashCommand::ReloadPlugins => "reload plugins and configured MCP servers", SlashCommand::Logout => "log out of Codex", SlashCommand::Rollout => "print the rollout file path", SlashCommand::TestApproval => "test approval request", @@ -193,6 +195,7 @@ impl SlashCommand { | SlashCommand::Plan | SlashCommand::Clear | SlashCommand::Logout + | SlashCommand::ReloadPlugins | SlashCommand::MemoryDrop | SlashCommand::MemoryUpdate => false, SlashCommand::Diff @@ -268,6 +271,17 @@ mod tests { assert_eq!(SlashCommand::from_str("pet"), Ok(SlashCommand::Pets)); } + #[test] + fn reload_plugins_command_uses_expected_name() { + assert_eq!(SlashCommand::ReloadPlugins.command(), "reload-plugins"); + assert_eq!( + SlashCommand::from_str("reload-plugins"), + Ok(SlashCommand::ReloadPlugins) + ); + assert!(!SlashCommand::ReloadPlugins.available_during_task()); + assert!(!SlashCommand::ReloadPlugins.supports_inline_args()); + } + #[test] fn certain_commands_are_available_during_task() { assert!(SlashCommand::Goal.available_during_task());