diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 359e780dc05..203987a3457 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -551,6 +551,23 @@ impl McpConnectionManager { normalize_tools_for_model_with_prefix(tools, self.prefix_mcp_tool_names) } + /// Returns servers whose tool inventory is not yet available without waiting. + pub fn pending_server_names_without_cached_tool_info_snapshot(&self) -> Vec { + let mut pending = self + .clients + .iter() + .filter(|(_, managed_client)| { + managed_client.cached_tool_info_snapshot.is_none() + && !managed_client + .startup_complete + .load(std::sync::atomic::Ordering::Acquire) + }) + .map(|(server_name, _)| server_name.clone()) + .collect::>(); + pending.sort(); + pending + } + /// Force-refresh codex apps tools by bypassing the in-process cache. /// /// On success, the refreshed tools replace the cache contents and the diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index e08fdb7ddf5..ed80424cfdc 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -1004,6 +1004,10 @@ async fn list_ready_or_cached_tools_skips_pending_client_without_cached_tool_inf }, ); + assert_eq!( + manager.pending_server_names_without_cached_tool_info_snapshot(), + vec!["optional".to_string()] + ); let tools = tokio::time::timeout( Duration::from_millis(/*millis*/ 10), manager.list_ready_or_cached_tools(), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index e7f2a0a15ae..64b5b718d4f 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -221,6 +221,8 @@ pub(crate) use self::session::SessionSettingsUpdate; use self::turn::AssistantMessageStreamParsers; #[cfg(test)] use self::turn::collect_explicit_app_ids_from_skill_items; +#[cfg(test)] +use self::turn::filter_discoverable_tools_while_apps_inventory_pending; use self::turn::realtime_text_for_event; use self::turn_context::TurnContext; use self::turn_context::TurnSkillsContext; diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 205b14e14ed..79b91ec18b3 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1581,6 +1581,37 @@ fn collect_explicit_app_ids_from_skill_items_skips_plain_mentions_with_skill_con assert_eq!(connector_ids, HashSet::::new()); } +#[test] +fn pending_apps_inventory_omits_connector_install_suggestions_but_keeps_plugins() { + let tools = vec![ + codex_tools::DiscoverableTool::Connector(Box::new(make_connector("calendar", "Calendar"))), + codex_tools::DiscoverableTool::Plugin(Box::new(codex_tools::DiscoverablePluginInfo { + id: "docs".to_string(), + name: "Docs".to_string(), + description: None, + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + })), + ]; + + assert_eq!( + filter_discoverable_tools_while_apps_inventory_pending( + tools, /*apps_inventory_pending*/ true + ), + vec![codex_tools::DiscoverableTool::Plugin(Box::new( + codex_tools::DiscoverablePluginInfo { + id: "docs".to_string(), + name: "Docs".to_string(), + description: None, + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + } + ))] + ); +} + #[tokio::test] async fn reconstruct_history_matches_live_compactions() { let (session, turn_context) = make_session_and_context().await; @@ -9737,8 +9768,8 @@ async fn fatal_tool_error_stops_turn_and_reports_error() { &turn_context, crate::tools::router::ToolRouterParams { deferred_mcp_tools, - lazy_mcp_tools: None, mcp_tools: Some(tools), + lazy_mcp_tools: None, discoverable_tools: None, extension_tool_executors: Vec::new(), dynamic_tools: turn_context.dynamic_tools.as_slice(), diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 7568807b9c0..6f41ff72697 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -53,6 +53,8 @@ use crate::stream_events_utils::record_completed_response_item_with_finalized_fa use crate::tasks::emit_compact_metric; use crate::tools::ToolRouter; use crate::tools::context::SharedTurnDiffTracker; +use crate::tools::handlers::LazyMcpToolSearchLoader; +use crate::tools::handlers::LazyMcpToolSearchLoaders; use crate::tools::parallel::ToolCallRuntime; use crate::tools::registry::ToolArgumentDiffConsumer; use crate::tools::router::ToolRouterParams; @@ -97,6 +99,7 @@ use codex_protocol::protocol::ReasoningRawContentDeltaEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; +use codex_tools::DiscoverableTool; use codex_tools::ToolName; use codex_tools::filter_request_plugin_install_discoverable_tools_for_client; use codex_utils_stream_parser::AssistantTextChunk; @@ -1177,7 +1180,7 @@ async fn run_sampling_request( #[expect( clippy::await_holding_invalid_type, - reason = "ready-or-cached tool inspection never awaits unresolved MCP startup" + reason = "MCP tool inspection holds the session-owned manager guard while listing tools" )] #[instrument(level = "trace", skip_all, @@ -1199,10 +1202,28 @@ pub(crate) async fn built_tools( .instrument(trace_span!("read_mcp_connection_manager")) .await; let has_mcp_servers = mcp_connection_manager.has_servers(); - let all_mcp_tools = mcp_connection_manager - .list_ready_or_cached_tools() - .or_cancel(cancellation_token) - .await?; + let lazy_mcp_tool_search_available = + search_tool_enabled(turn_context) && turn_context.provider.capabilities().namespace_tools; + let (all_mcp_tools, mut pending_mcp_server_names) = if lazy_mcp_tool_search_available { + let pending_mcp_server_names = + mcp_connection_manager.pending_server_names_without_cached_tool_info_snapshot(); + let tools = mcp_connection_manager + .list_ready_or_cached_tools() + .or_cancel(cancellation_token) + .await?; + (tools, pending_mcp_server_names) + } else { + let tools = mcp_connection_manager + .list_all_tools() + .or_cancel(cancellation_token) + .await?; + (tools, Vec::new()) + }; + pending_mcp_server_names.retain(|server_name| { + !all_mcp_tools + .iter() + .any(|tool| tool.server_name == *server_name) + }); drop(mcp_connection_manager); let loaded_plugins = sess .services @@ -1211,6 +1232,11 @@ pub(crate) async fn built_tools( .await; let apps_enabled = turn_context.apps_enabled(); + let effective_plugin_connector_ids = loaded_plugins + .effective_apps() + .into_iter() + .map(|connector_id| connector_id.0) + .collect::>(); let accessible_connectors = apps_enabled.then(|| connectors::accessible_connectors_from_mcp_tools(&all_mcp_tools)); let accessible_connectors_with_enabled_state = @@ -1219,10 +1245,7 @@ pub(crate) async fn built_tools( }); let connectors = if apps_enabled { let connectors = codex_connectors::merge::merge_plugin_connectors_with_accessible( - loaded_plugins - .effective_apps() - .into_iter() - .map(|connector_id| connector_id.0), + effective_plugin_connector_ids.iter().cloned(), accessible_connectors.clone().unwrap_or_default(), ); Some(connectors::with_app_enabled_state( @@ -1232,6 +1255,41 @@ pub(crate) async fn built_tools( } else { None }; + let apps_inventory_pending = pending_mcp_server_names + .iter() + .any(|server_name| server_name == CODEX_APPS_MCP_SERVER_NAME); + // Keep the tool_search schema stable as background startup finishes. + let lazy_mcp_tools: Option = + (lazy_mcp_tool_search_available && has_mcp_servers).then(|| { + let mcp_connection_manager = Arc::clone(&sess.services.mcp_connection_manager); + let config = Arc::clone(&turn_context.config); + let effective_plugin_connector_ids = effective_plugin_connector_ids.clone(); + let available = { + let mcp_connection_manager = Arc::clone(&mcp_connection_manager); + let config = Arc::clone(&config); + let effective_plugin_connector_ids = effective_plugin_connector_ids.clone(); + Arc::new(move || { + load_available_mcp_tools_for_search( + Arc::clone(&mcp_connection_manager), + apps_enabled, + effective_plugin_connector_ids.clone(), + Arc::clone(&config), + ) + .boxed() + }) as LazyMcpToolSearchLoader + }; + let pending = Arc::new(move || { + load_pending_mcp_tools_for_search( + Arc::clone(&mcp_connection_manager), + pending_mcp_server_names.clone(), + apps_enabled, + effective_plugin_connector_ids.clone(), + Arc::clone(&config), + ) + .boxed() + }) as LazyMcpToolSearchLoader; + LazyMcpToolSearchLoaders { available, pending } + }); let auth = sess.services.auth_manager.auth().await; let loaded_plugin_app_connector_ids = loaded_plugins .effective_apps() @@ -1248,6 +1306,10 @@ pub(crate) async fn built_tools( ) .await .map(|discoverable_tools| { + let discoverable_tools = filter_discoverable_tools_while_apps_inventory_pending( + discoverable_tools, + apps_inventory_pending, + ); filter_request_plugin_install_discoverable_tools_for_client( discoverable_tools, turn_context.app_server_client_name.as_deref(), @@ -1271,7 +1333,7 @@ pub(crate) async fn built_tools( &all_mcp_tools, connectors.as_deref(), &turn_context.config, - search_tool_enabled(turn_context), + lazy_mcp_tool_search_available, ); let mcp_tools = has_mcp_servers.then_some(mcp_tool_exposure.direct_tools); let deferred_mcp_tools = mcp_tool_exposure.deferred_tools; @@ -1280,7 +1342,7 @@ pub(crate) async fn built_tools( ToolRouterParams { mcp_tools, deferred_mcp_tools, - lazy_mcp_tools: None, + lazy_mcp_tools, discoverable_tools, extension_tool_executors: extension_tool_executors(sess), dynamic_tools: turn_context.dynamic_tools.as_slice(), @@ -1288,6 +1350,80 @@ pub(crate) async fn built_tools( ))) } +pub(super) fn filter_discoverable_tools_while_apps_inventory_pending( + discoverable_tools: Vec, + apps_inventory_pending: bool, +) -> Vec { + if !apps_inventory_pending { + return discoverable_tools; + } + + // Connector installability depends on current Apps accessibility, which is + // unknown until codex_apps startup completes. Plugin suggestions are + // independent of that pending inventory. + discoverable_tools + .into_iter() + .filter(|tool| matches!(tool, DiscoverableTool::Plugin(_))) + .collect() +} + +async fn load_pending_mcp_tools_for_search( + mcp_connection_manager: Arc>, + pending_mcp_server_names: Vec, + apps_enabled: bool, + effective_plugin_connector_ids: Vec, + config: Arc, +) -> Vec { + let readiness = { + let manager = mcp_connection_manager.read().await; + manager.wait_for_servers_ready(&pending_mcp_server_names) + }; + let _failures = readiness.await; + load_available_mcp_tools_for_search( + mcp_connection_manager, + apps_enabled, + effective_plugin_connector_ids, + config, + ) + .await +} + +#[expect( + clippy::await_holding_invalid_type, + reason = "post-readiness tool inspection only reads available MCP inventory" +)] +async fn load_available_mcp_tools_for_search( + mcp_connection_manager: Arc>, + apps_enabled: bool, + effective_plugin_connector_ids: Vec, + config: Arc, +) -> Vec { + // Names are normalized across the entire visible MCP inventory. Rebuild + // all searchable MCP entries together so a newly introduced collision + // cannot invalidate a previously returned name. + let available_mcp_tools = mcp_connection_manager + .read() + .await + .list_ready_or_cached_tools() + .await; + let connectors = apps_enabled.then(|| { + let connectors = codex_connectors::merge::merge_plugin_connectors_with_accessible( + effective_plugin_connector_ids, + connectors::accessible_connectors_from_mcp_tools(&available_mcp_tools), + ); + connectors::with_app_enabled_state(connectors, config.as_ref()) + }); + let mcp_tool_exposure = build_mcp_tool_exposure( + &available_mcp_tools, + connectors.as_deref(), + config.as_ref(), + /*search_tool_enabled*/ true, + ); + let mut searchable_tools = mcp_tool_exposure.direct_tools; + searchable_tools.extend(mcp_tool_exposure.deferred_tools.unwrap_or_default()); + searchable_tools +} + #[derive(Debug)] struct SamplingRequestResult { needs_follow_up: bool, diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 1bb3db03581..827a9c9e08c 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -68,6 +68,7 @@ pub use request_user_input::RequestUserInputHandler; pub use shell::ShellCommandHandler; pub(crate) use shell::ShellCommandHandlerOptions; pub use test_sync::TestSyncHandler; +pub(crate) use tool_search::LazyMcpToolSearchLoader; pub(crate) use tool_search::LazyMcpToolSearchLoaders; pub use tool_search::ToolSearchHandler; pub use unified_exec::ExecCommandHandler; diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index e14828e42cc..10937627283 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -66,6 +66,13 @@ impl AppsTestServer { } pub async fn mount_searchable(server: &MockServer) -> Result { + Self::mount_searchable_with_tools_list_delay(server, /*tools_list_delay*/ None).await + } + + pub async fn mount_searchable_with_tools_list_delay( + server: &MockServer, + tools_list_delay: Option, + ) -> Result { mount_oauth_metadata(server).await; mount_connectors_directory(server).await; mount_streamable_http_json_rpc( @@ -74,7 +81,7 @@ impl AppsTestServer { CONNECTOR_DESCRIPTION.to_string(), /*searchable*/ true, /*include_app_only_tool*/ false, - /*tools_list_delay*/ None, + tools_list_delay, ) .await; Ok(Self { diff --git a/codex-rs/core/tests/suite/openai_file_mcp.rs b/codex-rs/core/tests/suite/openai_file_mcp.rs index eaf98c85733..275b9fe6e62 100644 --- a/codex-rs/core/tests/suite/openai_file_mcp.rs +++ b/codex-rs/core/tests/suite/openai_file_mcp.rs @@ -160,7 +160,7 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res tokio::fs::write(test.cwd.path().join("report.txt"), b"hello world").await?; test.submit_turn_with_approval_and_permission_profile( - "Extract the report text with the app tool.", + "Use [$calendar](app://calendar) to extract the report text.", AskForApproval::Never, PermissionProfile::Disabled, ) diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index a2912e8f0bd..2f4ae4718f2 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -501,6 +501,9 @@ async fn selected_skill_rewaits_for_app_after_installing_mcp_dependency() -> Res Some(Duration::from_secs(/*secs*/ 2)), ) .await?; + let dependency_server = start_mock_server().await; + AppsTestServer::mount_with_connector_name(&dependency_server, "Dependency").await?; + let dependency_url = format!("{}/api/codex/apps", dependency_server.uri()); let mock = mount_sse_once( &server, sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), @@ -516,7 +519,6 @@ async fn selected_skill_rewaits_for_app_after_installing_mcp_dependency() -> Res .expect("write plugin app skill"); let skill_agents_dir = skill_path.parent().expect("skill dir").join("agents"); std::fs::create_dir_all(&skill_agents_dir).expect("create skill agents dir"); - let dependency_url = format!("{}/api/codex/apps", server.uri()); std::fs::write( skill_agents_dir.join("openai.yaml"), format!( diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 37ce40c15eb..2d646c67e06 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -262,15 +262,6 @@ async fn app_only_tools_are_not_visible_or_runnable_by_direct_model_calls() -> R .await?; let requests = mock.requests(); - assert!( - namespace_child_tool( - &requests[0].body_json(), - SEARCH_CALENDAR_NAMESPACE, - SEARCH_CALENDAR_CREATE_TOOL - ) - .is_some(), - "visible tool from the app-only tool's connector should be declared" - ); assert!( namespace_child_tool( &requests[0].body_json(), @@ -420,6 +411,66 @@ async fn search_tool_hides_apps_tools_without_search() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn non_search_model_waits_for_pending_apps_tools_on_first_request() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount_with_connector_name_and_tools_list_delay( + &server, + "Calendar", + Some(Duration::from_secs(/*secs*/ 2)), + ) + .await?; + let mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = + apps_enabled_builder(apps_server.chatgpt_base_url.clone()).with_config(|config| { + configure_search_capable_model(config); + let model = config + .model_catalog + .as_mut() + .and_then(|catalog| { + catalog + .models + .iter_mut() + .find(|model| model.slug == "gpt-5.4") + }) + .expect("gpt-5.4 exists in bundled models.json"); + model.supports_search_tool = false; + }); + let test = builder.build(&server).await?; + + test.submit_turn_with_approval_and_permission_profile( + "Create a calendar event", + AskForApproval::Never, + PermissionProfile::Disabled, + ) + .await?; + + let body = mock.single_request().body_json(); + let tools = tool_names(&body); + assert!(!tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME)); + assert!( + namespace_child_tool( + &body, + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL + ) + .is_some() + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn explicit_app_mentions_respect_always_defer() -> Result<()> { skip_if_no_network!(Ok(())); @@ -480,7 +531,11 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let apps_server = AppsTestServer::mount_searchable(&server).await?; + let apps_server = AppsTestServer::mount_searchable_with_tools_list_delay( + &server, + Some(Duration::from_secs(/*secs*/ 2)), + ) + .await?; let call_id = "tool-search-1"; let mock = mount_sse_sequence( &server, @@ -682,6 +737,11 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - .any(|name| name == SEARCH_CALENDAR_NAMESPACE), "app namespace should still be hidden before search: {first_request_tools:?}" ); + assert!( + tool_search_description(&first_request_body).is_some_and(|description| description + .contains("- MCP tools: Tools from MCP servers still starting")), + "pending Apps startup should be advertised as an MCP tool_search source" + ); let output_item = tool_search_output_item(&requests[1], call_id); assert_eq!( @@ -1137,17 +1197,6 @@ async fn tool_search_indexes_only_enabled_non_app_mcp_tools() -> Result<()> { .any(|name| name == TOOL_SEARCH_TOOL_NAME), "first request should advertise tool_search: {first_request_tools:?}" ); - assert!( - !first_request_tools - .iter() - .any(|name| name == "mcp__rmcp__echo"), - "non-app MCP tools should be hidden before search in large-search mode: {first_request_tools:?}" - ); - assert!( - !first_request_tools.iter().any(|name| name == "mcp__rmcp"), - "non-app MCP namespace should be hidden before search in large-search mode: {first_request_tools:?}" - ); - let echo_tools = tool_search_output_tools(&requests[1], echo_call_id); let echo_output = json!({ "tools": echo_tools }); let rmcp_echo_tool = namespace_child_tool(&echo_output, "mcp__rmcp", "echo") diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3cf983e7b58..b6d642c2103 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1211,10 +1211,8 @@ See the Codex keymap documentation for supported actions and examples." app_server: &mut AppServerSession, event: TuiEvent, ) -> Result { - if self.should_defer_draw_for_session_header_commit(&event) { - return Ok(AppRunControl::Continue); - } - + let defer_draw_for_session_header_commit = + self.should_defer_draw_for_session_header_commit(&event); let terminal_resize_reflow_enabled = self.terminal_resize_reflow_enabled(); if terminal_resize_reflow_enabled && matches!(event, TuiEvent::Draw | TuiEvent::Resize) { self.handle_draw_pre_render(tui)?; @@ -1224,6 +1222,12 @@ See the Codex keymap documentation for supported actions and examples." self.refresh_status_line(); } } + if defer_draw_for_session_header_commit { + // Preserve current dimensions for the queued session header insert while keeping the + // provisional header and composer from being painted as separate frames. + tui.terminal.autoresize()?; + return Ok(AppRunControl::Continue); + } if self.overlay.is_some() { let _ = self.handle_backtrack_overlay_event(tui, event).await?; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__startup_header_handoff_visible_states.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__startup_header_handoff_visible_states.snap index e762af12937..9be6631ac42 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__startup_header_handoff_visible_states.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__startup_header_handoff_visible_states.snap @@ -3,19 +3,11 @@ source: tui/src/chatwidget/tests/status_and_layout.rs expression: "format!(\"provisional live frame:\\n{placeholder}\\n\\ncommitted history:\\n{committed_header}\")" --- provisional live frame: -" " -"╭───────────────────────────────────────╮ " -"│ >_ OpenAI Codex (v0.0.0) │ " -"│ │ " -"│ model: loading /model to change │ " -"│ directory: /tmp/project │ " -"╰───────────────────────────────────────╯ " -" " -" " -"› Explain this codebase " -" " -" gpt-5.5 default · /tmp/project " - +>_ OpenAI Codex (v0.0.0) +model: loading /model to change +directory: /tmp/project +› Explain this codebase +gpt-5.5 default · /tmp/project committed history: ╭────────────────────────────────────────────────╮ diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d671e8863da..c53e60a6637 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1581,6 +1581,21 @@ async fn ui_snapshots_small_heights_task_running() { #[tokio::test] async fn startup_header_handoff_visible_states_snapshot() { + fn visible_text(text: &str) -> String { + text.lines() + .filter_map(|line| { + let line = line.trim_matches('"').trim(); + if line.is_empty() || line.starts_with('╭') || line.starts_with('╰') { + None + } else { + Some(line.trim_matches('│').trim()) + } + }) + .filter(|line| !line.is_empty()) + .collect::>() + .join("\n") + } + let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let cfg = test_config().await; @@ -1616,7 +1631,7 @@ async fn startup_header_handoff_visible_states_snapshot() { terminal .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw placeholder header"); - let placeholder = normalized_backend_snapshot(terminal.backend()); + let placeholder = visible_text(&normalized_backend_snapshot(terminal.backend())); let rollout_file = NamedTempFile::new().expect("rollout file"); chat.handle_thread_session(crate::session_state::ThreadSessionState {