diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 719efc177..12b1c669b 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -108,6 +108,19 @@ use codex_protocol::protocol::TurnStartedEvent; use std::time::Duration; impl App { + fn refresh_mcp_startup_expected_servers_from_config(&mut self) { + 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); + } + pub(super) async fn handle_app_server_event( &mut self, app_server_client: &AppServerSession, @@ -119,6 +132,8 @@ impl App { skipped, "app-server event consumer lagged; dropping ignored events" ); + self.refresh_mcp_startup_expected_servers_from_config(); + self.chat_widget.finish_mcp_startup_after_lag(); } AppServerEvent::ServerNotification(notification) => { self.handle_server_notification_event(app_server_client, notification) @@ -146,6 +161,9 @@ impl App { self.pending_app_server_requests .resolve_notification(¬ification.request_id); } + ServerNotification::McpServerStatusUpdated(_) => { + self.refresh_mcp_startup_expected_servers_from_config(); + } ServerNotification::AccountRateLimitsUpdated(notification) => { self.chat_widget.on_rate_limit_snapshot(Some( app_server_rate_limit_snapshot_to_core(notification.rate_limits.clone()), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ba05bdbb7..1267e6b73 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -26,6 +26,7 @@ //! progress indicators; once commentary completes and stream queues drain, we //! re-show it so users still see turn-in-progress state between output bursts. use std::collections::BTreeMap; +use std::collections::BTreeSet; use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; @@ -76,6 +77,8 @@ use codex_app_server_protocol::ErrorNotification; use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::McpServerStartupState; +use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ThreadItem; @@ -795,6 +798,16 @@ 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 enabled local config. + mcp_startup_expected_servers: Option>, + /// After startup settles, ignore stale updates until enough notifications confirm a new round. + mcp_startup_ignore_updates_until_next_start: bool, + /// A lag signal for the next round means terminal-only updates are enough to settle it. + mcp_startup_allow_terminal_only_next_round: bool, + /// Buffers post-settle MCP startup updates until they cover a full fresh round. + mcp_startup_pending_next_round: HashMap, + /// Tracks whether the buffered next round has seen any `Starting` update yet. + mcp_startup_pending_next_round_saw_starting: bool, connectors_cache: ConnectorsCacheState, connectors_partial_snapshot: Option, connectors_prefetch_in_flight: bool, @@ -2780,16 +2793,114 @@ impl ChatWidget { self.request_redraw(); } - #[cfg(test)] - fn on_mcp_startup_update(&mut self, ev: McpStartupUpdateEvent) { - let mut status = self.mcp_startup_status.take().unwrap_or_default(); - if let McpStartupStatus::Failed { error } = &ev.status { - self.on_warning(error); + /// Record one MCP startup update, promoting it into either the active startup + /// round or a buffered "next" round. + /// + /// This path has to deal with lossy app-server delivery. After + /// `finish_mcp_startup()` or `finish_mcp_startup_after_lag()`, we briefly + /// ignore incoming updates so stale events from the just-finished round do not + /// reopen startup. While that guard is active we buffer updates for a possible + /// next round, and only reactivate once the buffered set is coherent enough to + /// treat as a fresh startup round. + fn update_mcp_startup_status( + &mut self, + server: String, + status: McpStartupStatus, + complete_when_settled: bool, + ) { + let mut activated_pending_round = false; + let startup_status = if self.mcp_startup_ignore_updates_until_next_start { + // Ignore-mode buffers the next plausible round so stale post-finish + // updates cannot immediately reopen startup. A fresh `Starting` + // update resets the buffer only if we have not already seen a + // pending-round `Starting`; this preserves valid interleavings like + // `alpha: Starting -> alpha: Ready -> beta: Starting`. + if matches!(status, McpStartupStatus::Starting) + && !self.mcp_startup_pending_next_round_saw_starting + { + self.mcp_startup_pending_next_round.clear(); + self.mcp_startup_allow_terminal_only_next_round = false; + } + self.mcp_startup_pending_next_round_saw_starting |= + matches!(status, McpStartupStatus::Starting); + self.mcp_startup_pending_next_round.insert(server, status); + let Some(expected_servers) = &self.mcp_startup_expected_servers else { + return; + }; + let saw_full_round = expected_servers.is_empty() + || expected_servers + .iter() + .all(|name| self.mcp_startup_pending_next_round.contains_key(name)); + let saw_starting = self + .mcp_startup_pending_next_round + .values() + .any(|state| matches!(state, McpStartupStatus::Starting)); + if !(saw_full_round + && (saw_starting || self.mcp_startup_allow_terminal_only_next_round)) + { + return; + } + + // The buffered map now looks like a complete next round, so promote it + // to the active round and resume normal completion tracking. + self.mcp_startup_ignore_updates_until_next_start = false; + self.mcp_startup_allow_terminal_only_next_round = false; + self.mcp_startup_pending_next_round_saw_starting = false; + activated_pending_round = true; + std::mem::take(&mut self.mcp_startup_pending_next_round) + } else { + // Normal path: fold the update into the active round and surface + // per-server failures immediately. + let mut startup_status = self.mcp_startup_status.take().unwrap_or_default(); + if let McpStartupStatus::Failed { error } = &status { + self.on_warning(error); + } + startup_status.insert(server, status); + startup_status + }; + if activated_pending_round { + // A promoted buffered round may already contain terminal failures. + for state in startup_status.values() { + if let McpStartupStatus::Failed { error } = state { + self.on_warning(error); + } + } } - status.insert(ev.server, ev.status); - self.mcp_startup_status = Some(status); + self.mcp_startup_status = Some(startup_status); self.update_task_running_state(); + + // App-server-backed startup completes when every expected server has + // reported a non-Starting status. Lag handling can force an earlier + // settle via `finish_mcp_startup_after_lag()`. + if complete_when_settled + && let Some(current) = &self.mcp_startup_status + && let Some(expected_servers) = &self.mcp_startup_expected_servers + && !current.is_empty() + && expected_servers + .iter() + .all(|name| current.contains_key(name)) + && current + .values() + .all(|state| !matches!(state, McpStartupStatus::Starting)) + { + let mut failed = Vec::new(); + let mut cancelled = Vec::new(); + for (name, state) in current { + match state { + McpStartupStatus::Ready => {} + McpStartupStatus::Failed { .. } => failed.push(name.clone()), + McpStartupStatus::Cancelled => cancelled.push(name.clone()), + McpStartupStatus::Starting => {} + } + } + failed.sort(); + cancelled.sort(); + self.finish_mcp_startup(failed, cancelled); + return; + } if let Some(current) = &self.mcp_startup_status { + // Otherwise keep the status header focused on the remaining + // in-progress servers for the active round. let total = current.len(); let mut starting: Vec<_> = current .iter() @@ -2827,29 +2938,104 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn set_mcp_startup_expected_servers(&mut self, server_names: I) + where + I: IntoIterator, + { + self.mcp_startup_expected_servers = Some(server_names.into_iter().collect()); + } + #[cfg(test)] - fn on_mcp_startup_complete(&mut self, ev: McpStartupCompleteEvent) { - let mut parts = Vec::new(); - if !ev.failed.is_empty() { - let failed_servers: Vec<_> = ev.failed.iter().map(|f| f.server.clone()).collect(); - parts.push(format!("failed: {}", failed_servers.join(", "))); - } - if !ev.cancelled.is_empty() { + fn on_mcp_startup_update(&mut self, ev: McpStartupUpdateEvent) { + self.update_mcp_startup_status(ev.server, ev.status, /*complete_when_settled*/ false); + } + + fn finish_mcp_startup(&mut self, failed: Vec, cancelled: Vec) { + if !cancelled.is_empty() { self.on_warning(format!( "MCP startup interrupted. The following servers were not initialized: {}", - ev.cancelled.join(", ") + cancelled.join(", ") )); } + let mut parts = Vec::new(); + if !failed.is_empty() { + parts.push(format!("failed: {}", failed.join(", "))); + } if !parts.is_empty() { self.on_warning(format!("MCP startup incomplete ({})", parts.join("; "))); } self.mcp_startup_status = None; + self.mcp_startup_ignore_updates_until_next_start = true; + self.mcp_startup_allow_terminal_only_next_round = false; + self.mcp_startup_pending_next_round.clear(); + self.mcp_startup_pending_next_round_saw_starting = false; 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_ignore_updates_until_next_start { + if self.mcp_startup_pending_next_round.is_empty() { + self.mcp_startup_pending_next_round_saw_starting = false; + } + self.mcp_startup_allow_terminal_only_next_round = true; + } + + let Some(current) = &self.mcp_startup_status else { + return; + }; + + let mut failed = Vec::new(); + let mut cancelled = Vec::new(); + + let mut server_names: BTreeSet = current.keys().cloned().collect(); + if let Some(expected_servers) = &self.mcp_startup_expected_servers { + server_names.extend(expected_servers.iter().cloned()); + } + + for name in server_names { + match current.get(&name) { + Some(McpStartupStatus::Ready) => {} + Some(McpStartupStatus::Failed { .. }) => failed.push(name), + Some(McpStartupStatus::Cancelled | McpStartupStatus::Starting) | None => { + cancelled.push(name); + } + } + } + + 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(); + self.finish_mcp_startup(failed, ev.cancelled); + } + + fn on_mcp_server_status_updated(&mut self, notification: McpServerStatusUpdatedNotification) { + let status = match notification.status { + McpServerStartupState::Starting => McpStartupStatus::Starting, + McpServerStartupState::Ready => McpStartupStatus::Ready, + McpServerStartupState::Failed => McpStartupStatus::Failed { + error: notification.error.unwrap_or_else(|| { + format!("MCP client for `{}` failed to start", notification.name) + }), + }, + McpServerStartupState::Cancelled => McpStartupStatus::Cancelled, + }; + self.update_mcp_startup_status( + notification.name, + status, + /*complete_when_settled*/ true, + ); + } + /// Handle a turn aborted due to user interrupt (Esc). /// When there are queued user messages, restore them into the composer /// separated by newlines rather than auto‑submitting the next one. @@ -4552,6 +4738,11 @@ impl ChatWidget { agent_turn_running: false, mcp_startup_status: None, pending_turn_copyable_output: None, + mcp_startup_expected_servers: None, + mcp_startup_ignore_updates_until_next_start: false, + mcp_startup_allow_terminal_only_next_round: false, + mcp_startup_pending_next_round: HashMap::new(), + mcp_startup_pending_next_round_saw_starting: false, connectors_cache: ConnectorsCacheState::default(), connectors_partial_snapshot: None, connectors_prefetch_in_flight: false, @@ -6315,6 +6506,9 @@ impl ChatWidget { .map(|details| format!("{}: {details}", notification.summary)) .unwrap_or(notification.summary), ), + ServerNotification::McpServerStatusUpdated(notification) => { + self.on_mcp_server_status_updated(notification) + } ServerNotification::ItemGuardianApprovalReviewStarted(notification) => { self.on_guardian_review_notification( notification.target_item_id, @@ -6398,7 +6592,6 @@ impl ChatWidget { | ServerNotification::RawResponseItemCompleted(_) | ServerNotification::CommandExecOutputDelta(_) | ServerNotification::McpToolCallProgress(_) - | ServerNotification::McpServerStatusUpdated(_) | ServerNotification::McpServerOauthLoginCompleted(_) | ServerNotification::AppListUpdated(_) | ServerNotification::FsChanged(_) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_mcp_startup_failure_renders_warning_history.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_mcp_startup_failure_renders_warning_history.snap new file mode 100644 index 000000000..be819de54 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_mcp_startup_failure_renders_warning_history.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 11761 +expression: term.backend().vt100().screen().contents() +--- + + + +⚠ MCP client for `alpha` failed to start: handshake failed +⚠ MCP startup incomplete (failed: alpha) + + +› Ask Codex to do anything + + gpt-5.3-codex default · 100% left · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a7119d962..b4950dad7 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -50,6 +50,8 @@ use codex_app_server_protocol::ItemGuardianApprovalReviewCompletedNotification; use codex_app_server_protocol::ItemGuardianApprovalReviewStartedNotification; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::MarketplaceInterface; +use codex_app_server_protocol::McpServerStartupState; +use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::PatchApplyStatus as AppServerPatchApplyStatus; use codex_app_server_protocol::PatchChangeKind; use codex_app_server_protocol::PluginAuthPolicy; @@ -2132,6 +2134,11 @@ async fn make_chatwidget_manual( unified_exec_processes: Vec::new(), agent_turn_running: false, mcp_startup_status: None, + mcp_startup_expected_servers: None, + mcp_startup_ignore_updates_until_next_start: false, + mcp_startup_allow_terminal_only_next_round: false, + mcp_startup_pending_next_round: HashMap::new(), + mcp_startup_pending_next_round_saw_starting: false, connectors_cache: ConnectorsCacheState::default(), connectors_partial_snapshot: None, plugin_install_apps_needing_auth: Vec::new(), @@ -11667,6 +11674,611 @@ async fn mcp_startup_complete_does_not_clear_running_task() { assert!(chat.bottom_pane.status_indicator_visible()); } +#[tokio::test] +async fn app_server_mcp_startup_failure_renders_warning_history() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ 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, + }), + /*replay_kind*/ None, + ); + + assert!(drain_insert_history(&mut rx).is_empty()); + assert!(chat.bottom_pane.is_task_running()); + + 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()), + }), + /*replay_kind*/ None, + ); + + let failure_cells = drain_insert_history(&mut rx); + let failure_text = failure_cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert!(failure_text.contains("MCP client for `alpha` failed to start: handshake failed")); + assert!(!failure_text.contains("MCP startup incomplete")); + assert!(chat.bottom_pane.is_task_running()); + + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "beta".to_string(), + status: McpServerStartupState::Starting, + error: None, + }), + /*replay_kind*/ None, + ); + + assert!(drain_insert_history(&mut rx).is_empty()); + assert!(chat.bottom_pane.is_task_running()); + + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "beta".to_string(), + status: McpServerStartupState::Ready, + error: None, + }), + /*replay_kind*/ None, + ); + + let summary_cells = drain_insert_history(&mut rx); + let summary_text = summary_cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert_eq!(summary_text, "⚠ MCP startup incomplete (failed: alpha)\n"); + assert!(!chat.bottom_pane.is_task_running()); + + let width: u16 = 120; + let ui_height: u16 = chat.desired_height(width); + let vt_height: u16 = 10; + let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height); + + let backend = VT100Backend::new(width, vt_height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + term.set_viewport_area(viewport); + + for lines in failure_cells.into_iter().chain(summary_cells) { + crate::insert_history::insert_history_lines(&mut term, lines) + .expect("Failed to insert history lines in test"); + } + + term.draw(|f| { + chat.render(f.area(), f.buffer_mut()); + }) + .expect("draw MCP startup warning history"); + + assert_snapshot!( + "app_server_mcp_startup_failure_renders_warning_history", + term.backend().vt100().screen().contents() + ); +} + +#[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(/*model_override*/ 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, + }), + /*replay_kind*/ 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()), + }), + /*replay_kind*/ None, + ); + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "beta".to_string(), + status: McpServerStartupState::Starting, + error: None, + }), + /*replay_kind*/ 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::Starting, + error: None, + }), + /*replay_kind*/ None, + ); + + assert!(drain_insert_history(&mut rx).is_empty()); + assert!(!chat.bottom_pane.is_task_running()); + + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "beta".to_string(), + status: McpServerStartupState::Ready, + error: None, + }), + /*replay_kind*/ None, + ); + + assert!(drain_insert_history(&mut rx).is_empty()); + assert!(!chat.bottom_pane.is_task_running()); +} + +#[tokio::test] +async fn app_server_mcp_startup_after_lag_can_settle_without_starting_updates() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.show_welcome_banner = false; + chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]); + + chat.finish_mcp_startup_after_lag(); + + 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()), + }), + /*replay_kind*/ None, + ); + + let failure_text = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert!(failure_text.contains("MCP client for `alpha` failed to start: handshake failed")); + assert!(chat.bottom_pane.is_task_running()); + + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "beta".to_string(), + status: McpServerStartupState::Ready, + error: None, + }), + /*replay_kind*/ None, + ); + + let summary_text = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert_eq!(summary_text, "⚠ MCP startup incomplete (failed: alpha)\n"); + assert!(!chat.bottom_pane.is_task_running()); +} + +#[tokio::test] +async fn app_server_mcp_startup_after_lag_preserves_partial_terminal_only_round() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ 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, + }), + /*replay_kind*/ 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()), + }), + /*replay_kind*/ None, + ); + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "beta".to_string(), + status: McpServerStartupState::Starting, + error: None, + }), + /*replay_kind*/ None, + ); + let _ = drain_insert_history(&mut rx); + + chat.finish_mcp_startup_after_lag(); + let _ = drain_insert_history(&mut rx); + assert!(!chat.bottom_pane.is_task_running()); + + 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()), + }), + /*replay_kind*/ None, + ); + + assert!(drain_insert_history(&mut rx).is_empty()); + assert!(!chat.bottom_pane.is_task_running()); + + chat.finish_mcp_startup_after_lag(); + + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "beta".to_string(), + status: McpServerStartupState::Ready, + error: None, + }), + /*replay_kind*/ None, + ); + + let summary_text = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert!(summary_text.contains("MCP client for `alpha` failed to start: handshake failed")); + assert!(summary_text.contains("MCP startup incomplete (failed: alpha)")); + assert!(!chat.bottom_pane.is_task_running()); +} + +#[tokio::test] +async fn app_server_mcp_startup_next_round_discards_stale_terminal_updates() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ 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, + }), + /*replay_kind*/ 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()), + }), + /*replay_kind*/ None, + ); + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "beta".to_string(), + status: McpServerStartupState::Starting, + error: None, + }), + /*replay_kind*/ None, + ); + let _ = drain_insert_history(&mut rx); + + chat.finish_mcp_startup_after_lag(); + let _ = drain_insert_history(&mut rx); + assert!(!chat.bottom_pane.is_task_running()); + + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "alpha".to_string(), + status: McpServerStartupState::Failed, + error: Some( + "MCP client for `alpha` failed to start: stale handshake failed".to_string(), + ), + }), + /*replay_kind*/ None, + ); + assert!(drain_insert_history(&mut rx).is_empty()); + + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "beta".to_string(), + status: McpServerStartupState::Starting, + error: None, + }), + /*replay_kind*/ None, + ); + assert!(drain_insert_history(&mut rx).is_empty()); + assert!(!chat.bottom_pane.is_task_running()); + + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "alpha".to_string(), + status: McpServerStartupState::Ready, + error: None, + }), + /*replay_kind*/ None, + ); + assert!(drain_insert_history(&mut rx).is_empty()); + assert!(chat.bottom_pane.is_task_running()); + + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "beta".to_string(), + status: McpServerStartupState::Ready, + error: None, + }), + /*replay_kind*/ None, + ); + + let summary_text = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert!(summary_text.is_empty()); + assert!(!chat.bottom_pane.is_task_running()); +} + +#[tokio::test] +async fn app_server_mcp_startup_next_round_keeps_terminal_statuses_after_starting() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.show_welcome_banner = false; + chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]); + + chat.finish_mcp_startup_after_lag(); + + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "alpha".to_string(), + status: McpServerStartupState::Starting, + error: None, + }), + /*replay_kind*/ None, + ); + assert!(drain_insert_history(&mut rx).is_empty()); + + 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()), + }), + /*replay_kind*/ None, + ); + + let failure_text = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert!(failure_text.contains("MCP client for `alpha` failed to start: handshake failed")); + + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "beta".to_string(), + status: McpServerStartupState::Starting, + error: None, + }), + /*replay_kind*/ None, + ); + assert!(drain_insert_history(&mut rx).is_empty()); + assert!(chat.bottom_pane.is_task_running()); + + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "beta".to_string(), + status: McpServerStartupState::Ready, + error: None, + }), + /*replay_kind*/ None, + ); + + let summary_text = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert_eq!(summary_text, "⚠ MCP startup incomplete (failed: alpha)\n"); + assert!(!chat.bottom_pane.is_task_running()); +} + +#[tokio::test] +async fn app_server_mcp_startup_next_round_with_empty_expected_servers_reactivates() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.show_welcome_banner = false; + chat.set_mcp_startup_expected_servers(std::iter::empty::()); + chat.finish_mcp_startup(Vec::new(), Vec::new()); + + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "runtime".to_string(), + status: McpServerStartupState::Starting, + error: None, + }), + /*replay_kind*/ None, + ); + assert!(drain_insert_history(&mut rx).is_empty()); + assert!(chat.bottom_pane.is_task_running()); + + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "runtime".to_string(), + status: McpServerStartupState::Failed, + error: Some("MCP client for `runtime` failed to start: handshake failed".to_string()), + }), + /*replay_kind*/ None, + ); + + let summary_text = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert!(summary_text.contains("MCP client for `runtime` failed to start: handshake failed")); + assert!(summary_text.contains("MCP startup incomplete (failed: runtime)")); + assert!(!chat.bottom_pane.is_task_running()); +} + +#[tokio::test] +async fn app_server_mcp_startup_after_lag_with_empty_expected_servers_preserves_failures() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.show_welcome_banner = false; + chat.set_mcp_startup_expected_servers(std::iter::empty::()); + + chat.on_mcp_startup_update(McpStartupUpdateEvent { + server: "runtime".to_string(), + status: McpStartupStatus::Starting, + }); + chat.on_mcp_startup_update(McpStartupUpdateEvent { + server: "runtime".to_string(), + status: McpStartupStatus::Failed { + error: "MCP client for `runtime` failed to start: handshake failed".to_string(), + }, + }); + + let warning_text = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert!(warning_text.contains("MCP client for `runtime` failed to start: handshake failed")); + 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 incomplete (failed: runtime)")); + assert!(!chat.bottom_pane.is_task_running()); +} + +#[tokio::test] +async fn app_server_mcp_startup_after_lag_includes_runtime_servers_with_expected_set() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.show_welcome_banner = false; + chat.set_mcp_startup_expected_servers(["alpha".to_string()]); + + chat.on_mcp_startup_update(McpStartupUpdateEvent { + server: "alpha".to_string(), + status: McpStartupStatus::Ready, + }); + chat.on_mcp_startup_update(McpStartupUpdateEvent { + server: "runtime".to_string(), + status: McpStartupStatus::Failed { + error: "MCP client for `runtime` failed to start: handshake failed".to_string(), + }, + }); + + let warning_text = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert!(warning_text.contains("MCP client for `runtime` failed to start: handshake failed")); + 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 incomplete (failed: runtime)")); + assert!(!chat.bottom_pane.is_task_running()); +} + +#[tokio::test] +async fn app_server_mcp_startup_next_round_after_lag_can_settle_without_starting_updates() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ 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, + }), + /*replay_kind*/ 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()), + }), + /*replay_kind*/ None, + ); + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "beta".to_string(), + status: McpServerStartupState::Starting, + error: None, + }), + /*replay_kind*/ None, + ); + let _ = drain_insert_history(&mut rx); + + chat.finish_mcp_startup_after_lag(); + let _ = drain_insert_history(&mut rx); + assert!(!chat.bottom_pane.is_task_running()); + + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "alpha".to_string(), + status: McpServerStartupState::Failed, + error: Some( + "MCP client for `alpha` failed to start: stale handshake failed".to_string(), + ), + }), + /*replay_kind*/ None, + ); + assert!(drain_insert_history(&mut rx).is_empty()); + + chat.finish_mcp_startup_after_lag(); + + 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()), + }), + /*replay_kind*/ None, + ); + + let failure_text = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert!(failure_text.is_empty()); + assert!(!chat.bottom_pane.is_task_running()); + + chat.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + name: "beta".to_string(), + status: McpServerStartupState::Ready, + error: None, + }), + /*replay_kind*/ None, + ); + + let summary_text = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert!(summary_text.contains("MCP client for `alpha` failed to start: handshake failed")); + assert!(summary_text.contains("MCP startup incomplete (failed: alpha)")); + 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;