diff --git a/codex-rs/tui_app_server/src/app/app_server_adapter.rs b/codex-rs/tui_app_server/src/app/app_server_adapter.rs index d6dd52715e..7f649c17e7 100644 --- a/codex-rs/tui_app_server/src/app/app_server_adapter.rs +++ b/codex-rs/tui_app_server/src/app/app_server_adapter.rs @@ -119,6 +119,7 @@ impl App { skipped, "app-server event consumer lagged; dropping ignored events" ); + self.chat_widget.finish_mcp_startup_after_lag(); } AppServerEvent::ServerNotification(notification) => { self.handle_server_notification_event(app_server_client, notification) @@ -138,7 +139,7 @@ impl App { async fn handle_server_notification_event( &mut self, - app_server_client: &AppServerSession, + _app_server_client: &AppServerSession, notification: ServerNotification, ) { match ¬ification { @@ -148,31 +149,16 @@ impl App { } ServerNotification::McpServerStatusUpdated(_) => { if self.chat_widget.needs_mcp_startup_expected_servers() { - match super::fetch_all_mcp_server_statuses(app_server_client.request_handle()) - .await - { - Ok(statuses) => { - let disabled_config_mcp_servers: std::collections::HashSet = - self.chat_widget - .config_ref() - .mcp_servers - .get() - .iter() - .filter_map(|(name, server)| { - (!server.enabled).then_some(name.clone()) - }) - .collect(); - self.chat_widget.set_mcp_startup_expected_servers( - statuses.into_iter().filter_map(|status| { - (!disabled_config_mcp_servers.contains(&status.name)) - .then_some(status.name) - }), - ); - } - Err(err) => { - tracing::warn!(%err, "failed to seed expected MCP startup servers"); - } - } + let enabled_config_mcp_servers: Vec = self + .chat_widget + .config_ref() + .mcp_servers + .get() + .iter() + .filter_map(|(name, server)| server.enabled.then_some(name.clone())) + .collect(); + self.chat_widget + .set_mcp_startup_expected_servers(enabled_config_mcp_servers); } } ServerNotification::AccountRateLimitsUpdated(notification) => { diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index f9d4ccbf1e..53a2c5d39c 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -797,8 +797,10 @@ pub(crate) struct ChatWidget { /// bottom pane is treated as "running" while this is populated, even if no agent turn is /// currently executing. mcp_startup_status: Option>, - /// Expected MCP servers for the current startup round, seeded from the app-server runtime. + /// Expected MCP servers for the current startup round, seeded from enabled local config. mcp_startup_expected_servers: Option>, + /// MCP startup is a one-time app boot sequence; ignore straggling status updates after settle. + mcp_startup_complete: bool, connectors_cache: ConnectorsCacheState, connectors_partial_snapshot: Option, connectors_prefetch_in_flight: bool, @@ -2790,6 +2792,9 @@ impl ChatWidget { status: McpStartupStatus, complete_when_settled: bool, ) { + if self.mcp_startup_complete { + return; + } let mut startup_status = self.mcp_startup_status.take().unwrap_or_default(); if let McpStartupStatus::Failed { error } = &status { self.on_warning(error); @@ -2862,7 +2867,7 @@ impl ChatWidget { } pub(crate) fn needs_mcp_startup_expected_servers(&self) -> bool { - self.mcp_startup_expected_servers.is_none() + !self.mcp_startup_complete && self.mcp_startup_expected_servers.is_none() } pub(crate) fn set_mcp_startup_expected_servers(&mut self, server_names: I) @@ -2894,11 +2899,53 @@ impl ChatWidget { self.mcp_startup_status = None; self.mcp_startup_expected_servers = None; + self.mcp_startup_complete = true; self.update_task_running_state(); self.maybe_send_next_queued_input(); self.request_redraw(); } + pub(crate) fn finish_mcp_startup_after_lag(&mut self) { + if self.mcp_startup_complete { + return; + } + + let Some(current) = &self.mcp_startup_status else { + return; + }; + + let mut failed = Vec::new(); + let mut cancelled = Vec::new(); + + if let Some(expected_servers) = &self.mcp_startup_expected_servers { + for name in expected_servers { + match current.get(name) { + Some(McpStartupStatus::Ready) => {} + Some(McpStartupStatus::Failed { .. }) => failed.push(name.clone()), + Some(McpStartupStatus::Cancelled | McpStartupStatus::Starting) | None => { + cancelled.push(name.clone()); + } + } + } + } else { + for (name, state) in current { + match state { + McpStartupStatus::Ready => {} + McpStartupStatus::Failed { .. } => failed.push(name.clone()), + McpStartupStatus::Cancelled | McpStartupStatus::Starting => { + cancelled.push(name.clone()); + } + } + } + } + + failed.sort(); + failed.dedup(); + cancelled.sort(); + cancelled.dedup(); + self.finish_mcp_startup(failed, cancelled); + } + #[cfg(test)] fn on_mcp_startup_complete(&mut self, ev: McpStartupCompleteEvent) { let failed = ev.failed.into_iter().map(|f| f.server).collect(); @@ -4626,6 +4673,7 @@ impl ChatWidget { mcp_startup_status: None, pending_turn_copyable_output: None, mcp_startup_expected_servers: None, + mcp_startup_complete: false, connectors_cache: ConnectorsCacheState::default(), connectors_partial_snapshot: None, connectors_prefetch_in_flight: false, diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 602fe079be..2ce9f92e7b 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -2074,6 +2074,7 @@ async fn make_chatwidget_manual( agent_turn_running: false, mcp_startup_status: None, mcp_startup_expected_servers: None, + mcp_startup_complete: false, connectors_cache: ConnectorsCacheState::default(), connectors_partial_snapshot: None, plugin_install_apps_needing_auth: Vec::new(), @@ -11687,6 +11688,64 @@ async fn app_server_mcp_startup_failure_renders_warning_history() { ); } +#[tokio::test] +async fn app_server_mcp_startup_lag_settles_startup_and_ignores_late_updates() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.show_welcome_banner = false; + chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]); + + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "alpha".to_string(), + status: McpServerStartupState::Starting, + error: None, + }), + None, + ); + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "alpha".to_string(), + status: McpServerStartupState::Failed, + error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()), + }), + None, + ); + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "beta".to_string(), + status: McpServerStartupState::Starting, + error: None, + }), + None, + ); + + let _ = drain_insert_history(&mut rx); + assert!(chat.bottom_pane.is_task_running()); + + chat.finish_mcp_startup_after_lag(); + + let summary_text = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert!(summary_text.contains("MCP startup interrupted")); + assert!(summary_text.contains("beta")); + assert!(summary_text.contains("MCP startup incomplete (failed: alpha)")); + assert!(!chat.bottom_pane.is_task_running()); + + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "beta".to_string(), + status: McpServerStartupState::Ready, + error: None, + }), + None, + ); + + assert!(drain_insert_history(&mut rx).is_empty()); + assert!(!chat.bottom_pane.is_task_running()); +} + #[tokio::test] async fn background_event_updates_status_header() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;