diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 79ac7e1bb8dd..9fe8a85d4fc4 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -65,6 +65,7 @@ use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Verbosity as VerbosityConfig; use codex_protocol::models::ContentItem; +use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; @@ -1163,18 +1164,13 @@ impl ModelClientSession { None, ) })?; + let completion_items = synthesize_chat_completions_output_items(&assistant_text); let (tx_event, rx_event) = mpsc::channel(8); let _ = tx_event.try_send(Ok(ResponseEvent::Created)); - let _ = tx_event.try_send(Ok(ResponseEvent::OutputItemDone(ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: assistant_text, - }], - end_turn: None, - phase: None, - }))); + for item in completion_items { + let _ = tx_event.try_send(Ok(ResponseEvent::OutputItemDone(item))); + } let _ = tx_event.try_send(Ok(ResponseEvent::Completed { response_id, token_usage: None, @@ -1588,6 +1584,98 @@ fn extract_chat_completions_text(response_json: &JsonValue) -> Option { }) } +fn synthesize_chat_completions_output_items(text: &str) -> Vec { + let parsed = parse_claude_tool_wrapper_blocks(text); + if parsed.is_empty() { + return vec![assistant_message_item(text.to_string())]; + } + + let mut items = Vec::new(); + for item in parsed { + items.push(item); + } + items +} + +fn assistant_message_item(text: String) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { text }], + end_turn: None, + phase: None, + } +} + +fn parse_claude_tool_wrapper_blocks(text: &str) -> Vec { + let mut items = Vec::new(); + let mut remaining = text; + let mut tool_index = 0usize; + + while let Some(start) = remaining.find("") { + let before = &remaining[..start]; + if !before.is_empty() { + items.push(assistant_message_item(before.to_string())); + } + + let after_open = &remaining[start + "".len()..]; + let Some(end_call) = after_open.find("") else { + return vec![assistant_message_item(text.to_string())]; + }; + let tool_call_payload = after_open[..end_call].trim(); + let after_call = &after_open[end_call + "".len()..]; + + let Some(result_start) = after_call.find("") else { + return vec![assistant_message_item(text.to_string())]; + }; + let between = &after_call[..result_start]; + if !between.is_empty() { + items.push(assistant_message_item(between.to_string())); + } + + let after_result_open = &after_call[result_start + "".len()..]; + let Some(end_result) = after_result_open.find("") else { + return vec![assistant_message_item(text.to_string())]; + }; + let tool_result_payload = after_result_open[..end_result].trim(); + remaining = &after_result_open[end_result + "".len()..]; + + let Ok(tool_call_json) = serde_json::from_str::(tool_call_payload) else { + return vec![assistant_message_item(text.to_string())]; + }; + let Some(name) = tool_call_json + .get("name") + .and_then(JsonValue::as_str) + .map(ToString::to_string) + else { + return vec![assistant_message_item(text.to_string())]; + }; + let arguments = tool_call_json + .get("arguments") + .cloned() + .unwrap_or(JsonValue::Object(serde_json::Map::new())); + let call_id = format!("chat-completions-tool-call-{tool_index}"); + tool_index += 1; + items.push(ResponseItem::FunctionCall { + id: None, + name, + namespace: None, + arguments: arguments.to_string(), + call_id: call_id.clone(), + }); + items.push(ResponseItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload::from_text(tool_result_payload.to_string()), + }); + } + + if !remaining.is_empty() { + items.push(assistant_message_item(remaining.to_string())); + } + + items +} + fn map_response_stream( api_stream: S, session_telemetry: SessionTelemetry, diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 441a34864577..2f10e244e0ef 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -1,9 +1,12 @@ use super::AuthRequestTelemetryContext; use super::ModelClient; use super::PendingUnauthorizedRetry; +use super::synthesize_chat_completions_output_items; use super::UnauthorizedRecoveryExecution; use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; +use codex_protocol::models::FunctionCallOutputBody; +use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; @@ -116,3 +119,56 @@ fn auth_request_telemetry_context_tracks_attached_auth_and_retry_phase() { assert_eq!(auth_context.recovery_mode, Some("managed")); assert_eq!(auth_context.recovery_phase, Some("refresh_token")); } + +#[test] +fn synthesize_chat_completions_output_items_converts_claude_tool_wrappers() { + let items = synthesize_chat_completions_output_items( + "before{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}}okafter", + ); + + assert_eq!(items.len(), 4); + assert!(matches!( + &items[0], + ResponseItem::Message { role, content, .. } + if role == "assistant" + && matches!( + content.first(), + Some(codex_protocol::models::ContentItem::OutputText { text }) if text == "before" + ) + )); + assert!(matches!( + &items[1], + ResponseItem::FunctionCall { name, arguments, .. } + if name == "shell" && arguments == "{\"command\":\"pwd\"}" + )); + assert!(matches!( + &items[2], + ResponseItem::FunctionCallOutput { output, .. } + if matches!(&output.body, FunctionCallOutputBody::Text(text) if text == "ok") + )); + assert!(matches!( + &items[3], + ResponseItem::Message { role, content, .. } + if role == "assistant" + && matches!( + content.first(), + Some(codex_protocol::models::ContentItem::OutputText { text }) if text == "after" + ) + )); +} + +#[test] +fn synthesize_chat_completions_output_items_leaves_plain_text_unchanged() { + let items = synthesize_chat_completions_output_items("plain assistant text"); + + assert_eq!(items.len(), 1); + assert!(matches!( + &items[0], + ResponseItem::Message { role, content, .. } + if role == "assistant" + && matches!( + content.first(), + Some(codex_protocol::models::ContentItem::OutputText { text }) if text == "plain assistant text" + ) + )); +} diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 0decfa63e5eb..0ba1b9dcba70 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -474,10 +474,7 @@ impl ModelsManager { candidates: &[ModelInfo], config: &Config, ) -> ModelInfo { - // First use the normal longest-prefix match. If that misses, allow a narrowly scoped - // retry for namespaced slugs like `custom/gpt-5.3-codex`. - let remote = Self::find_model_by_longest_prefix(model, candidates) - .or_else(|| Self::find_model_by_namespaced_suffix(model, candidates)); + let remote = Self::find_metadata_candidate(model, candidates); let model_info = if let Some(remote) = remote { ModelInfo { slug: model.to_string(), @@ -490,6 +487,15 @@ impl ModelsManager { model_info::with_config_overrides(model_info, config) } + /// Reuse the same metadata lookup rules for both direct model selection and provider-backed + /// model listings. + fn find_metadata_candidate(model: &str, candidates: &[ModelInfo]) -> Option { + // First use the normal longest-prefix match. If that misses, allow a narrowly scoped + // retry for namespaced slugs like `custom/gpt-5.3-codex`. + Self::find_model_by_longest_prefix(model, candidates) + .or_else(|| Self::find_model_by_namespaced_suffix(model, candidates)) + } + /// Refresh models if the provided ETag differs from the cached ETag. /// /// Uses `Online` strategy to fetch latest models when ETags differ. @@ -1010,10 +1016,7 @@ impl ModelsManager { return None; } - let mut candidate = bundled_models - .iter() - .find(|bundled| bundled.slug == model_id) - .cloned() + let mut candidate = Self::find_metadata_candidate(&model_id, &bundled_models) .unwrap_or_else(|| model_info::model_info_from_slug(&model_id)); candidate.slug = model_id.clone(); if candidate.display_name.is_empty() { diff --git a/codex-rs/core/src/models_manager/manager_tests.rs b/codex-rs/core/src/models_manager/manager_tests.rs index 70b42637b00f..69a0142d162a 100644 --- a/codex-rs/core/src/models_manager/manager_tests.rs +++ b/codex-rs/core/src/models_manager/manager_tests.rs @@ -5,6 +5,7 @@ use crate::config::ConfigBuilder; use crate::model_provider_info::WireApi; use chrono::Utc; use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::openai_models::ReasoningEffort; use core_test_support::responses::mount_models_once; use pretty_assertions::assert_eq; use serde_json::json; @@ -827,6 +828,88 @@ async fn models_dev_provider_match_uses_base_url_host_when_name_is_custom() { ); } +#[tokio::test] +async fn models_dev_provider_alias_model_inherits_canonical_reasoning_metadata() { + let models_dev_server = MockServer::start().await; + let _models_dev = wiremock::Mock::given(method("GET")) + .and(path("/api.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "azure": { + "id": "azure", + "name": "Azure", + "api": "https://azure.example.com/openai", + "env": ["AZURE_OPENAI_API_KEY"], + "models": { + "azure/gpt-5.4-pro": { + "id": "azure/gpt-5.4-pro", + "name": "Azure GPT 5.4 Pro", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "temperature": true, + "tool_call": true, + "limit": {"context": 128000, "output": 4096}, + "options": {} + } + } + } + }))) + .expect(1) + .mount_as_scoped(&models_dev_server) + .await; + + let codex_home = tempdir().expect("temp dir"); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("unused")); + let provider = ModelProviderInfo { + name: "Azure OpenAI".to_string(), + base_url: Some("https://azure.example.com/openai".to_string()), + env_key: Some("AZURE_OPENAI_API_KEY".to_string()), + env_key_instructions: None, + experimental_bearer_token: None, + wire_api: WireApi::Responses, + query_params: Some( + [("api-version".to_string(), "2025-04-01-preview".to_string())] + .into_iter() + .collect(), + ), + http_headers: None, + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: Some(0), + stream_idle_timeout_ms: Some(5_000), + websocket_connect_timeout_ms: None, + requires_openai_auth: false, + supports_websockets: false, + }; + let manager = ModelsManager::with_provider_and_models_dev_url_for_tests( + codex_home.path().to_path_buf(), + auth_manager, + provider, + format!("{}/api.json", models_dev_server.uri()), + ); + + let available = manager.list_models(RefreshStrategy::OnlineIfUncached).await; + let preset = available + .iter() + .find(|preset| preset.model == "azure/gpt-5.4-pro") + .expect("expected Azure alias model to be listed"); + + assert_eq!(preset.default_reasoning_effort, ReasoningEffort::Medium); + assert_eq!( + preset + .supported_reasoning_efforts + .iter() + .map(|preset| preset.effort) + .collect::>(), + vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ] + ); +} + #[tokio::test] async fn non_openai_provider_falls_back_to_provider_models_when_models_dev_has_no_match() { let models_dev_server = MockServer::start().await; diff --git a/codex-rs/core/src/stream_events_utils_tests.rs b/codex-rs/core/src/stream_events_utils_tests.rs index bfebb8902c52..2d76780bae96 100644 --- a/codex-rs/core/src/stream_events_utils_tests.rs +++ b/codex-rs/core/src/stream_events_utils_tests.rs @@ -68,6 +68,7 @@ fn last_assistant_message_from_item_returns_none_for_plan_only_hidden_message() assert_eq!(last_assistant_message_from_item(&item, true), None); } + #[tokio::test] async fn save_image_generation_result_saves_base64_to_png_in_temp_dir() { let expected_path = std::env::temp_dir().join("ig_save_base64.png"); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ca3d7449467d..c9380e184789 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3270,6 +3270,18 @@ impl App { .clone() .unwrap_or_else(|| self.config.model_provider_id.clone()); let provider_changed = selected_provider != self.config.model_provider_id; + if provider_changed { + if let Some(provider_info) = + self.config.model_providers.get(&selected_provider).cloned() + { + self.config.model_provider = provider_info.clone(); + self.chat_widget.config_mut().model_provider = provider_info; + } + self.config.model_provider_id = selected_provider.clone(); + self.chat_widget.config_mut().model_provider_id = selected_provider.clone(); + self.chat_widget.set_model(&model); + self.refresh_status_line(); + } let mut builder = ConfigEditsBuilder::new(&self.config.codex_home) .with_profile(profile.as_deref()) @@ -3292,9 +3304,6 @@ impl App { match builder.apply().await { Ok(()) => { - if provider_changed { - self.start_fresh_session_with_summary_hint(tui).await; - } let effort_label = effort .map(|selected_effort| selected_effort.to_string()) .unwrap_or_else(|| "default".to_string()); @@ -3310,9 +3319,6 @@ impl App { message.push(' '); message.push_str(label); } - if provider_changed { - message.push_str(". Provider change applied in a fresh session"); - } if let Some(profile) = profile.as_deref() { message.push_str(" for "); message.push_str(profile); @@ -7747,6 +7753,63 @@ model = "claude-opus-4.6" Ok(()) } + #[tokio::test] + async fn persist_model_selection_switches_provider_in_runtime() -> Result<()> { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf(); + std::fs::write( + codex_home.path().join("config.toml"), + r#" +model_provider = "openai" +model = "gpt-5.4" +"#, + )?; + + let copilot_provider = codex_core::ModelProviderInfo::create_github_copilot_provider(); + app.config + .model_providers + .insert("github-copilot".to_string(), copilot_provider.clone()); + app.chat_widget + .config_mut() + .model_providers + .insert("github-copilot".to_string(), copilot_provider); + + let mut tui = crate::tui::tests::create_test_tui()?; + let control = app + .handle_event( + &mut tui, + AppEvent::PersistModelSelection { + provider: Some("github-copilot".to_string()), + model: "claude-4.6-opus".to_string(), + effort: None, + }, + ) + .await?; + + assert!(matches!(control, AppRunControl::Continue)); + assert_eq!(app.config.model_provider_id, "github-copilot"); + assert_eq!(app.config.model.as_deref(), Some("gpt-5.4")); + assert_eq!( + app.chat_widget.config_ref().model_provider_id, + "github-copilot" + ); + assert_eq!( + app.chat_widget.config_ref().model.as_deref(), + Some("claude-4.6-opus") + ); + assert_eq!(app.chat_widget.current_model(), "claude-4.6-opus"); + + let rebuilt = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await?; + assert_eq!(rebuilt.model_provider_id, "github-copilot"); + assert_eq!(rebuilt.model.as_deref(), Some("claude-4.6-opus")); + + Ok(()) + } + #[tokio::test] async fn sync_tui_theme_selection_updates_chat_widget_config_copy() { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index de56ab883cab..c9e7f10730bb 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -8227,6 +8227,7 @@ impl ChatWidget { /// Set the model in the widget's config copy and stored collaboration mode. pub(crate) fn set_model(&mut self, model: &str) { + self.config.model = Some(model.to_string()); self.current_collaboration_mode = self.current_collaboration_mode.with_updates( Some(model.to_string()), /*effort*/ None, @@ -9438,6 +9439,10 @@ impl ChatWidget { &self.config } + pub(crate) fn config_mut(&mut self) -> &mut Config { + &mut self.config + } + #[cfg(test)] pub(crate) fn status_line_text(&self) -> Option { self.bottom_pane.status_line_text() diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 809f99b51660..dd5268387d23 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -8327,7 +8327,7 @@ async fn model_picker_switches_to_azure_model_and_back_without_runtime_switch() } #[tokio::test] -async fn model_picker_switches_from_gpt5_3_to_copilot_claude_without_runtime_switch() { +async fn model_picker_switches_from_gpt5_3_to_copilot_claude_with_runtime_switch() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; chat.config.model_provider_id = "azure".to_string(); chat.set_model("gpt-5.3-codex"); @@ -8393,16 +8393,19 @@ async fn model_picker_switches_from_gpt5_3_to_copilot_claude_without_runtime_swi "expected persisted Copilot Claude model selection; events: {switch_events:?}" ); assert!( - !switch_events + switch_events .iter() - .any(|event| matches!(event, AppEvent::UpdateModel(_))), - "did not expect in-session model update for provider switch; events: {switch_events:?}" + .any(|event| matches!(event, AppEvent::UpdateModel(model) if model == "claude-opus-4.6")), + "expected in-session model update for provider switch; events: {switch_events:?}" ); assert!( - !switch_events + switch_events .iter() - .any(|event| matches!(event, AppEvent::UpdateReasoningEffort(_))), - "did not expect in-session reasoning update for provider switch; events: {switch_events:?}" + .any(|event| matches!( + event, + AppEvent::UpdateReasoningEffort(Some(ReasoningEffortConfig::Medium)) + )), + "expected in-session reasoning update for provider switch; events: {switch_events:?}" ); } diff --git a/codex-rs/tui/tests/suite/model_switching_e2e.rs b/codex-rs/tui/tests/suite/model_switching_e2e.rs index 22ea2f1ba193..6a9fac87f2b0 100644 --- a/codex-rs/tui/tests/suite/model_switching_e2e.rs +++ b/codex-rs/tui/tests/suite/model_switching_e2e.rs @@ -638,6 +638,93 @@ async fn models_dev_provider_model_switch_then_prompt_uses_selected_model() -> R Ok(()) } +#[tokio::test] +async fn models_dev_provider_alias_model_switch_then_prompt_uses_selected_reasoning_effort() +-> Result<()> { + if cfg!(windows) { + return Ok(()); + } + + let repo_root = codex_utils_cargo_bin::repo_root()?; + let Some(codex_cli) = find_codex_cli(&repo_root) else { + eprintln!("skipping integration test because codex binary is unavailable"); + return Ok(()); + }; + + let prompt = "What year did GPT-1 launch?"; + let answer_text = "GPT-1 launched in 2018."; + let startup_model = "azure/gpt-5.4"; + let selected_model = "azure/gpt-5.4-pro"; + let selected_reasoning = "high"; + let provider_id = "azure-local"; + + let (provider_base_url, models_dev_url, server) = spawn_models_dev_and_responses_server( + "azure", + "Azure", + &[startup_model, selected_model], + selected_model, + answer_text, + )?; + let codex_home = tempdir_with_models_dev_provider_config( + &repo_root, + provider_id, + "Azure", + startup_model, + provider_base_url.as_str(), + "AZURE_TEST_KEY", + )?; + + let mut env = HashMap::new(); + env.insert("AZURE_TEST_KEY".to_string(), "test-azure-token".to_string()); + env.insert("CODEX_MODELS_DEV_URL".to_string(), models_dev_url); + + let output = run_codex_cli_with_filter_and_reasoning( + &codex_cli, + codex_home.path(), + &repo_root, + startup_model, + selected_model, + Some(selected_reasoning), + Some(prompt), + env, + ) + .await + .context("switch models.dev Azure alias via /model, pick reasoning, and run prompt")?; + let requests = server + .join() + .expect("models.dev/responses server should join")?; + + anyhow::ensure!( + requests + .iter() + .any(|request| request.starts_with("GET /api.json ")), + "expected GET /api.json request; got requests: {requests:?}; output: {output}" + ); + assert_model_and_provider_in_config(codex_home.path(), selected_model, provider_id, &output)?; + anyhow::ensure!( + output.contains("2018"), + "expected answer to contain 2018, got output: {output}" + ); + let responses_request = requests + .iter() + .find(|request| request.starts_with("POST /v1/responses ")) + .context("missing POST /v1/responses request")?; + anyhow::ensure!( + responses_request.contains(format!("\"model\":\"{selected_model}\"").as_str()), + "expected switched model in /responses request body; request: {responses_request}" + ); + anyhow::ensure!( + responses_request.contains("\"reasoning\":{\"effort\":\"high\"}"), + "expected selected reasoning effort in /responses request body; request: {responses_request}" + ); + anyhow::ensure!( + responses_request.contains(prompt), + "expected prompt text in /responses request body; request: {responses_request}" + ); + + Ok(()) +} + #[tokio::test] async fn models_dev_provider_config_parses_custom_provider() -> Result<()> { let repo_root = codex_utils_cargo_bin::repo_root()?; @@ -1199,6 +1286,31 @@ async fn run_codex_cli_with_filter( cwd, startup_model_hint, filter, + None, + prompt_after_switch, + extra_env, + true, + ) + .await +} + +async fn run_codex_cli_with_filter_and_reasoning( + codex_cli: &Path, + codex_home: &Path, + cwd: &Path, + startup_model_hint: &str, + filter: &str, + reasoning_filter: Option<&str>, + prompt_after_switch: Option<&str>, + extra_env: HashMap, +) -> Result { + run_codex_cli_with_filter_options( + codex_cli, + codex_home, + cwd, + startup_model_hint, + filter, + reasoning_filter, prompt_after_switch, extra_env, true, @@ -1213,6 +1325,7 @@ async fn run_codex_cli_with_filter_options( cwd: &Path, startup_model_hint: &str, filter: &str, + reasoning_filter: Option<&str>, prompt_after_switch: Option<&str>, extra_env: HashMap, send_escape_before_prompt: bool, @@ -1254,6 +1367,7 @@ async fn run_codex_cli_with_filter_options( let (reasoning_prompt_tx, mut reasoning_prompt_rx) = watch::channel(false); const REASONING_CONFIRM_HINT: &str = "Press enter to confirm or esc to go back"; let filter = filter.to_string(); + let reasoning_filter = reasoning_filter.map(std::borrow::ToOwned::to_owned); let prompt_after_switch = prompt_after_switch.map(std::borrow::ToOwned::to_owned); let input_task = tokio::spawn(async move { // Wait for startup to finish before dispatching `/model`. @@ -1293,6 +1407,29 @@ async fn run_codex_cli_with_filter_options( .unwrap_or(false); if reasoning_prompt_visible { sleep(Duration::from_millis(200)).await; + if let Some(reasoning_filter) = reasoning_filter.as_ref() { + match reasoning_filter.as_str() { + "medium" => {} + "high" => { + let _ = writer_for_input.send(b"\x1b[B".to_vec()).await; + sleep(Duration::from_millis(120)).await; + } + "xhigh" => { + let _ = writer_for_input.send(b"\x1b[B".to_vec()).await; + sleep(Duration::from_millis(120)).await; + let _ = writer_for_input.send(b"\x1b[B".to_vec()).await; + sleep(Duration::from_millis(120)).await; + } + "low" => { + let _ = writer_for_input.send(b"\x1b[A".to_vec()).await; + sleep(Duration::from_millis(120)).await; + } + _ => { + type_text_with_stabilization(&writer_for_input, reasoning_filter).await; + sleep(Duration::from_millis(120)).await; + } + } + } let _ = writer_for_input.send(vec![b'\r']).await; } if let Some(prompt) = prompt_after_switch {