From 674e3d3c90d78508602c720c0f2d304ec5715a26 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 11 Sep 2025 11:08:51 -0700 Subject: [PATCH 01/18] Add Compact and Turn Context to the rollout items (#3444) Adding compact and turn context to the rollout items based on #3440 --- codex-rs/core/src/codex.rs | 28 ++++++++++- codex-rs/core/src/conversation_history.rs | 21 +++++++++ codex-rs/core/src/rollout/list.rs | 6 +++ codex-rs/core/src/rollout/policy.rs | 6 ++- codex-rs/core/src/rollout/recorder.rs | 6 +++ codex-rs/core/tests/suite/compact.rs | 57 +++++++++++++++++++++-- codex-rs/protocol/src/protocol.rs | 17 +++++++ 7 files changed, 132 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 46d155be74..adfaca19c5 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -19,11 +19,13 @@ use codex_apply_patch::ApplyPatchAction; use codex_apply_patch::MaybeApplyPatchVerified; use codex_apply_patch::maybe_parse_apply_patch_verified; use codex_protocol::mcp_protocol::ConversationId; +use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::ConversationPathResponseEvent; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::TaskStartedEvent; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnContextItem; use futures::prelude::*; use mcp_types::CallToolResult; use serde::Deserialize; @@ -1786,6 +1788,15 @@ async fn try_run_turn( }) }; + let rollout_item = RolloutItem::TurnContext(TurnContextItem { + cwd: turn_context.cwd.clone(), + approval_policy: turn_context.approval_policy, + sandbox_policy: turn_context.sandbox_policy.clone(), + model: turn_context.client.get_model().clone(), + effort: turn_context.client.get_reasoning_effort(), + summary: turn_context.client.get_reasoning_summary(), + }); + sess.persist_rollout_items(&[rollout_item]).await; let mut stream = turn_context.client.clone().stream(&prompt).await?; let mut output = Vec::new(); @@ -1968,10 +1979,14 @@ async fn run_compact_task( sess.remove_task(&sub_id); - { + let rollout_item = { let mut state = sess.state.lock_unchecked(); state.history.keep_last_messages(1); - } + RolloutItem::Compacted(CompactedItem { + message: state.history.last_agent_message(), + }) + }; + sess.persist_rollout_items(&[rollout_item]).await; let event = Event { id: sub_id.clone(), @@ -2997,6 +3012,15 @@ async fn drain_to_completed( sub_id: &str, prompt: &Prompt, ) -> CodexResult<()> { + let rollout_item = RolloutItem::TurnContext(TurnContextItem { + cwd: turn_context.cwd.clone(), + approval_policy: turn_context.approval_policy, + sandbox_policy: turn_context.sandbox_policy.clone(), + model: turn_context.client.get_model(), + effort: turn_context.client.get_reasoning_effort(), + summary: turn_context.client.get_reasoning_summary(), + }); + sess.persist_rollout_items(&[rollout_item]).await; let mut stream = turn_context.client.clone().stream(prompt).await?; loop { let maybe_event = stream.next().await; diff --git a/codex-rs/core/src/conversation_history.rs b/codex-rs/core/src/conversation_history.rs index ed15bd4155..3cdbbb33c7 100644 --- a/codex-rs/core/src/conversation_history.rs +++ b/codex-rs/core/src/conversation_history.rs @@ -1,3 +1,4 @@ +use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; /// Transcript of conversation history @@ -59,6 +60,26 @@ impl ConversationHistory { kept.reverse(); self.items = kept; } + + pub(crate) fn last_agent_message(&self) -> String { + for item in self.items.iter().rev() { + if let ResponseItem::Message { role, content, .. } = item + && role == "assistant" + { + return content + .iter() + .find_map(|ci| { + if let ContentItem::OutputText { text } = ci { + Some(text.clone()) + } else { + None + } + }) + .unwrap_or_default(); + } + } + String::new() + } } /// Anything that is not a system message or "reasoning" message is considered diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index 417638e781..3d6964512d 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -318,6 +318,12 @@ async fn read_head_and_flags( head.push(val); } } + RolloutItem::TurnContext(_) => { + // Not included in `head`; skip. + } + RolloutItem::Compacted(_) => { + // Not included in `head`; skip. + } RolloutItem::EventMsg(ev) => { if matches!(ev, EventMsg::UserMessage(_)) { saw_user_event = true; diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index c0d6d3af3a..c1a5cb238d 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -8,8 +8,10 @@ pub(crate) fn is_persisted_response_item(item: &RolloutItem) -> bool { match item { RolloutItem::ResponseItem(item) => should_persist_response_item(item), RolloutItem::EventMsg(ev) => should_persist_event_msg(ev), - // Always persist session meta - RolloutItem::SessionMeta(_) => true, + // Persist Codex executive markers so we can analyze flows (e.g., compaction, API turns). + RolloutItem::Compacted(_) | RolloutItem::TurnContext(_) | RolloutItem::SessionMeta(_) => { + true + } } } diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 63495bd8d0..580a41ed37 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -238,6 +238,12 @@ impl RolloutRecorder { RolloutItem::ResponseItem(item) => { items.push(RolloutItem::ResponseItem(item)); } + RolloutItem::Compacted(item) => { + items.push(RolloutItem::Compacted(item)); + } + RolloutItem::TurnContext(item) => { + items.push(RolloutItem::TurnContext(item)); + } RolloutItem::EventMsg(_ev) => { items.push(RolloutItem::EventMsg(_ev)); } diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index f5e854d275..524f819b3e 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -3,10 +3,13 @@ use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; +use codex_core::NewConversation; use codex_core::built_in_model_providers; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; +use codex_core::protocol::RolloutItem; +use codex_core::protocol::RolloutLine; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use core_test_support::load_default_config_for_test; use core_test_support::wait_for_event; @@ -142,11 +145,12 @@ async fn summarize_context_three_requests_and_instructions() { let mut config = load_default_config_for_test(&home); config.model_provider = model_provider; let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); - let codex = conversation_manager - .new_conversation(config) - .await - .unwrap() - .conversation; + let NewConversation { + conversation: codex, + session_configured, + .. + } = conversation_manager.new_conversation(config).await.unwrap(); + let rollout_path = session_configured.rollout_path; // 1) Normal user input – should hit server once. codex @@ -248,4 +252,47 @@ async fn summarize_context_three_requests_and_instructions() { !messages.iter().any(|(_, t)| t.contains(SUMMARIZE_TRIGGER)), "third request should not include the summarize trigger" ); + + // Shut down Codex to flush rollout entries before inspecting the file. + codex.submit(Op::Shutdown).await.unwrap(); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; + + // Verify rollout contains APITurn entries for each API call and a Compacted entry. + let text = std::fs::read_to_string(&rollout_path).unwrap_or_else(|e| { + panic!( + "failed to read rollout file {}: {e}", + rollout_path.display() + ) + }); + let mut api_turn_count = 0usize; + let mut saw_compacted_summary = false; + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Ok(entry): Result = serde_json::from_str(trimmed) else { + continue; + }; + match entry.item { + RolloutItem::TurnContext(_) => { + api_turn_count += 1; + } + RolloutItem::Compacted(ci) => { + if ci.message == SUMMARY_TEXT { + saw_compacted_summary = true; + } + } + _ => {} + } + } + + assert!( + api_turn_count == 3, + "expected three APITurn entries in rollout" + ); + assert!( + saw_compacted_summary, + "expected a Compacted entry containing the summarizer output" + ); } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index be31228560..2eb3d1e808 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -897,9 +897,26 @@ pub struct SessionMetaLine { pub enum RolloutItem { SessionMeta(SessionMetaLine), ResponseItem(ResponseItem), + Compacted(CompactedItem), + TurnContext(TurnContextItem), EventMsg(EventMsg), } +#[derive(Serialize, Deserialize, Clone, Debug, TS)] +pub struct CompactedItem { + pub message: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug, TS)] +pub struct TurnContextItem { + pub cwd: PathBuf, + pub approval_policy: AskForApproval, + pub sandbox_policy: SandboxPolicy, + pub model: String, + pub effort: ReasoningEffortConfig, + pub summary: ReasoningSummaryConfig, +} + #[derive(Serialize, Deserialize, Clone)] pub struct RolloutLine { pub timestamp: String, From 167154178b186217303a75512e4f318195a7291c Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 11 Sep 2025 11:32:47 -0700 Subject: [PATCH 02/18] fix: use -F instead of -f for force=true in gh call (#3486) Apparently `-F` is the correct thing to use. From the code sample on https://docs.github.com/en/rest/git/refs?apiVersion=2022-11-28#update-a-reference ```shell gh api \ --method PATCH \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ /repos/OWNER/REPO/git/refs/REF \ -f 'sha=aa218f56b14c9653891f9e74264a383fa43fefbd' -F "force=true" ``` Also, I ran the following locally and verified it worked: ```shell export GITHUB_REPOSITORY=openai/codex export GITHUB_SHA=305252b2fb2d57bb40a9e4bad269db9a761f7099 gh api \ repos/${GITHUB_REPOSITORY}/git/refs/heads/latest-alpha-cli \ -X PATCH \ -f sha="${GITHUB_SHA}" \ -F force=true ``` `$GITHUB_REPOSITORY` and `$GITHUB_SHA` should already be available as environment variables for the `run` step without having to be redeclared in the `env` section. --- .github/workflows/rust-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index f0661d273d..07af62a17c 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -237,4 +237,4 @@ jobs: repos/${GITHUB_REPOSITORY}/git/refs/heads/latest-alpha-cli \ -X PATCH \ -f sha="${GITHUB_SHA}" \ - -f force=true + -F force=true From 167b4f0e254638165191a6162f264cefb4bced79 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 11 Sep 2025 11:45:17 -0700 Subject: [PATCH 03/18] Clear composer on fork (#3445) Fixes this image --- codex-rs/tui/src/app_backtrack.rs | 2 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 4 ++++ codex-rs/tui/src/chatwidget.rs | 5 +++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 4445c9391a..d1765c1976 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -335,7 +335,7 @@ impl App { self.trim_transcript_for_backtrack(drop_count); self.render_transcript_once(tui); if !prefill.is_empty() { - self.chat_widget.insert_str(prefill); + self.chat_widget.set_composer_text(prefill.to_string()); } tui.frame_requester().schedule_frame(); } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index c94144bf1e..14094badea 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -243,6 +243,10 @@ impl ChatComposer { /// Replace the entire composer content with `text` and reset cursor. pub(crate) fn set_text_content(&mut self, text: String) { + // Clear any existing content, placeholders, and attachments first. + self.textarea.set_text(""); + self.pending_pastes.clear(); + self.attached_images.clear(); self.textarea.set_text(&text); self.textarea.set_cursor(0); self.sync_command_popup(); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1ccfddea80..7f9ea76538 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1316,6 +1316,11 @@ impl ChatWidget { self.bottom_pane.insert_str(text); } + /// Replace the composer content with the provided text and reset cursor. + pub(crate) fn set_composer_text(&mut self, text: String) { + self.bottom_pane.set_composer_text(text); + } + pub(crate) fn show_esc_backtrack_hint(&mut self) { self.bottom_pane.show_esc_backtrack_hint(); } From 66967500bb7a4aae0cbf9e4d582b1fe34c788e55 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 11 Sep 2025 11:56:49 -0700 Subject: [PATCH 04/18] Assign the entire gpt-5 model family same characteristics (#3490) So the context size indicator is displayed. --- codex-rs/core/src/openai_model_info.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/openai_model_info.rs b/codex-rs/core/src/openai_model_info.rs index 9c2f24214e..2f81619c66 100644 --- a/codex-rs/core/src/openai_model_info.rs +++ b/codex-rs/core/src/openai_model_info.rs @@ -78,7 +78,7 @@ pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option { max_output_tokens: 4_096, }), - "gpt-5" => Some(ModelInfo { + _ if slug.starts_with("gpt-5") => Some(ModelInfo { context_window: 272_000, max_output_tokens: 128_000, }), From bec51f6c05caa284fab96ac3ea8aab5961f96b7c Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 11 Sep 2025 11:59:37 -0700 Subject: [PATCH 05/18] chore: enable clippy::redundant_clone (#3489) Created this PR by: - adding `redundant_clone` to `[workspace.lints.clippy]` in `cargo-rs/Cargol.toml` - running `cargo clippy --tests --fix` - running `just fmt` Though I had to clean up one instance of the following that resulted: ```rust let codex = codex; ``` --- codex-rs/Cargo.toml | 1 + codex-rs/apply-patch/src/parser.rs | 4 ++-- codex-rs/core/src/auth.rs | 9 ++++----- codex-rs/core/src/codex.rs | 17 ++++++----------- codex-rs/core/src/environment_context.rs | 2 +- codex-rs/core/src/event_mapping.rs | 2 +- codex-rs/core/src/git_info.rs | 7 ++----- codex-rs/core/src/parse_command.rs | 18 ++++-------------- codex-rs/core/src/project_doc.rs | 2 +- codex-rs/core/src/rollout/tests.rs | 6 +++--- codex-rs/core/src/safety.rs | 2 +- codex-rs/core/src/seatbelt.rs | 2 +- codex-rs/core/src/shell.rs | 10 ++-------- codex-rs/core/src/turn_diff_tracker.rs | 2 +- .../src/event_processor_with_human_output.rs | 4 ++-- codex-rs/exec/tests/suite/common.rs | 2 +- codex-rs/execpolicy/src/execv_checker.rs | 12 ++++++------ codex-rs/execpolicy/src/policy_parser.rs | 2 +- codex-rs/login/src/server.rs | 4 ++-- codex-rs/mcp-server/src/codex_tool_runner.rs | 2 +- codex-rs/mcp-server/src/message_processor.rs | 1 - codex-rs/mcp-server/src/outgoing_message.rs | 2 +- codex-rs/tui/src/app.rs | 2 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 10 +++++----- codex-rs/tui/src/bottom_pane/mod.rs | 2 +- codex-rs/tui/src/bottom_pane/textarea.rs | 4 +--- codex-rs/tui/src/chatwidget.rs | 12 ++++++------ codex-rs/tui/src/chatwidget/agent.rs | 4 ++-- codex-rs/tui/src/chatwidget/tests.rs | 2 +- codex-rs/tui/src/diff_render.rs | 4 ++-- codex-rs/tui/src/history_cell.rs | 4 ++-- codex-rs/tui/src/onboarding/auth.rs | 2 +- .../tui/src/onboarding/onboarding_screen.rs | 2 +- codex-rs/tui/src/user_approval_widget.rs | 8 ++++---- 34 files changed, 71 insertions(+), 98 deletions(-) diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 4155992293..1502e62f2f 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -34,6 +34,7 @@ rust = {} [workspace.lints.clippy] expect_used = "deny" +redundant_clone = "deny" uninlined_format_args = "deny" unwrap_used = "deny" diff --git a/codex-rs/apply-patch/src/parser.rs b/codex-rs/apply-patch/src/parser.rs index 3b89c4da85..768c89ad78 100644 --- a/codex-rs/apply-patch/src/parser.rs +++ b/codex-rs/apply-patch/src/parser.rs @@ -617,7 +617,7 @@ fn test_parse_patch_lenient() { assert_eq!( parse_patch_text(&patch_text_in_double_quoted_heredoc, ParseMode::Lenient), Ok(ApplyPatchArgs { - hunks: expected_patch.clone(), + hunks: expected_patch, patch: patch_text.to_string(), workdir: None, }) @@ -637,7 +637,7 @@ fn test_parse_patch_lenient() { "< Option { - self.get_current_token_data() - .and_then(|t| t.account_id.clone()) + self.get_current_token_data().and_then(|t| t.account_id) } pub fn get_plan_type(&self) -> Option { @@ -146,7 +145,7 @@ impl CodexAuth { } fn get_current_token_data(&self) -> Option { - self.get_current_auth_json().and_then(|t| t.tokens.clone()) + self.get_current_auth_json().and_then(|t| t.tokens) } /// Consider this private to integration tests. @@ -291,10 +290,10 @@ async fn update_tokens( let tokens = auth_dot_json.tokens.get_or_insert_with(TokenData::default); tokens.id_token = parse_id_token(&id_token).map_err(std::io::Error::other)?; if let Some(access_token) = access_token { - tokens.access_token = access_token.to_string(); + tokens.access_token = access_token; } if let Some(refresh_token) = refresh_token { - tokens.refresh_token = refresh_token.to_string(); + tokens.refresh_token = refresh_token; } auth_dot_json.last_refresh = Some(Utc::now()); write_auth_json(auth_file, &auth_dot_json)?; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index adfaca19c5..b48093d8f4 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -214,12 +214,7 @@ impl Codex { let conversation_id = session.conversation_id; // This task will run until Op::Shutdown is received. - tokio::spawn(submission_loop( - session.clone(), - turn_context, - config, - rx_sub, - )); + tokio::spawn(submission_loop(session, turn_context, config, rx_sub)); let codex = Codex { next_id: AtomicU64::new(0), tx_sub, @@ -1074,7 +1069,7 @@ impl AgentTask { id: self.sub_id, msg: EventMsg::TurnAborted(TurnAbortedEvent { reason }), }; - let sess = self.sess.clone(); + let sess = self.sess; tokio::spawn(async move { sess.send_event(event).await; }); @@ -1772,7 +1767,7 @@ async fn try_run_turn( } }) .map(|call_id| ResponseItem::CustomToolCallOutput { - call_id: call_id.clone(), + call_id, output: "aborted".to_string(), }) .collect::>() @@ -1792,7 +1787,7 @@ async fn try_run_turn( cwd: turn_context.cwd.clone(), approval_policy: turn_context.approval_policy, sandbox_policy: turn_context.sandbox_policy.clone(), - model: turn_context.client.get_model().clone(), + model: turn_context.client.get_model(), effort: turn_context.client.get_reasoning_effort(), summary: turn_context.client.get_reasoning_summary(), }); @@ -3154,7 +3149,7 @@ mod tests { exit_code: 0, stdout: StreamOutput::new(String::new()), stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new(full.clone()), + aggregated_output: StreamOutput::new(full), duration: StdDuration::from_secs(1), }; @@ -3188,7 +3183,7 @@ mod tests { fn model_truncation_respects_byte_budget() { // Construct a large output (about 100kB) so byte budget dominates let big_line = "x".repeat(100); - let full = std::iter::repeat_n(big_line.clone(), 1000) + let full = std::iter::repeat_n(big_line, 1000) .collect::>() .join("\n"); diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index 1d755841e8..89af9e1c91 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -63,7 +63,7 @@ impl EnvironmentContext { if writable_roots.is_empty() { None } else { - Some(writable_roots.clone()) + Some(writable_roots) } } _ => None, diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 2628de4281..3ae7f78516 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -159,7 +159,7 @@ mod tests { EventMsg::UserMessage(user) => { assert_eq!(user.message, "Hello world"); assert!(matches!(user.kind, Some(InputMessageKind::Plain))); - assert_eq!(user.images, Some(vec![img1.clone(), img2.clone()])); + assert_eq!(user.images, Some(vec![img1, img2])); } other => panic!("expected UserMessage, got {other:?}"), } diff --git a/codex-rs/core/src/git_info.rs b/codex-rs/core/src/git_info.rs index 619f4f52de..ca62ad499e 100644 --- a/codex-rs/core/src/git_info.rs +++ b/codex-rs/core/src/git_info.rs @@ -802,7 +802,7 @@ mod tests { async fn resolve_root_git_project_for_trust_regular_repo_returns_repo_root() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); let repo_path = create_test_git_repo(&temp_dir).await; - let expected = std::fs::canonicalize(&repo_path).unwrap().to_path_buf(); + let expected = std::fs::canonicalize(&repo_path).unwrap(); assert_eq!( resolve_root_git_project_for_trust(&repo_path), @@ -810,10 +810,7 @@ mod tests { ); let nested = repo_path.join("sub/dir"); std::fs::create_dir_all(&nested).unwrap(); - assert_eq!( - resolve_root_git_project_for_trust(&nested), - Some(expected.clone()) - ); + assert_eq!(resolve_root_git_project_for_trust(&nested), Some(expected)); } #[tokio::test] diff --git a/codex-rs/core/src/parse_command.rs b/codex-rs/core/src/parse_command.rs index 15dd2ff411..ea886d61f0 100644 --- a/codex-rs/core/src/parse_command.rs +++ b/codex-rs/core/src/parse_command.rs @@ -868,7 +868,7 @@ pub fn parse_command_impl(command: &[String]) -> Vec { let parts = if contains_connectors(&normalized) { split_on_connectors(&normalized) } else { - vec![normalized.clone()] + vec![normalized] }; // Preserve left-to-right execution order for all commands, including bash -c/-lc @@ -1201,10 +1201,7 @@ fn parse_bash_lc_commands(original: &[String]) -> Option> { name, } } else { - ParsedCommand::Read { - cmd: cmd.clone(), - name, - } + ParsedCommand::Read { cmd, name } } } else { ParsedCommand::Read { @@ -1215,10 +1212,7 @@ fn parse_bash_lc_commands(original: &[String]) -> Option> { } ParsedCommand::ListFiles { path, cmd, .. } => { if had_connectors { - ParsedCommand::ListFiles { - cmd: cmd.clone(), - path, - } + ParsedCommand::ListFiles { cmd, path } } else { ParsedCommand::ListFiles { cmd: shlex_join(&script_tokens), @@ -1230,11 +1224,7 @@ fn parse_bash_lc_commands(original: &[String]) -> Option> { query, path, cmd, .. } => { if had_connectors { - ParsedCommand::Search { - cmd: cmd.clone(), - query, - path, - } + ParsedCommand::Search { cmd, query, path } } else { ParsedCommand::Search { cmd: shlex_join(&script_tokens), diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index a869b40cb3..549248fc11 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -115,7 +115,7 @@ pub fn discover_project_doc_paths(config: &Config) -> std::io::Result = vec![dir.clone()]; let mut git_root: Option = None; - let mut cursor = dir.clone(); + let mut cursor = dir; while let Some(parent) = cursor.parent() { let git_marker = cursor.join(".git"); let git_exists = match std::fs::metadata(&git_marker) { diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index ecff658d29..86f47645f9 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -305,7 +305,7 @@ async fn test_pagination_cursor() { path: p1, head: head_1, }], - next_cursor: Some(expected_cursor3.clone()), + next_cursor: Some(expected_cursor3), num_scanned_files: 5, // scanned 05, 04 (anchor), 03, 02 (anchor), 01 reached_scan_cap: false, }; @@ -344,7 +344,7 @@ async fn test_get_conversation_contents() { let expected_cursor: Cursor = serde_json::from_str(&format!("\"{ts}|{uuid}\"")).unwrap(); let expected_page = ConversationsPage { items: vec![ConversationItem { - path: expected_path.clone(), + path: expected_path, head: expected_head, }], next_cursor: Some(expected_cursor), @@ -437,7 +437,7 @@ async fn test_stable_ordering_same_second_pagination() { path: p1, head: head(u1), }], - next_cursor: Some(expected_cursor2.clone()), + next_cursor: Some(expected_cursor2), num_scanned_files: 3, // scanned u3, u2 (anchor), u1 reached_scan_cap: false, }; diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs index 790267f55a..42b1ffdff9 100644 --- a/codex-rs/core/src/safety.rs +++ b/codex-rs/core/src/safety.rs @@ -293,7 +293,7 @@ mod tests { // With the parent dir explicitly added as a writable root, the // outside write should be permitted. let policy_with_parent = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![parent.clone()], + writable_roots: vec![parent], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index 31aa770d2e..285e5d5691 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -153,7 +153,7 @@ mod tests { // Build a policy that only includes the two test roots as writable and // does not automatically include defaults TMPDIR or /tmp. let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![root_with_git.clone(), root_without_git.clone()], + writable_roots: vec![root_with_git, root_without_git], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index 7ef79076e0..1734d5de65 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -326,10 +326,7 @@ mod tests { .format_default_shell_invocation(input.iter().map(|s| s.to_string()).collect()); let expected_cmd = expected_cmd .iter() - .map(|s| { - s.replace("BASHRC_PATH", bashrc_path.to_str().unwrap()) - .to_string() - }) + .map(|s| s.replace("BASHRC_PATH", bashrc_path.to_str().unwrap())) .collect(); assert_eq!(actual_cmd, Some(expected_cmd)); @@ -435,10 +432,7 @@ mod macos_tests { .format_default_shell_invocation(input.iter().map(|s| s.to_string()).collect()); let expected_cmd = expected_cmd .iter() - .map(|s| { - s.replace("ZSHRC_PATH", zshrc_path.to_str().unwrap()) - .to_string() - }) + .map(|s| s.replace("ZSHRC_PATH", zshrc_path.to_str().unwrap())) .collect(); assert_eq!(actual_cmd, Some(expected_cmd)); diff --git a/codex-rs/core/src/turn_diff_tracker.rs b/codex-rs/core/src/turn_diff_tracker.rs index 2157ed1784..6c12d6cd46 100644 --- a/codex-rs/core/src/turn_diff_tracker.rs +++ b/codex-rs/core/src/turn_diff_tracker.rs @@ -678,7 +678,7 @@ index {left_oid}..{right_oid} let dest = dir.path().join("dest.txt"); let mut acc = TurnDiffTracker::new(); let mv = HashMap::from([( - src.clone(), + src, FileChange::Update { unified_diff: "".into(), move_path: Some(dest.clone()), diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 3c879397bd..c80117706b 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -280,7 +280,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { parsed_cmd: _, }) => { self.call_id_to_command.insert( - call_id.clone(), + call_id, ExecCommandBegin { command: command.clone(), }, @@ -382,7 +382,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { // Store metadata so we can calculate duration later when we // receive the corresponding PatchApplyEnd event. self.call_id_to_patch.insert( - call_id.clone(), + call_id, PatchApplyBegin { start_time: Instant::now(), auto_approved, diff --git a/codex-rs/exec/tests/suite/common.rs b/codex-rs/exec/tests/suite/common.rs index 8c57e7afcb..4a3719aaba 100644 --- a/codex-rs/exec/tests/suite/common.rs +++ b/codex-rs/exec/tests/suite/common.rs @@ -61,7 +61,7 @@ pub(crate) async fn run_e2e_exec_test(cwd: &Path, response_streams: Vec) .context("should find binary for codex-exec") .expect("should find binary for codex-exec") .current_dir(cwd.clone()) - .env("CODEX_HOME", cwd.clone()) + .env("CODEX_HOME", cwd) .env("OPENAI_API_KEY", "dummy") .env("OPENAI_BASE_URL", format!("{uri}/v1")) .arg("--skip-git-repo-check") diff --git a/codex-rs/execpolicy/src/execv_checker.rs b/codex-rs/execpolicy/src/execv_checker.rs index fcd80b2b5f..ae61d54cf8 100644 --- a/codex-rs/execpolicy/src/execv_checker.rs +++ b/codex-rs/execpolicy/src/execv_checker.rs @@ -88,7 +88,7 @@ impl ExecvChecker { let mut program = valid_exec.program.to_string(); for system_path in valid_exec.system_path { if is_executable_file(&system_path) { - program = system_path.to_string(); + program = system_path; break; } } @@ -196,7 +196,7 @@ system_path=[{fake_cp:?}] let checker = setup(&fake_cp); let exec_call = ExecCall { program: "cp".into(), - args: vec![source.clone(), dest.clone()], + args: vec![source, dest.clone()], }; let valid_exec = match checker.r#match(&exec_call)? { MatchedExec::Match { exec } => exec, @@ -207,7 +207,7 @@ system_path=[{fake_cp:?}] assert_eq!( checker.check(valid_exec.clone(), &cwd, &[], &[]), Err(ReadablePathNotInReadableFolders { - file: source_path.clone(), + file: source_path, folders: vec![] }), ); @@ -229,7 +229,7 @@ system_path=[{fake_cp:?}] // Both readable and writeable folders specified. assert_eq!( checker.check( - valid_exec.clone(), + valid_exec, &cwd, std::slice::from_ref(&root_path), std::slice::from_ref(&root_path) @@ -241,7 +241,7 @@ system_path=[{fake_cp:?}] // folders. let exec_call_folders_as_args = ExecCall { program: "cp".into(), - args: vec![root.clone(), root.clone()], + args: vec![root.clone(), root], }; let valid_exec_call_folders_as_args = match checker.r#match(&exec_call_folders_as_args)? { MatchedExec::Match { exec } => exec, @@ -254,7 +254,7 @@ system_path=[{fake_cp:?}] std::slice::from_ref(&root_path), std::slice::from_ref(&root_path) ), - Ok(cp.clone()), + Ok(cp), ); // Specify a parent of a readable folder as input. diff --git a/codex-rs/execpolicy/src/policy_parser.rs b/codex-rs/execpolicy/src/policy_parser.rs index 254c12d7aa..b065c109a8 100644 --- a/codex-rs/execpolicy/src/policy_parser.rs +++ b/codex-rs/execpolicy/src/policy_parser.rs @@ -104,7 +104,7 @@ impl PolicyBuilder { info!("adding program spec: {program_spec:?}"); let name = program_spec.program.clone(); let mut programs = self.programs.borrow_mut(); - programs.insert(name.clone(), program_spec); + programs.insert(name, program_spec); } fn add_forbidden_substrings(&self, substrings: &[String]) { diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index e267652dda..2cf1189f4c 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -42,7 +42,7 @@ impl ServerOptions { pub fn new(codex_home: PathBuf, client_id: String) -> Self { Self { codex_home, - client_id: client_id.to_string(), + client_id, issuer: DEFAULT_ISSUER.to_string(), port: DEFAULT_PORT, open_browser: true, @@ -126,7 +126,7 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result { let shutdown_notify = Arc::new(tokio::sync::Notify::new()); let server_handle = { let shutdown_notify = shutdown_notify.clone(); - let server = server.clone(); + let server = server; tokio::spawn(async move { let result = loop { tokio::select! { diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index d3351faa88..226408f805 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -222,7 +222,7 @@ async fn run_codex_tool_session_inner( } EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => { let text = match last_agent_message { - Some(msg) => msg.clone(), + Some(msg) => msg, None => "".to_string(), }; let result = CallToolResult { diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index a108b7555e..c696d4538b 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -531,7 +531,6 @@ impl MessageProcessor { // Spawn the long-running reply handler. tokio::spawn({ - let codex = codex.clone(); let outgoing = outgoing.clone(); let prompt = prompt.clone(); let running_requests_id_to_codex_uuid = running_requests_id_to_codex_uuid.clone(); diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 5ce2e99423..939d986aec 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -299,7 +299,7 @@ mod tests { let Ok(expected_params) = serde_json::to_value(&event) else { panic!("Event must serialize"); }; - assert_eq!(params, Some(expected_params.clone())); + assert_eq!(params, Some(expected_params)); } #[tokio::test] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 12cd789c5f..b2f91682a6 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -304,7 +304,7 @@ impl App { } pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage { - self.chat_widget.token_usage().clone() + self.chat_widget.token_usage() } async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 14094badea..410701a629 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -487,7 +487,7 @@ impl ChatComposer { } => { // Hide popup without modifying text, remember token to avoid immediate reopen. if let Some(tok) = Self::current_at_token(&self.textarea) { - self.dismissed_file_popup_token = Some(tok.to_string()); + self.dismissed_file_popup_token = Some(tok); } self.active_popup = ActivePopup::None; (InputResult::None, true) @@ -546,7 +546,7 @@ impl ChatComposer { Some(ext) if ext == "jpg" || ext == "jpeg" => "JPEG", _ => "IMG", }; - self.attach_image(path_buf.clone(), w, h, format_label); + self.attach_image(path_buf, w, h, format_label); // Add a trailing space to keep typing fluid. self.textarea.insert_str(" "); } else { @@ -2123,7 +2123,7 @@ mod tests { // Re-add and test backspace in middle: should break the placeholder string // and drop the image mapping (same as text placeholder behavior). - composer.attach_image(path.clone(), 20, 10, "PNG"); + composer.attach_image(path, 20, 10, "PNG"); let placeholder2 = composer.attached_images[0].placeholder.clone(); // Move cursor to roughly middle of placeholder if let Some(start_pos) = composer.textarea.text().find(&placeholder2) { @@ -2182,7 +2182,7 @@ mod tests { let path1 = PathBuf::from("/tmp/image_dup1.png"); let path2 = PathBuf::from("/tmp/image_dup2.png"); - composer.attach_image(path1.clone(), 10, 5, "PNG"); + composer.attach_image(path1, 10, 5, "PNG"); // separate placeholders with a space for clarity composer.handle_paste(" ".into()); composer.attach_image(path2.clone(), 10, 5, "PNG"); @@ -2231,7 +2231,7 @@ mod tests { assert!(composer.textarea.text().starts_with("[image 3x2 PNG] ")); let imgs = composer.take_recent_submission_images(); - assert_eq!(imgs, vec![tmp_path.clone()]); + assert_eq!(imgs, vec![tmp_path]); } #[test] diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 8d84ccc121..f30bd418e9 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -564,7 +564,7 @@ mod tests { let (tx_raw, rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx.clone(), + app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 07b6077587..9166b8c7f6 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -649,9 +649,7 @@ impl TextArea { } fn add_element(&mut self, range: Range) { - let elem = TextElement { - range: range.clone(), - }; + let elem = TextElement { range }; self.elements.push(elem); self.elements.sort_by_key(|e| e.range.start); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 7f9ea76538..2756db2231 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -574,14 +574,14 @@ impl ChatWidget { self.active_exec_cell = Some(history_cell::new_active_exec_command( ev.call_id.clone(), ev.command.clone(), - ev.parsed_cmd.clone(), + ev.parsed_cmd, )); } } else { self.active_exec_cell = Some(history_cell::new_active_exec_command( ev.call_id.clone(), ev.command.clone(), - ev.parsed_cmd.clone(), + ev.parsed_cmd, )); } @@ -804,7 +804,7 @@ impl ChatWidget { "attach_image path={path:?} width={width} height={height} format={format_label}", ); self.bottom_pane - .attach_image(path.clone(), width, height, format_label); + .attach_image(path, width, height, format_label); self.request_redraw(); } @@ -986,7 +986,7 @@ impl ChatWidget { // Only show the text portion in conversation history. if !text.is_empty() { - self.add_to_history(history_cell::new_user_prompt(text.clone())); + self.add_to_history(history_cell::new_user_prompt(text)); } } @@ -1055,10 +1055,10 @@ impl ChatWidget { EventMsg::PlanUpdate(update) => self.on_plan_update(update), EventMsg::ExecApprovalRequest(ev) => { // For replayed events, synthesize an empty id (these should not occur). - self.on_exec_approval_request(id.clone().unwrap_or_default(), ev) + self.on_exec_approval_request(id.unwrap_or_default(), ev) } EventMsg::ApplyPatchApprovalRequest(ev) => { - self.on_apply_patch_approval_request(id.clone().unwrap_or_default(), ev) + self.on_apply_patch_approval_request(id.unwrap_or_default(), ev) } EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev), EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta), diff --git a/codex-rs/tui/src/chatwidget/agent.rs b/codex-rs/tui/src/chatwidget/agent.rs index 3d2de5c084..bf15b6c4ad 100644 --- a/codex-rs/tui/src/chatwidget/agent.rs +++ b/codex-rs/tui/src/chatwidget/agent.rs @@ -20,7 +20,7 @@ pub(crate) fn spawn_agent( ) -> UnboundedSender { let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); - let app_event_tx_clone = app_event_tx.clone(); + let app_event_tx_clone = app_event_tx; tokio::spawn(async move { let NewConversation { conversation_id: _, @@ -71,7 +71,7 @@ pub(crate) fn spawn_agent_from_existing( ) -> UnboundedSender { let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); - let app_event_tx_clone = app_event_tx.clone(); + let app_event_tx_clone = app_event_tx; tokio::spawn(async move { // Forward the captured `SessionConfigured` event so it can be rendered in the UI. let ev = codex_core::protocol::Event { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index aad6e83913..c148b8670b 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -352,7 +352,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { let long = format!("echo {}", "a".repeat(200)); let ev_long = ExecApprovalRequestEvent { call_id: "call-long".into(), - command: vec!["bash".into(), "-lc".into(), long.clone()], + command: vec!["bash".into(), "-lc".into(), long], cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, }; diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 06afd85ca6..20d26106b2 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -737,10 +737,10 @@ mod tests { let mut changes: HashMap = HashMap::new(); changes.insert( - abs_old.clone(), + abs_old, FileChange::Update { unified_diff: patch, - move_path: Some(abs_new.clone()), + move_path: Some(abs_new), }, ); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index a7be1d6560..b3b45774c1 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -697,7 +697,7 @@ fn spinner(start_time: Option) -> Span<'static> { pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> PlainHistoryCell { let title_line = Line::from(vec!["tool".magenta(), " running...".dim()]); - let lines: Vec = vec![title_line, format_mcp_invocation(invocation.clone())]; + let lines: Vec = vec![title_line, format_mcp_invocation(invocation)]; PlainHistoryCell { lines } } @@ -1324,7 +1324,7 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> { let invocation_spans = vec![ invocation.server.clone().cyan(), ".".into(), - invocation.tool.clone().cyan(), + invocation.tool.cyan(), "(".into(), args_str.dim(), ")".into(), diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 97a6b7c21c..d419badada 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -432,7 +432,7 @@ impl AuthModeWidget { match &mut *guard { SignInState::ApiKeyEntry(state) => { if state.value.is_empty() { - if let Some(prefill) = prefill_from_env.clone() { + if let Some(prefill) = prefill_from_env { state.value = prefill; state.prepopulated_from_env = true; } else { diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 29bf2e5bc5..c9617d796b 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -71,7 +71,7 @@ impl OnboardingScreen { config, } = args; let cwd = config.cwd.clone(); - let codex_home = config.codex_home.clone(); + let codex_home = config.codex_home; let mut steps: Vec = vec![Step::Welcome(WelcomeWidget { is_logged_in: !matches!(login_status, LoginStatus::NotAuthenticated), })]; diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs index 4f1739554a..410766e0fe 100644 --- a/codex-rs/tui/src/user_approval_widget.rs +++ b/codex-rs/tui/src/user_approval_widget.rs @@ -244,7 +244,7 @@ impl UserApprovalWidget { "You ".into(), "approved".bold(), " codex to run ".into(), - snippet.clone().dim(), + snippet.dim(), " this time".bold(), ]); } @@ -254,7 +254,7 @@ impl UserApprovalWidget { "You ".into(), "approved".bold(), " codex to run ".into(), - snippet.clone().dim(), + snippet.dim(), " every time this session".bold(), ]); } @@ -264,7 +264,7 @@ impl UserApprovalWidget { "You ".into(), "did not approve".bold(), " codex to run ".into(), - snippet.clone().dim(), + snippet.dim(), ]); } ReviewDecision::Abort => { @@ -273,7 +273,7 @@ impl UserApprovalWidget { "You ".into(), "canceled".bold(), " the request to run ".into(), - snippet.clone().dim(), + snippet.dim(), ]); } } From 027944c64efb7810161676c495660d1fae106b70 Mon Sep 17 00:00:00 2001 From: Dylan Date: Thu, 11 Sep 2025 12:09:20 -0700 Subject: [PATCH 06/18] fix: improve handle_sandbox_error timeouts (#3435) ## Summary Handle timeouts the same way, regardless of approval mode. There's more to do here, but this is simple and should be zero-regret ## Testing - [x] existing tests pass - [x] test locally and verify rollout --- codex-rs/core/src/codex.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b48093d8f4..c82c50e45d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2718,6 +2718,20 @@ async fn handle_sandbox_error( let sub_id = exec_command_context.sub_id.clone(); let cwd = exec_command_context.cwd.clone(); + // if the command timed out, we can simply return this failure to the model + if matches!(error, SandboxErr::Timeout) { + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!( + "command timed out after {} milliseconds", + params.timeout_duration().as_millis() + ), + success: Some(false), + }, + }; + } + // Early out if either the user never wants to be asked for approval, or // we're letting the model manage escalation requests. Otherwise, continue match turn_context.approval_policy { @@ -2735,20 +2749,6 @@ async fn handle_sandbox_error( AskForApproval::UnlessTrusted | AskForApproval::OnFailure => (), } - // similarly, if the command timed out, we can simply return this failure to the model - if matches!(error, SandboxErr::Timeout) { - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - content: format!( - "command timed out after {} milliseconds", - params.timeout_duration().as_millis() - ), - success: Some(false), - }, - }; - } - // Note that when `error` is `SandboxErr::Denied`, it could be a false // positive. That is, it may have exited with a non-zero exit code, not // because the sandbox denied it, but because that is its expected behavior, From 8f7b22b652bd39a870bceedddcc1c958690ee54c Mon Sep 17 00:00:00 2001 From: Charlie Weems Date: Thu, 11 Sep 2025 14:38:24 -0700 Subject: [PATCH 07/18] Add more detailed documentation on MCP server usage (#3345) Adds further information on how to get started with `codex mcp`: - Tool details and parameter references - Quickstart with example using MCP inspector. --- docs/advanced.md | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/docs/advanced.md b/docs/advanced.md index a08b35c118..38fbd0db45 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -38,5 +38,49 @@ args = ["-y", "mcp-server"] env = { "API_KEY" = "value" } ``` +## Using Codex as an MCP Server + +The Codex CLI can also be run as an MCP _server_ via `codex mcp`. For example, you can use `codex mcp` to make Codex available as a tool inside of a multi-agent framework like the OpenAI [Agents SDK](https://platform.openai.com/docs/guides/agents). + +### Codex MCP Server Quickstart +You can launch a Codex MCP server with the [Model Context Protocol Inspector](https://modelcontextprotocol.io/legacy/tools/inspector): + +``` bash +npx @modelcontextprotocol/inspector codex mcp +``` +Send a `tools/list` request and you will see that there are two tools available: + +**`codex`** - Run a Codex session. Accepts configuration parameters matching the Codex Config struct. The `codex` tool takes the following properties: + +Property | Type | Description +-------------------|----------|---------------------------------------------------------------------------------------------------------- +approval-policy | string | Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `never`. +base-instructions | string | The set of instructions to use instead of the default ones. +config | object | Individual [config settings](https://github.com/openai/codex/blob/main/docs/config.md#config) that will override what is in CODEX_HOME/config.toml. +cwd | string | Working directory for the session. If relative, resolved against the server process's current directory. +include-plan-tool | boolean | Whether to include the plan tool in the conversation. +model | string | Optional override for the model name (e.g. "o3", "o4-mini"). +profile | string | Configuration profile from config.toml to specify default options. +**prompt** (required) | string | The initial user prompt to start the Codex conversation. +sandbox | string | Sandbox mode: `read-only`, `workspace-write`, or `danger-full-access`. + +**`codex-reply`** - Continue a Codex session by providing the session id and prompt. The `codex-reply` tool takes the following properties: + +Property | Type | Description +-----------|--------|--------------------------------------------------------------- +**prompt** (required) | string | The next user prompt to continue the Codex conversation. +**conversationId** (required) | string | The id of the conversation to continue. + +### Trying it Out > [!TIP] -> It is somewhat experimental, but the Codex CLI can also be run as an MCP _server_ via `codex mcp`. If you launch it with an MCP client such as `npx @modelcontextprotocol/inspector codex mcp` and send it a `tools/list` request, you will see that there is only one tool, `codex`, that accepts a grab-bag of inputs, including a catch-all `config` map for anything you might want to override. Feel free to play around with it and provide feedback via GitHub issues. +> Codex often takes a few minutes to run. To accommodate this, adjust the MCP inspector's Request and Total timeouts to 600000ms (10 minutes) under ⛭ Configuration. + +Use the MCP inspector and `codex mcp` to build a simple tic-tac-toe game with the following settings: + +**approval-policy:** never + +**prompt:** Implement a simple tic-tac-toe game with HTML, Javascript, and CSS. Write the game in a single file called index.html. + +**sandbox:** workspace-write + +Click "Run Tool" and you should see a list of events emitted from the Codex MCP server as it builds the game. From 44587c2443566e576f5e77e044b06ca7f4e3cebf Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 11 Sep 2025 18:01:25 -0400 Subject: [PATCH 08/18] Use PlanType enum when formatting usage-limit CTA (#3495) - Started using Play type struct - Added CTA for team/business - Refactored a bit to unify the logic --- codex-rs/core/src/auth.rs | 5 +- codex-rs/core/src/client.rs | 38 ++++++++++- codex-rs/core/src/error.rs | 114 ++++++++++++++++++++++++-------- codex-rs/core/src/token_data.rs | 9 --- 4 files changed, 125 insertions(+), 41 deletions(-) diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index fe882c5dc4..09ad13b4f7 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -17,6 +17,7 @@ use std::time::Duration; use codex_protocol::mcp_protocol::AuthMode; +use crate::token_data::PlanType; use crate::token_data::TokenData; use crate::token_data::parse_id_token; @@ -134,9 +135,9 @@ impl CodexAuth { self.get_current_token_data().and_then(|t| t.account_id) } - pub fn get_plan_type(&self) -> Option { + pub(crate) fn get_plan_type(&self) -> Option { self.get_current_token_data() - .and_then(|t| t.id_token.chatgpt_plan_type.as_ref().map(|p| p.as_string())) + .and_then(|t| t.id_token.chatgpt_plan_type) } fn get_current_auth_json(&self) -> Option { diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 1268be65c3..8c19451804 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -41,6 +41,7 @@ use crate::model_provider_info::WireApi; use crate::openai_model_info::get_model_info; use crate::openai_tools::create_tools_json_for_responses_api; use crate::protocol::TokenUsage; +use crate::token_data::PlanType; use crate::util::backoff; use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; @@ -60,7 +61,7 @@ struct Error { message: Option, // Optional fields available on "usage_limit_reached" and "usage_not_included" errors - plan_type: Option, + plan_type: Option, resets_in_seconds: Option, } @@ -304,7 +305,7 @@ impl ModelClient { // token. let plan_type = error .plan_type - .or_else(|| auth.and_then(|a| a.get_plan_type())); + .or_else(|| auth.as_ref().and_then(|a| a.get_plan_type())); let resets_in_seconds = error.resets_in_seconds; return Err(CodexErr::UsageLimitReached(UsageLimitReachedError { plan_type, @@ -1037,4 +1038,37 @@ mod tests { let delay = try_parse_retry_after(&err); assert_eq!(delay, Some(Duration::from_secs_f64(1.898))); } + + #[test] + fn error_response_deserializes_old_schema_known_plan_type_and_serializes_back() { + use crate::token_data::KnownPlan; + use crate::token_data::PlanType; + + let json = r#"{"error":{"type":"usage_limit_reached","plan_type":"pro","resets_in_seconds":3600}}"#; + let resp: ErrorResponse = + serde_json::from_str(json).expect("should deserialize old schema"); + + assert!(matches!( + resp.error.plan_type, + Some(PlanType::Known(KnownPlan::Pro)) + )); + + let plan_json = serde_json::to_string(&resp.error.plan_type).expect("serialize plan_type"); + assert_eq!(plan_json, "\"pro\""); + } + + #[test] + fn error_response_deserializes_old_schema_unknown_plan_type_and_serializes_back() { + use crate::token_data::PlanType; + + let json = + r#"{"error":{"type":"usage_limit_reached","plan_type":"vip","resets_in_seconds":60}}"#; + let resp: ErrorResponse = + serde_json::from_str(json).expect("should deserialize old schema"); + + assert!(matches!(resp.error.plan_type, Some(PlanType::Unknown(ref s)) if s == "vip")); + + let plan_json = serde_json::to_string(&resp.error.plan_type).expect("serialize plan_type"); + assert_eq!(plan_json, "\"vip\""); + } } diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 36b7abe677..b1cda02420 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -1,3 +1,5 @@ +use crate::token_data::KnownPlan; +use crate::token_data::PlanType; use codex_protocol::mcp_protocol::ConversationId; use reqwest::StatusCode; use serde_json; @@ -127,38 +129,58 @@ pub enum CodexErr { #[derive(Debug)] pub struct UsageLimitReachedError { - pub plan_type: Option, - pub resets_in_seconds: Option, + pub(crate) plan_type: Option, + pub(crate) resets_in_seconds: Option, } impl std::fmt::Display for UsageLimitReachedError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // Base message differs slightly for legacy ChatGPT Plus plan users. - if let Some(plan_type) = &self.plan_type - && plan_type == "plus" - { - write!( - f, - "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing) or try again" - )?; - if let Some(secs) = self.resets_in_seconds { - let reset_duration = format_reset_duration(secs); - write!(f, " in {reset_duration}.")?; - } else { - write!(f, " later.")?; + let message = match self.plan_type.as_ref() { + Some(PlanType::Known(KnownPlan::Plus)) => format!( + "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing){}", + retry_suffix_after_or(self.resets_in_seconds) + ), + Some(PlanType::Known(KnownPlan::Team)) | Some(PlanType::Known(KnownPlan::Business)) => { + format!( + "You've hit your usage limit. To get more access now, send a request to your admin{}", + retry_suffix_after_or(self.resets_in_seconds) + ) } - } else { - write!(f, "You've hit your usage limit.")?; - - if let Some(secs) = self.resets_in_seconds { - let reset_duration = format_reset_duration(secs); - write!(f, " Try again in {reset_duration}.")?; - } else { - write!(f, " Try again later.")?; + Some(PlanType::Known(KnownPlan::Free)) => { + "To use Codex with your ChatGPT plan, upgrade to Plus: https://openai.com/chatgpt/pricing." + .to_string() } - } + Some(PlanType::Known(KnownPlan::Pro)) + | Some(PlanType::Known(KnownPlan::Enterprise)) + | Some(PlanType::Known(KnownPlan::Edu)) => format!( + "You've hit your usage limit.{}", + retry_suffix(self.resets_in_seconds) + ), + Some(PlanType::Unknown(_)) | None => format!( + "You've hit your usage limit.{}", + retry_suffix(self.resets_in_seconds) + ), + }; - Ok(()) + write!(f, "{message}") + } +} + +fn retry_suffix(resets_in_seconds: Option) -> String { + if let Some(secs) = resets_in_seconds { + let reset_duration = format_reset_duration(secs); + format!(" Try again in {reset_duration}.") + } else { + " Try again later.".to_string() + } +} + +fn retry_suffix_after_or(resets_in_seconds: Option) -> String { + if let Some(secs) = resets_in_seconds { + let reset_duration = format_reset_duration(secs); + format!(" or try again in {reset_duration}.") + } else { + " or try again later.".to_string() } } @@ -237,7 +259,7 @@ mod tests { #[test] fn usage_limit_reached_error_formats_plus_plan() { let err = UsageLimitReachedError { - plan_type: Some("plus".to_string()), + plan_type: Some(PlanType::Known(KnownPlan::Plus)), resets_in_seconds: None, }; assert_eq!( @@ -246,6 +268,18 @@ mod tests { ); } + #[test] + fn usage_limit_reached_error_formats_free_plan() { + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Free)), + resets_in_seconds: Some(3600), + }; + assert_eq!( + err.to_string(), + "To use Codex with your ChatGPT plan, upgrade to Plus: https://openai.com/chatgpt/pricing." + ); + } + #[test] fn usage_limit_reached_error_formats_default_when_none() { let err = UsageLimitReachedError { @@ -258,10 +292,34 @@ mod tests { ); } + #[test] + fn usage_limit_reached_error_formats_team_plan() { + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Team)), + resets_in_seconds: Some(3600), + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. To get more access now, send a request to your admin or try again in 1 hour." + ); + } + + #[test] + fn usage_limit_reached_error_formats_business_plan_without_reset() { + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Business)), + resets_in_seconds: None, + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. To get more access now, send a request to your admin or try again later." + ); + } + #[test] fn usage_limit_reached_error_formats_default_for_other_plans() { let err = UsageLimitReachedError { - plan_type: Some("pro".to_string()), + plan_type: Some(PlanType::Known(KnownPlan::Pro)), resets_in_seconds: None, }; assert_eq!( @@ -285,7 +343,7 @@ mod tests { #[test] fn usage_limit_reached_includes_hours_and_minutes() { let err = UsageLimitReachedError { - plan_type: Some("plus".to_string()), + plan_type: Some(PlanType::Known(KnownPlan::Plus)), resets_in_seconds: Some(3 * 3600 + 32 * 60), }; assert_eq!( diff --git a/codex-rs/core/src/token_data.rs b/codex-rs/core/src/token_data.rs index 2c4f859c6c..7185a54fee 100644 --- a/codex-rs/core/src/token_data.rs +++ b/codex-rs/core/src/token_data.rs @@ -47,15 +47,6 @@ pub(crate) enum PlanType { Unknown(String), } -impl PlanType { - pub fn as_string(&self) -> String { - match self { - Self::Known(known) => format!("{known:?}").to_lowercase(), - Self::Unknown(s) => s.clone(), - } - } -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub(crate) enum KnownPlan { From 8453915e0284e037550528ff4cd9fb2f0b17c278 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 11 Sep 2025 15:04:29 -0700 Subject: [PATCH 09/18] feat: TUI onboarding (#3398) Example of how onboarding could look like --- codex-rs/common/src/model_presets.rs | 7 + codex-rs/core/src/config.rs | 251 +++++++++++++++++++++++++- codex-rs/core/src/internal_storage.rs | 68 +++++++ codex-rs/core/src/lib.rs | 2 + codex-rs/tui/src/app.rs | 112 +++++++++++- codex-rs/tui/src/chatwidget.rs | 12 +- codex-rs/tui/src/history_cell.rs | 6 + codex-rs/tui/src/lib.rs | 142 +++++++++++++-- codex-rs/tui/src/new_model_popup.rs | 155 ++++++++++++++++ 9 files changed, 729 insertions(+), 26 deletions(-) create mode 100644 codex-rs/core/src/internal_storage.rs create mode 100644 codex-rs/tui/src/new_model_popup.rs diff --git a/codex-rs/common/src/model_presets.rs b/codex-rs/common/src/model_presets.rs index 686a2c0291..2b6e5dfb92 100644 --- a/codex-rs/common/src/model_presets.rs +++ b/codex-rs/common/src/model_presets.rs @@ -49,6 +49,13 @@ pub fn builtin_model_presets() -> &'static [ModelPreset] { model: "gpt-5", effort: ReasoningEffort::High, }, + ModelPreset { + id: "gpt-5-high-new", + label: "gpt-5 high new", + description: "— our latest release tuned to rely on the model's built-in reasoning defaults", + model: "gpt-5-high-new", + effort: ReasoningEffort::Medium, + }, ]; PRESETS } diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 0eba6c852e..c2b1fd93bb 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -15,6 +15,7 @@ use crate::model_provider_info::built_in_model_providers; use crate::openai_model_info::get_model_info; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; +use anyhow::Context; use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; @@ -31,6 +32,7 @@ use toml::Value as TomlValue; use toml_edit::DocumentMut; const OPENAI_DEFAULT_MODEL: &str = "gpt-5"; +pub const GPT5_HIGH_MODEL: &str = "gpt-5-high"; /// Maximum number of bytes of the documentation that will be embedded. Larger /// files are *silently truncated* to this size so we do not take up too much of @@ -128,9 +130,6 @@ pub struct Config { /// output will be hyperlinked using the specified URI scheme. pub file_opener: UriBasedFileOpener, - /// Collection of settings that are specific to the TUI. - pub tui: Tui, - /// Path to the `codex-linux-sandbox` executable. This must be set if /// [`crate::exec::SandboxType::LinuxSeccomp`] is used. Note that this /// cannot be set in the config file: it must be set in code via @@ -351,6 +350,107 @@ pub fn set_project_trusted(codex_home: &Path, project_path: &Path) -> anyhow::Re Ok(()) } +fn ensure_profile_table<'a>( + doc: &'a mut DocumentMut, + profile_name: &str, +) -> anyhow::Result<&'a mut toml_edit::Table> { + let mut created_profiles_table = false; + { + let root = doc.as_table_mut(); + let needs_table = !root.contains_key("profiles") + || root + .get("profiles") + .and_then(|item| item.as_table()) + .is_none(); + if needs_table { + root.insert("profiles", toml_edit::table()); + created_profiles_table = true; + } + } + + let Some(profiles_table) = doc["profiles"].as_table_mut() else { + return Err(anyhow::anyhow!( + "profiles table missing after initialization" + )); + }; + + if created_profiles_table { + profiles_table.set_implicit(true); + } + + let needs_profile_table = !profiles_table.contains_key(profile_name) + || profiles_table + .get(profile_name) + .and_then(|item| item.as_table()) + .is_none(); + if needs_profile_table { + profiles_table.insert(profile_name, toml_edit::table()); + } + + let Some(profile_table) = profiles_table + .get_mut(profile_name) + .and_then(|item| item.as_table_mut()) + else { + return Err(anyhow::anyhow!(format!( + "profile table missing for {profile_name}" + ))); + }; + + profile_table.set_implicit(false); + Ok(profile_table) +} + +// TODO(jif) refactor config persistence. +pub async fn persist_model_selection( + codex_home: &Path, + active_profile: Option<&str>, + model: &str, + effort: Option, +) -> anyhow::Result<()> { + let config_path = codex_home.join(CONFIG_TOML_FILE); + let serialized = match tokio::fs::read_to_string(&config_path).await { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(err) => return Err(err.into()), + }; + + let mut doc = if serialized.is_empty() { + DocumentMut::new() + } else { + serialized.parse::()? + }; + + if let Some(profile_name) = active_profile { + let profile_table = ensure_profile_table(&mut doc, profile_name)?; + profile_table["model"] = toml_edit::value(model); + if let Some(effort) = effort { + profile_table["model_reasoning_effort"] = toml_edit::value(effort.to_string()); + } + } else { + let table = doc.as_table_mut(); + table["model"] = toml_edit::value(model); + if let Some(effort) = effort { + table["model_reasoning_effort"] = toml_edit::value(effort.to_string()); + } + } + + // TODO(jif) refactor the home creation + tokio::fs::create_dir_all(codex_home) + .await + .with_context(|| { + format!( + "failed to create Codex home directory at {}", + codex_home.display() + ) + })?; + + tokio::fs::write(&config_path, doc.to_string()) + .await + .with_context(|| format!("failed to persist config.toml at {}", config_path.display()))?; + + Ok(()) +} + /// Apply a single dotted-path override onto a TOML value. fn apply_toml_override(root: &mut TomlValue, path: &str, value: TomlValue) { use toml::value::Table; @@ -804,7 +904,6 @@ impl Config { codex_home, history, file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode), - tui: cfg.tui.unwrap_or_default(), codex_linux_sandbox_exe, hide_agent_reasoning: cfg.hide_agent_reasoning.unwrap_or(false), @@ -948,6 +1047,7 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + use tempfile::TempDir; #[test] @@ -1038,6 +1138,145 @@ exclude_slash_tmp = true ); } + #[tokio::test] + async fn persist_model_selection_updates_defaults() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + persist_model_selection( + codex_home.path(), + None, + "gpt-5-high-new", + Some(ReasoningEffort::High), + ) + .await?; + + let serialized = + tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; + let parsed: ConfigToml = toml::from_str(&serialized)?; + + assert_eq!(parsed.model.as_deref(), Some("gpt-5-high-new")); + assert_eq!(parsed.model_reasoning_effort, Some(ReasoningEffort::High)); + + Ok(()) + } + + #[tokio::test] + async fn persist_model_selection_overwrites_existing_model() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + + tokio::fs::write( + &config_path, + r#" +model = "gpt-5" +model_reasoning_effort = "medium" + +[profiles.dev] +model = "gpt-4.1" +"#, + ) + .await?; + + persist_model_selection( + codex_home.path(), + None, + "o4-mini", + Some(ReasoningEffort::High), + ) + .await?; + + let serialized = tokio::fs::read_to_string(config_path).await?; + let parsed: ConfigToml = toml::from_str(&serialized)?; + + assert_eq!(parsed.model.as_deref(), Some("o4-mini")); + assert_eq!(parsed.model_reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!( + parsed + .profiles + .get("dev") + .and_then(|profile| profile.model.as_deref()), + Some("gpt-4.1"), + ); + + Ok(()) + } + + #[tokio::test] + async fn persist_model_selection_updates_profile() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + persist_model_selection( + codex_home.path(), + Some("dev"), + "gpt-5-high-new", + Some(ReasoningEffort::Low), + ) + .await?; + + let serialized = + tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; + let parsed: ConfigToml = toml::from_str(&serialized)?; + let profile = parsed + .profiles + .get("dev") + .expect("profile should be created"); + + assert_eq!(profile.model.as_deref(), Some("gpt-5-high-new")); + assert_eq!(profile.model_reasoning_effort, Some(ReasoningEffort::Low)); + + Ok(()) + } + + #[tokio::test] + async fn persist_model_selection_updates_existing_profile() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + + tokio::fs::write( + &config_path, + r#" +[profiles.dev] +model = "gpt-4" +model_reasoning_effort = "medium" + +[profiles.prod] +model = "gpt-5" +"#, + ) + .await?; + + persist_model_selection( + codex_home.path(), + Some("dev"), + "o4-high", + Some(ReasoningEffort::Medium), + ) + .await?; + + let serialized = tokio::fs::read_to_string(config_path).await?; + let parsed: ConfigToml = toml::from_str(&serialized)?; + + let dev_profile = parsed + .profiles + .get("dev") + .expect("dev profile should survive updates"); + assert_eq!(dev_profile.model.as_deref(), Some("o4-high")); + assert_eq!( + dev_profile.model_reasoning_effort, + Some(ReasoningEffort::Medium) + ); + + assert_eq!( + parsed + .profiles + .get("prod") + .and_then(|profile| profile.model.as_deref()), + Some("gpt-5"), + ); + + Ok(()) + } + struct PrecedenceTestFixture { cwd: TempDir, codex_home: TempDir, @@ -1196,7 +1435,6 @@ model_verbosity = "high" codex_home: fixture.codex_home(), history: History::default(), file_opener: UriBasedFileOpener::VsCode, - tui: Tui::default(), codex_linux_sandbox_exe: None, hide_agent_reasoning: false, show_raw_agent_reasoning: false, @@ -1253,7 +1491,6 @@ model_verbosity = "high" codex_home: fixture.codex_home(), history: History::default(), file_opener: UriBasedFileOpener::VsCode, - tui: Tui::default(), codex_linux_sandbox_exe: None, hide_agent_reasoning: false, show_raw_agent_reasoning: false, @@ -1325,7 +1562,6 @@ model_verbosity = "high" codex_home: fixture.codex_home(), history: History::default(), file_opener: UriBasedFileOpener::VsCode, - tui: Tui::default(), codex_linux_sandbox_exe: None, hide_agent_reasoning: false, show_raw_agent_reasoning: false, @@ -1383,7 +1619,6 @@ model_verbosity = "high" codex_home: fixture.codex_home(), history: History::default(), file_opener: UriBasedFileOpener::VsCode, - tui: Tui::default(), codex_linux_sandbox_exe: None, hide_agent_reasoning: false, show_raw_agent_reasoning: false, diff --git a/codex-rs/core/src/internal_storage.rs b/codex-rs/core/src/internal_storage.rs new file mode 100644 index 0000000000..f7d17e88f9 --- /dev/null +++ b/codex-rs/core/src/internal_storage.rs @@ -0,0 +1,68 @@ +use anyhow::Context; +use serde::Deserialize; +use serde::Serialize; +use std::path::Path; +use std::path::PathBuf; + +pub(crate) const INTERNAL_STORAGE_FILE: &str = "internal_storage.json"; + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct InternalStorage { + #[serde(skip)] + storage_path: PathBuf, + #[serde(default)] + pub gpt_5_high_model_prompt_seen: bool, +} + +// TODO(jif) generalise all the file writers and build proper async channel inserters. +impl InternalStorage { + pub fn load(codex_home: &Path) -> Self { + let storage_path = codex_home.join(INTERNAL_STORAGE_FILE); + + match std::fs::read_to_string(&storage_path) { + Ok(serialized) => match serde_json::from_str::(&serialized) { + Ok(mut storage) => { + storage.storage_path = storage_path; + storage + } + Err(error) => { + tracing::warn!("failed to parse internal storage: {error:?}"); + Self::empty(storage_path) + } + }, + Err(error) => { + tracing::warn!("failed to read internal storage: {error:?}"); + Self::empty(storage_path) + } + } + } + + fn empty(storage_path: PathBuf) -> Self { + Self { + storage_path, + ..Default::default() + } + } + + pub async fn persist(&self) -> anyhow::Result<()> { + let serialized = serde_json::to_string_pretty(self)?; + + if let Some(parent) = self.storage_path.parent() { + tokio::fs::create_dir_all(parent).await.with_context(|| { + format!( + "failed to create internal storage directory at {}", + parent.display() + ) + })?; + } + + tokio::fs::write(&self.storage_path, serialized) + .await + .with_context(|| { + format!( + "failed to persist internal storage at {}", + self.storage_path.display() + ) + }) + } +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 5223b6d327..55d0062454 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -28,6 +28,7 @@ mod exec_command; pub mod exec_env; mod flags; pub mod git_info; +pub mod internal_storage; mod is_safe_command; pub mod landlock; mod mcp_connection_manager; @@ -74,6 +75,7 @@ pub use rollout::list::ConversationsPage; pub use rollout::list::Cursor; mod user_notification; pub mod util; + pub use apply_patch::CODEX_APPLY_PATCH_ARG1; pub use safety::get_platform_sandbox; // Re-export the protocol types from the standalone `codex-protocol` crate so existing diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b2f91682a6..5680c2265c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -11,6 +11,8 @@ use codex_ansi_escape::ansi_escape_line; use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; +use codex_core::config::persist_model_selection; +use codex_core::model_family::find_family_for_model; use codex_core::protocol::TokenUsage; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -37,6 +39,9 @@ pub(crate) struct App { /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, + pub(crate) active_profile: Option, + model_saved_to_profile: bool, + model_saved_to_global: bool, pub(crate) file_search: FileSearchManager, @@ -61,6 +66,7 @@ impl App { tui: &mut tui::Tui, auth_manager: Arc, config: Config, + active_profile: Option, initial_prompt: Option, initial_images: Vec, resume_selection: ResumeSelection, @@ -119,6 +125,9 @@ impl App { app_event_tx, chat_widget, config, + active_profile, + model_saved_to_profile: false, + model_saved_to_global: false, file_search, enhanced_keys_supported, transcript_lines: Vec::new(), @@ -291,7 +300,14 @@ impl App { self.chat_widget.set_reasoning_effort(effort); } AppEvent::UpdateModel(model) => { - self.chat_widget.set_model(model); + self.chat_widget.set_model(model.clone()); + self.config.model = model.clone(); + if let Some(family) = find_family_for_model(&model) { + self.config.model_family = family; + } + self.model_saved_to_profile = false; + self.model_saved_to_global = false; + self.show_model_save_hint(); } AppEvent::UpdateAskForApprovalPolicy(policy) => { self.chat_widget.set_approval_policy(policy); @@ -307,6 +323,92 @@ impl App { self.chat_widget.token_usage() } + fn show_model_save_hint(&mut self) { + let model = self.config.model.clone(); + if self.active_profile.is_some() { + self.chat_widget.add_info_message(format!( + "Model switched to {model}. Press Ctrl+S to save it for this profile, then press Ctrl+S again to set it as your global default." + )); + } else { + self.chat_widget.add_info_message(format!( + "Model switched to {model}. Press Ctrl+S to save it as your global default." + )); + } + } + + async fn persist_model_shortcut(&mut self) { + enum SaveScope<'a> { + Profile(&'a str), + Global, + AlreadySaved, + } + + let scope = if let Some(profile) = self + .active_profile + .as_deref() + .filter(|_| !self.model_saved_to_profile) + { + SaveScope::Profile(profile) + } else if !self.model_saved_to_global { + SaveScope::Global + } else { + SaveScope::AlreadySaved + }; + + let model = self.config.model.clone(); + let effort = self.config.model_reasoning_effort; + let codex_home = self.config.codex_home.clone(); + + match scope { + SaveScope::Profile(profile) => { + match persist_model_selection(&codex_home, Some(profile), &model, Some(effort)) + .await + { + Ok(()) => { + self.model_saved_to_profile = true; + self.chat_widget.add_info_message(format!( + "Saved model {model} ({effort}) for profile `{profile}`. Press Ctrl+S again to make this your global default." + )); + } + Err(err) => { + tracing::error!( + error = %err, + "failed to persist model selection via shortcut" + ); + self.chat_widget.add_error_message(format!( + "Failed to save model preference for profile `{profile}`: {err}" + )); + } + } + } + SaveScope::Global => { + match persist_model_selection(&codex_home, None, &model, Some(effort)).await { + Ok(()) => { + self.model_saved_to_global = true; + self.chat_widget.add_info_message(format!( + "Saved model {model} ({effort}) as your global default." + )); + } + Err(err) => { + tracing::error!( + error = %err, + "failed to persist global model selection via shortcut" + ); + self.chat_widget.add_error_message(format!( + "Failed to save global model preference: {err}" + )); + } + } + } + SaveScope::AlreadySaved => { + self.chat_widget.add_info_message( + "Model preference already saved globally; no further action needed." + .to_string(), + ); + } + } + } + async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { match key_event { KeyEvent { @@ -320,6 +422,14 @@ impl App { self.overlay = Some(Overlay::new_transcript(self.transcript_lines.clone())); tui.frame_requester().schedule_frame(); } + KeyEvent { + code: KeyCode::Char('s'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + self.persist_model_shortcut().await; + } // Esc primes/advances backtracking only in normal (not working) mode // with an empty composer. In any other state, forward Esc so the // active UI (e.g. status indicator, modals, popups) handles it. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2756db2231..f9987a68b3 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1207,7 +1207,7 @@ impl ChatWidget { self.bottom_pane.show_selection_view( "Select model and reasoning level".to_string(), Some("Switch between OpenAI models for this and future Codex CLI session".to_string()), - Some("Press Enter to confirm or Esc to go back".to_string()), + Some("Press Enter to confirm, Esc to go back, Ctrl+S to save".to_string()), items, ); } @@ -1273,6 +1273,16 @@ impl ChatWidget { self.config.model = model; } + pub(crate) fn add_info_message(&mut self, message: String) { + self.add_to_history(history_cell::new_info_event(message)); + self.request_redraw(); + } + + pub(crate) fn add_error_message(&mut self, message: String) { + self.add_to_history(history_cell::new_error_event(message)); + self.request_redraw(); + } + pub(crate) fn add_mcp_output(&mut self) { if self.config.mcp_servers.is_empty() { self.add_to_history(history_cell::empty_mcp_output()); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index b3b45774c1..6c9f18fc0f 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1052,6 +1052,12 @@ pub(crate) fn new_mcp_tools_output( PlainHistoryCell { lines } } +pub(crate) fn new_info_event(message: String) -> PlainHistoryCell { + let lines: Vec> = + vec![vec![padded_emoji("💾").green(), " ".into(), message.into()].into()]; + PlainHistoryCell { lines } +} + pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { // Use a hair space (U+200A) to create a subtle, near-invisible separation // before the text. VS16 is intentionally omitted to keep spacing tighter diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 9a58512749..7ce175286b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -11,8 +11,10 @@ use codex_core::RolloutRecorder; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; +use codex_core::config::GPT5_HIGH_MODEL; use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; +use codex_core::config::persist_model_selection; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_ollama::DEFAULT_OSS_MODEL; @@ -47,6 +49,7 @@ pub mod live_wrap; mod markdown; mod markdown_render; mod markdown_stream; +mod new_model_popup; pub mod onboarding; mod pager_overlay; mod render; @@ -65,12 +68,14 @@ mod wrapping; #[cfg(not(debug_assertions))] mod updates; -pub use cli::Cli; - +use crate::new_model_popup::ModelUpgradeDecision; +use crate::new_model_popup::run_model_upgrade_popup; use crate::onboarding::TrustDirectorySelection; use crate::onboarding::onboarding_screen::OnboardingScreenArgs; use crate::onboarding::onboarding_screen::run_onboarding_app; use crate::tui::Tui; +pub use cli::Cli; +use codex_core::internal_storage::InternalStorage; // (tests access modules directly within the crate) @@ -174,14 +179,21 @@ pub async fn run_main( } }; + let cli_profile_override = cli.config_profile.clone(); + let active_profile = cli_profile_override + .clone() + .or_else(|| config_toml.profile.clone()); + let should_show_trust_screen = determine_repo_trust_state( &mut config, &config_toml, approval_policy, sandbox_mode, - cli.config_profile.clone(), + cli_profile_override, )?; + let internal_storage = InternalStorage::load(&config.codex_home); + let log_dir = codex_core::config::log_dir(&config)?; std::fs::create_dir_all(&log_dir)?; // Open (or create) your log file, appending to it. @@ -224,14 +236,22 @@ pub async fn run_main( let _ = tracing_subscriber::registry().with(file_layer).try_init(); - run_ratatui_app(cli, config, should_show_trust_screen) - .await - .map_err(|err| std::io::Error::other(err.to_string())) + run_ratatui_app( + cli, + config, + internal_storage, + active_profile, + should_show_trust_screen, + ) + .await + .map_err(|err| std::io::Error::other(err.to_string())) } async fn run_ratatui_app( cli: Cli, config: Config, + mut internal_storage: InternalStorage, + active_profile: Option, should_show_trust_screen: bool, ) -> color_eyre::Result { let mut config = config; @@ -300,14 +320,6 @@ async fn run_ratatui_app( // Initialize high-fidelity session event logging if enabled. session_log::maybe_init(&config); - let Cli { - prompt, - images, - resume, - r#continue, - .. - } = cli; - let auth_manager = AuthManager::shared(config.codex_home.clone()); let login_status = get_login_status(&config); let should_show_onboarding = @@ -330,7 +342,7 @@ async fn run_ratatui_app( } } - let resume_selection = if r#continue { + let resume_selection = if cli.r#continue { match RolloutRecorder::list_conversations(&config.codex_home, 1, None).await { Ok(page) => page .items @@ -339,7 +351,7 @@ async fn run_ratatui_app( .unwrap_or(resume_picker::ResumeSelection::StartFresh), Err(_) => resume_picker::ResumeSelection::StartFresh, } - } else if resume { + } else if cli.resume { match resume_picker::run_resume_picker(&mut tui, &config.codex_home).await? { resume_picker::ResumeSelection::Exit => { restore(); @@ -352,10 +364,42 @@ async fn run_ratatui_app( resume_picker::ResumeSelection::StartFresh }; + if should_show_model_rollout_prompt( + &cli, + &config, + active_profile.as_deref(), + internal_storage.gpt_5_high_model_prompt_seen, + ) { + internal_storage.gpt_5_high_model_prompt_seen = true; + if let Err(e) = internal_storage.persist().await { + error!("Failed to persist internal storage: {e:?}"); + } + + let upgrade_decision = run_model_upgrade_popup(&mut tui).await?; + let switch_to_new_model = upgrade_decision == ModelUpgradeDecision::Switch; + + if switch_to_new_model { + config.model = GPT5_HIGH_MODEL.to_owned(); + if let Err(e) = persist_model_selection( + &config.codex_home, + active_profile.as_deref(), + &config.model, + None, + ) + .await + { + error!("Failed to persist model selection: {e:?}"); + } + } + } + + let Cli { prompt, images, .. } = cli; + let app_result = App::run( &mut tui, auth_manager, config, + active_profile, prompt, images, resume_selection, @@ -463,11 +507,44 @@ fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool login_status == LoginStatus::NotAuthenticated } +fn should_show_model_rollout_prompt( + cli: &Cli, + config: &Config, + active_profile: Option<&str>, + gpt_5_high_model_prompt_seen: bool, +) -> bool { + // TODO(jif) drop. + let debug_high_enabled = std::env::var("DEBUG_HIGH") + .map(|v| v.eq_ignore_ascii_case("1")) + .unwrap_or(false); + + active_profile.is_none() + && debug_high_enabled + && cli.model.is_none() + && !gpt_5_high_model_prompt_seen + && config.model_provider.requires_openai_auth + && !cli.oss +} + #[cfg(test)] mod tests { use super::*; + use clap::Parser; + use std::sync::Once; + + fn enable_debug_high_env() { + static DEBUG_HIGH_ONCE: Once = Once::new(); + DEBUG_HIGH_ONCE.call_once(|| { + // SAFETY: Tests run in a controlled environment and require this env variable to + // opt into the GPT-5 High rollout prompt gating. We only set it once. + unsafe { + std::env::set_var("DEBUG_HIGH", "1"); + } + }); + } fn make_config() -> Config { + enable_debug_high_env(); Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides::default(), @@ -484,4 +561,37 @@ mod tests { &cfg )); } + + #[test] + fn shows_model_rollout_prompt_for_default_model() { + let cli = Cli::parse_from(["codex"]); + let cfg = make_config(); + assert!(should_show_model_rollout_prompt(&cli, &cfg, None, false)); + } + + #[test] + fn hides_model_rollout_prompt_when_marked_seen() { + let cli = Cli::parse_from(["codex"]); + let cfg = make_config(); + assert!(!should_show_model_rollout_prompt(&cli, &cfg, None, true)); + } + + #[test] + fn hides_model_rollout_prompt_when_cli_overrides_model() { + let cli = Cli::parse_from(["codex", "--model", "gpt-4.1"]); + let cfg = make_config(); + assert!(!should_show_model_rollout_prompt(&cli, &cfg, None, false)); + } + + #[test] + fn hides_model_rollout_prompt_when_profile_active() { + let cli = Cli::parse_from(["codex"]); + let cfg = make_config(); + assert!(!should_show_model_rollout_prompt( + &cli, + &cfg, + Some("gpt5"), + false, + )); + } } diff --git a/codex-rs/tui/src/new_model_popup.rs b/codex-rs/tui/src/new_model_popup.rs new file mode 100644 index 0000000000..e89fb67ef5 --- /dev/null +++ b/codex-rs/tui/src/new_model_popup.rs @@ -0,0 +1,155 @@ +use crate::tui::FrameRequester; +use crate::tui::Tui; +use crate::tui::TuiEvent; +use codex_core::config::GPT5_HIGH_MODEL; +use color_eyre::eyre::Result; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::prelude::Widget; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; +use ratatui::widgets::WidgetRef; +use ratatui::widgets::Wrap; +use tokio_stream::StreamExt; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ModelUpgradeDecision { + Switch, + KeepCurrent, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ModelUpgradeOption { + TryNewModel, + KeepCurrent, +} + +struct ModelUpgradePopup { + highlighted: ModelUpgradeOption, + decision: Option, + request_frame: FrameRequester, +} + +impl ModelUpgradePopup { + fn new(request_frame: FrameRequester) -> Self { + Self { + highlighted: ModelUpgradeOption::TryNewModel, + decision: None, + request_frame, + } + } + + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event.code { + KeyCode::Up | KeyCode::Char('k') => self.highlight(ModelUpgradeOption::TryNewModel), + KeyCode::Down | KeyCode::Char('j') => self.highlight(ModelUpgradeOption::KeepCurrent), + KeyCode::Char('1') => self.select(ModelUpgradeOption::TryNewModel), + KeyCode::Char('2') => self.select(ModelUpgradeOption::KeepCurrent), + KeyCode::Enter => self.select(self.highlighted), + KeyCode::Esc => self.select(ModelUpgradeOption::KeepCurrent), + _ => {} + } + } + + fn highlight(&mut self, option: ModelUpgradeOption) { + if self.highlighted != option { + self.highlighted = option; + self.request_frame.schedule_frame(); + } + } + + fn select(&mut self, option: ModelUpgradeOption) { + self.decision = Some(option.into()); + self.request_frame.schedule_frame(); + } +} + +impl From for ModelUpgradeDecision { + fn from(option: ModelUpgradeOption) -> Self { + match option { + ModelUpgradeOption::TryNewModel => ModelUpgradeDecision::Switch, + ModelUpgradeOption::KeepCurrent => ModelUpgradeDecision::KeepCurrent, + } + } +} + +impl WidgetRef for &ModelUpgradePopup { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + Clear.render(area, buf); + + let mut lines: Vec = vec![ + Line::from(vec![ + "> ".into(), + format!("Try {GPT5_HIGH_MODEL} as your default model").bold(), + ]), + format!(" {GPT5_HIGH_MODEL} is our latest model tuned for coding workflows.").into(), + " Switch now or keep your current default – you can change models any time.".into(), + "".into(), + ]; + + let create_option = + |index: usize, option: ModelUpgradeOption, text: &str| -> Line<'static> { + if self.highlighted == option { + Line::from(vec![ + format!("> {}. ", index + 1).cyan(), + text.to_owned().cyan(), + ]) + } else { + format!(" {}. {text}", index + 1).into() + } + }; + + lines.push(create_option( + 0, + ModelUpgradeOption::TryNewModel, + &format!("Yes, switch me to {GPT5_HIGH_MODEL}"), + )); + lines.push(create_option( + 1, + ModelUpgradeOption::KeepCurrent, + "Not right now", + )); + lines.push("".into()); + lines.push( + " Press Enter to confirm or Esc to keep your current model" + .dim() + .into(), + ); + + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(area, buf); + } +} + +pub(crate) async fn run_model_upgrade_popup(tui: &mut Tui) -> Result { + let mut popup = ModelUpgradePopup::new(tui.frame_requester()); + + tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&popup, frame.area()); + })?; + + let events = tui.event_stream(); + tokio::pin!(events); + while popup.decision.is_none() { + if let Some(event) = events.next().await { + match event { + TuiEvent::Key(key_event) => popup.handle_key_event(key_event), + TuiEvent::Draw => { + let _ = tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&popup, frame.area()); + }); + } + _ => {} + } + } else { + break; + } + } + + Ok(popup.decision.unwrap_or(ModelUpgradeDecision::KeepCurrent)) +} From 44bb53df1ee2260633f9d55a18897cb2a5cb7328 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 11 Sep 2025 16:10:24 -0700 Subject: [PATCH 10/18] bug: default to image (#3501) Default the MIME type to image --- codex-rs/core/src/unified_exec/mod.rs | 20 -------------------- codex-rs/protocol/src/models.rs | 2 +- 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 25ab3d4dc0..2fe8661f0e 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -586,26 +586,6 @@ mod tests { Ok(()) } - #[cfg(unix)] - #[tokio::test] - async fn correct_path_resolution() -> Result<(), UnifiedExecError> { - let manager = UnifiedExecSessionManager::default(); - let result = manager - .handle_request(UnifiedExecRequest { - session_id: None, - input_chunks: &["echo".to_string(), "codex".to_string()], - timeout_ms: Some(1_500), - }) - .await?; - - assert!(result.session_id.is_none()); - assert!(result.output.contains("codex")); - - assert!(manager.sessions.lock().await.is_empty()); - - Ok(()) - } - #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn reusing_completed_session_returns_unknown_session() -> Result<(), UnifiedExecError> { diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 13a81c9de6..08cd64f600 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -219,7 +219,7 @@ impl From> for ResponseInputItem { let mime = mime_guess::from_path(&path) .first() .map(|m| m.essence_str().to_owned()) - .unwrap_or_else(|| "application/octet-stream".to_string()); + .unwrap_or_else(|| "image".to_string()); let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); Some(ContentItem::InputImage { image_url: format!("data:{mime};base64,{encoded}"), From 3b5a5412bbd7952a443a11f37d6422e1a4c3a7fd Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 11 Sep 2025 17:42:44 -0700 Subject: [PATCH 11/18] Log cf-ray header in client traces (#3488) ## Summary - log the `cf-ray` header when tracing HTTP responses in the Codex client - keep existing response status logging unchanged ## Testing - just fmt - just fix -p codex-core - cargo test -p codex-core *(fails: suite::client::azure_overrides_assign_properties_used_for_responses_url, suite::client::env_var_overrides_loaded_auth)* ------ https://chatgpt.com/codex/tasks/task_i_68c31640dacc83209be131baf91611cd --- codex-rs/core/src/client.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 8c19451804..e6e21df396 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -240,10 +240,10 @@ impl ModelClient { let res = req_builder.send().await; if let Ok(resp) = &res { trace!( - "Response status: {}, request-id: {}", + "Response status: {}, cf-ray: {}", resp.status(), resp.headers() - .get("x-request-id") + .get("cf-ray") .map(|v| v.to_str().unwrap_or_default()) .unwrap_or_default() ); From 6ccd32c601984e36120e877b9c52ae6670ac93df Mon Sep 17 00:00:00 2001 From: Fouad Matin <169186268+fouad-openai@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:46:20 -0700 Subject: [PATCH 12/18] add(readme): IDE (#3494) update copy in readme to add link to IDE --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8f7e624eea..ab93ecad22 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,10 @@

npm i -g @openai/codex
or brew install codex

-

Codex CLI is a coding agent from OpenAI that runs locally on your computer.
If you are looking for the cloud-based agent from OpenAI, Codex Web, see chatgpt.com/codex.

+

Codex CLI is a coding agent from OpenAI that runs locally on your computer. +
+
If you want Codex in your code editor (VS Code, Cursor, Windsurf), install in your IDE +
If you are looking for the cloud-based agent from OpenAI, Codex Web, go to chatgpt.com/codex

Codex CLI splash From 9bbeb75361a3b0111eb62b6912fd5cdd86e57bf4 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 11 Sep 2025 21:04:40 -0700 Subject: [PATCH 13/18] feat: include reasoning_effort in NewConversationResponse (#3506) `ClientRequest::NewConversation` picks up the reasoning level from the user's defaults in `config.toml`, so it should be reported in `NewConversationResponse`. --- codex-rs/core/src/codex.rs | 1 + codex-rs/exec/src/event_processor_with_human_output.rs | 1 + codex-rs/mcp-server/src/codex_message_processor.rs | 1 + codex-rs/mcp-server/src/outgoing_message.rs | 4 ++++ .../mcp-server/tests/suite/codex_message_processor_flow.rs | 1 + codex-rs/mcp-server/tests/suite/create_conversation.rs | 1 + codex-rs/protocol/src/mcp_protocol.rs | 2 ++ codex-rs/protocol/src/protocol.rs | 5 +++++ codex-rs/tui/src/chatwidget/tests.rs | 1 + codex-rs/tui/src/history_cell.rs | 1 + codex-rs/tui/tests/fixtures/binary-size-log.jsonl | 6 +++--- 11 files changed, 21 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index c82c50e45d..f77e6a40c3 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -505,6 +505,7 @@ impl Session { msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, model, + reasoning_effort: model_reasoning_effort, history_log_id, history_entry_count, initial_messages, diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index c80117706b..3856e05bf5 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -520,6 +520,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { let SessionConfiguredEvent { session_id: conversation_id, model, + reasoning_effort: _, history_log_id: _, history_entry_count: _, initial_messages: _, diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs index 858bb99c24..b7b2b0f95f 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/mcp-server/src/codex_message_processor.rs @@ -593,6 +593,7 @@ impl CodexMessageProcessor { let response = NewConversationResponse { conversation_id, model: session_configured.model, + reasoning_effort: session_configured.reasoning_effort, rollout_path: session_configured.rollout_path, }; self.outgoing.send_response(request_id, response).await; diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 939d986aec..04e99516ce 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -258,6 +258,7 @@ pub(crate) struct OutgoingError { mod tests { use codex_core::protocol::EventMsg; use codex_core::protocol::SessionConfiguredEvent; + use codex_protocol::config_types::ReasoningEffort; use codex_protocol::mcp_protocol::ConversationId; use codex_protocol::mcp_protocol::LoginChatGptCompleteNotification; use pretty_assertions::assert_eq; @@ -279,6 +280,7 @@ mod tests { msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, model: "gpt-4o".to_string(), + reasoning_effort: ReasoningEffort::default(), history_log_id: 1, history_entry_count: 1000, initial_messages: None, @@ -312,6 +314,7 @@ mod tests { let session_configured_event = SessionConfiguredEvent { session_id: conversation_id, model: "gpt-4o".to_string(), + reasoning_effort: ReasoningEffort::default(), history_log_id: 1, history_entry_count: 1000, initial_messages: None, @@ -342,6 +345,7 @@ mod tests { "msg": { "session_id": session_configured_event.session_id, "model": session_configured_event.model, + "reasoning_effort": session_configured_event.reasoning_effort, "history_log_id": session_configured_event.history_log_id, "history_entry_count": session_configured_event.history_entry_count, "type": "session_configured", diff --git a/codex-rs/mcp-server/tests/suite/codex_message_processor_flow.rs b/codex-rs/mcp-server/tests/suite/codex_message_processor_flow.rs index 7737c49af3..49d9ab1807 100644 --- a/codex-rs/mcp-server/tests/suite/codex_message_processor_flow.rs +++ b/codex-rs/mcp-server/tests/suite/codex_message_processor_flow.rs @@ -90,6 +90,7 @@ async fn test_codex_jsonrpc_conversation_flow() { let NewConversationResponse { conversation_id, model, + reasoning_effort: _, rollout_path: _, } = new_conv_resp; assert_eq!(model, "mock-model"); diff --git a/codex-rs/mcp-server/tests/suite/create_conversation.rs b/codex-rs/mcp-server/tests/suite/create_conversation.rs index 5071868c85..1b62d01d46 100644 --- a/codex-rs/mcp-server/tests/suite/create_conversation.rs +++ b/codex-rs/mcp-server/tests/suite/create_conversation.rs @@ -59,6 +59,7 @@ async fn test_conversation_create_and_send_message_ok() { let NewConversationResponse { conversation_id, model, + reasoning_effort: _, rollout_path: _, } = to_response::(new_conv_resp) .expect("deserialize newConversation response"); diff --git a/codex-rs/protocol/src/mcp_protocol.rs b/codex-rs/protocol/src/mcp_protocol.rs index 26887aa59b..a382b8bc7a 100644 --- a/codex-rs/protocol/src/mcp_protocol.rs +++ b/codex-rs/protocol/src/mcp_protocol.rs @@ -217,6 +217,8 @@ pub struct NewConversationParams { pub struct NewConversationResponse { pub conversation_id: ConversationId, pub model: String, + /// Note this could be ignored by the model. + pub reasoning_effort: ReasoningEffort, pub rollout_path: PathBuf, } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 2eb3d1e808..134059e978 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1081,6 +1081,9 @@ pub struct SessionConfiguredEvent { /// Tell the client what model is being queried. pub model: String, + /// The effort the model is putting into reasoning about the user's request. + pub reasoning_effort: ReasoningEffortConfig, + /// Identifier of the history log file (inode on Unix, 0 otherwise). pub history_log_id: u64, @@ -1169,6 +1172,7 @@ mod tests { msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, model: "codex-mini-latest".to_string(), + reasoning_effort: ReasoningEffortConfig::default(), history_log_id: 0, history_entry_count: 0, initial_messages: None, @@ -1182,6 +1186,7 @@ mod tests { "type": "session_configured", "session_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", "model": "codex-mini-latest", + "reasoning_effort": "medium", "history_log_id": 0, "history_entry_count": 0, "rollout_path": format!("{}", rollout_file.path().display()), diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index c148b8670b..dc1da26eb4 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -138,6 +138,7 @@ fn resumed_initial_messages_render_history() { let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, model: "test-model".to_string(), + reasoning_effort: ReasoningEffortConfig::default(), history_log_id: 0, history_entry_count: 0, initial_messages: Some(vec![ diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 6c9f18fc0f..42a79d60c5 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -601,6 +601,7 @@ pub(crate) fn new_session_info( ) -> PlainHistoryCell { let SessionConfiguredEvent { model, + reasoning_effort: _, session_id: _, history_log_id: _, history_entry_count: _, diff --git a/codex-rs/tui/tests/fixtures/binary-size-log.jsonl b/codex-rs/tui/tests/fixtures/binary-size-log.jsonl index 98cac7c0ae..2b243d18ec 100644 --- a/codex-rs/tui/tests/fixtures/binary-size-log.jsonl +++ b/codex-rs/tui/tests/fixtures/binary-size-log.jsonl @@ -1,4 +1,4 @@ -{"ts":"2025-08-09T15:51:04.827Z","dir":"meta","kind":"session_start","cwd":"/Users/easong/code/codex/codex-rs","model":"gpt-5","model_provider_id":"openai","model_provider_name":"OpenAI"} +{"ts":"2025-08-09T15:51:04.827Z","dir":"meta","kind":"session_start","cwd":"/Users/easong/code/codex/codex-rs","model":"gpt-5","reasoning_effort":"medium","model_provider_id":"openai","model_provider_name":"OpenAI"} {"ts":"2025-08-09T15:51:04.827Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('c'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} {"ts":"2025-08-09T15:51:04.827Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('o'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} {"ts":"2025-08-09T15:51:04.827Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('m'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} @@ -34,7 +34,7 @@ {"ts":"2025-08-09T15:51:04.829Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T15:51:04.829Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] resume_path: None"} {"ts":"2025-08-09T15:51:04.830Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} -{"ts":"2025-08-09T15:51:04.856Z","dir":"to_tui","kind":"codex_event","payload":{"id":"0","msg":{"type":"session_configured","session_id":"d126e3d0-80ed-480a-be8c-09d97ff602cf","model":"gpt-5","history_log_id":2532619,"history_entry_count":339,"rollout_path":"/tmp/codex-test-rollout.jsonl"}}} +{"ts":"2025-08-09T15:51:04.856Z","dir":"to_tui","kind":"codex_event","payload":{"id":"0","msg":{"type":"session_configured","session_id":"d126e3d0-80ed-480a-be8c-09d97ff602cf","model":"gpt-5","reasoning_effort":"medium","history_log_id":2532619,"history_entry_count":339,"rollout_path":"/tmp/codex-test-rollout.jsonl"}}} {"ts":"2025-08-09T15:51:04.856Z","dir":"to_tui","kind":"insert_history","lines":9} {"ts":"2025-08-09T15:51:04.857Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T15:51:04.857Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} @@ -16447,7 +16447,7 @@ {"ts":"2025-08-09T16:06:58.083Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T16:06:58.085Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} {"ts":"2025-08-09T16:06:58.085Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] resume_path: None"} -{"ts":"2025-08-09T16:06:58.136Z","dir":"to_tui","kind":"codex_event","payload":{"id":"0","msg":{"type":"session_configured","session_id":"c7df96da-daec-4fe9-aed9-3cd19b7a6192","model":"gpt-5","history_log_id":2532619,"history_entry_count":342,"rollout_path":"/tmp/codex-test-rollout.jsonl"}}} +{"ts":"2025-08-09T16:06:58.136Z","dir":"to_tui","kind":"codex_event","payload":{"id":"0","msg":{"type":"session_configured","session_id":"c7df96da-daec-4fe9-aed9-3cd19b7a6192","model":"gpt-5","reasoning_effort":"medium","history_log_id":2532619,"history_entry_count":342,"rollout_path":"/tmp/codex-test-rollout.jsonl"}}} {"ts":"2025-08-09T16:06:58.136Z","dir":"to_tui","kind":"insert_history","lines":9} {"ts":"2025-08-09T16:06:58.136Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T16:06:58.136Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} From c172e8e997f794c7e8bff5df781fc2b87117bae6 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 11 Sep 2025 23:44:17 -0700 Subject: [PATCH 14/18] feat: added SetDefaultModel to JSON-RPC server (#3512) This adds `SetDefaultModel`, which takes `model` and `reasoning_effort` as optional fields. If set, the field will overwrite what is in the user's `config.toml`. This reuses logic that was added to support the `/model` command in the TUI: https://github.com/openai/codex/pull/2799. --- codex-rs/core/src/config.rs | 4 +- .../mcp-server/src/codex_message_processor.rs | 42 +++++++++++++ .../mcp-server/tests/common/mcp_process.rs | 10 +++ codex-rs/mcp-server/tests/suite/mod.rs | 1 + .../tests/suite/set_default_model.rs | 62 +++++++++++++++++++ codex-rs/protocol-ts/src/lib.rs | 1 + codex-rs/protocol/src/mcp_protocol.rs | 18 ++++++ 7 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 codex-rs/mcp-server/tests/suite/set_default_model.rs diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index c2b1fd93bb..2cfd15fcae 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -495,7 +495,7 @@ fn apply_toml_override(root: &mut TomlValue, path: &str, value: TomlValue) { } /// Base config deserialized from ~/.codex/config.toml. -#[derive(Deserialize, Debug, Clone, Default)] +#[derive(Deserialize, Debug, Clone, Default, PartialEq)] pub struct ConfigToml { /// Optional override of model selection. pub model: Option, @@ -627,7 +627,7 @@ pub struct ProjectConfig { pub trust_level: Option, } -#[derive(Deserialize, Debug, Clone, Default)] +#[derive(Deserialize, Debug, Clone, Default, PartialEq)] pub struct ToolsToml { #[serde(default, alias = "web_search_request")] pub web_search: Option, diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs index b7b2b0f95f..6cd6fdeca8 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/mcp-server/src/codex_message_processor.rs @@ -18,6 +18,9 @@ use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; use codex_core::config::load_config_as_toml; +use codex_core::config_edit::CONFIG_KEY_EFFORT; +use codex_core::config_edit::CONFIG_KEY_MODEL; +use codex_core::config_edit::persist_non_null_overrides; use codex_core::default_client::get_codex_user_agent; use codex_core::exec::ExecParams; use codex_core::exec_env::create_env; @@ -71,6 +74,8 @@ use codex_protocol::mcp_protocol::SendUserMessageResponse; use codex_protocol::mcp_protocol::SendUserTurnParams; use codex_protocol::mcp_protocol::SendUserTurnResponse; use codex_protocol::mcp_protocol::ServerNotification; +use codex_protocol::mcp_protocol::SetDefaultModelParams; +use codex_protocol::mcp_protocol::SetDefaultModelResponse; use codex_protocol::mcp_protocol::UserInfoResponse; use codex_protocol::mcp_protocol::UserSavedConfig; use codex_protocol::models::ContentItem; @@ -192,6 +197,9 @@ impl CodexMessageProcessor { ClientRequest::GetUserSavedConfig { request_id } => { self.get_user_saved_config(request_id).await; } + ClientRequest::SetDefaultModel { request_id, params } => { + self.set_default_model(request_id, params).await; + } ClientRequest::GetUserAgent { request_id } => { self.get_user_agent(request_id).await; } @@ -499,6 +507,40 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + async fn set_default_model(&self, request_id: RequestId, params: SetDefaultModelParams) { + let SetDefaultModelParams { + model, + reasoning_effort, + } = params; + let effort_str = reasoning_effort.map(|effort| effort.to_string()); + + let overrides: [(&[&str], Option<&str>); 2] = [ + (&[CONFIG_KEY_MODEL], model.as_deref()), + (&[CONFIG_KEY_EFFORT], effort_str.as_deref()), + ]; + + match persist_non_null_overrides( + &self.config.codex_home, + self.config.active_profile.as_deref(), + &overrides, + ) + .await + { + Ok(()) => { + let response = SetDefaultModelResponse {}; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to persist overrides: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + async fn exec_one_off_command(&self, request_id: RequestId, params: ExecOneOffCommandParams) { tracing::debug!("ExecOneOffCommand params: {params:?}"); diff --git a/codex-rs/mcp-server/tests/common/mcp_process.rs b/codex-rs/mcp-server/tests/common/mcp_process.rs index a7969f9529..a30e9817f2 100644 --- a/codex-rs/mcp-server/tests/common/mcp_process.rs +++ b/codex-rs/mcp-server/tests/common/mcp_process.rs @@ -24,6 +24,7 @@ use codex_protocol::mcp_protocol::RemoveConversationListenerParams; use codex_protocol::mcp_protocol::ResumeConversationParams; use codex_protocol::mcp_protocol::SendUserMessageParams; use codex_protocol::mcp_protocol::SendUserTurnParams; +use codex_protocol::mcp_protocol::SetDefaultModelParams; use mcp_types::CallToolRequestParams; use mcp_types::ClientCapabilities; @@ -301,6 +302,15 @@ impl McpProcess { self.send_request("userInfo", None).await } + /// Send a `setDefaultModel` JSON-RPC request. + pub async fn send_set_default_model_request( + &mut self, + params: SetDefaultModelParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("setDefaultModel", params).await + } + /// Send a `listConversations` JSON-RPC request. pub async fn send_list_conversations_request( &mut self, diff --git a/codex-rs/mcp-server/tests/suite/mod.rs b/codex-rs/mcp-server/tests/suite/mod.rs index 4e603e1759..97e5370908 100644 --- a/codex-rs/mcp-server/tests/suite/mod.rs +++ b/codex-rs/mcp-server/tests/suite/mod.rs @@ -9,5 +9,6 @@ mod interrupt; mod list_resume; mod login; mod send_message; +mod set_default_model; mod user_agent; mod user_info; diff --git a/codex-rs/mcp-server/tests/suite/set_default_model.rs b/codex-rs/mcp-server/tests/suite/set_default_model.rs new file mode 100644 index 0000000000..e11b69ed2d --- /dev/null +++ b/codex-rs/mcp-server/tests/suite/set_default_model.rs @@ -0,0 +1,62 @@ +use codex_core::config::ConfigToml; +use codex_protocol::config_types::ReasoningEffort; +use codex_protocol::mcp_protocol::SetDefaultModelParams; +use codex_protocol::mcp_protocol::SetDefaultModelResponse; +use mcp_test_support::McpProcess; +use mcp_test_support::to_response; +use mcp_types::JSONRPCResponse; +use mcp_types::RequestId; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn set_default_model_persists_overrides() { + let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); + + let mut mcp = McpProcess::new(codex_home.path()) + .await + .expect("spawn mcp process"); + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) + .await + .expect("init timeout") + .expect("init failed"); + + let params = SetDefaultModelParams { + model: Some("o4-mini".to_string()), + reasoning_effort: Some(ReasoningEffort::High), + }; + + let request_id = mcp + .send_set_default_model_request(params) + .await + .expect("send setDefaultModel"); + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await + .expect("setDefaultModel timeout") + .expect("setDefaultModel response"); + + let _: SetDefaultModelResponse = + to_response(resp).expect("deserialize setDefaultModel response"); + + let config_path = codex_home.path().join("config.toml"); + let config_contents = tokio::fs::read_to_string(&config_path) + .await + .expect("read config.toml"); + let config_toml: ConfigToml = toml::from_str(&config_contents).expect("parse config.toml"); + + assert_eq!( + ConfigToml { + model: Some("o4-mini".to_string()), + model_reasoning_effort: Some(ReasoningEffort::High), + ..Default::default() + }, + config_toml, + ); +} diff --git a/codex-rs/protocol-ts/src/lib.rs b/codex-rs/protocol-ts/src/lib.rs index 6fda7074fc..967d9b1fcc 100644 --- a/codex-rs/protocol-ts/src/lib.rs +++ b/codex-rs/protocol-ts/src/lib.rs @@ -40,6 +40,7 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { codex_protocol::mcp_protocol::ApplyPatchApprovalResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ExecCommandApprovalResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::GetUserSavedConfigResponse::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::SetDefaultModelResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::GetUserAgentResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::UserInfoResponse::export_all_to(out_dir)?; diff --git a/codex-rs/protocol/src/mcp_protocol.rs b/codex-rs/protocol/src/mcp_protocol.rs index a382b8bc7a..42f38d46bd 100644 --- a/codex-rs/protocol/src/mcp_protocol.rs +++ b/codex-rs/protocol/src/mcp_protocol.rs @@ -153,6 +153,11 @@ pub enum ClientRequest { #[serde(rename = "id")] request_id: RequestId, }, + SetDefaultModel { + #[serde(rename = "id")] + request_id: RequestId, + params: SetDefaultModelParams, + }, GetUserAgent { #[serde(rename = "id")] request_id: RequestId, @@ -416,6 +421,19 @@ pub struct GetUserSavedConfigResponse { pub config: UserSavedConfig, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct SetDefaultModelParams { + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct SetDefaultModelResponse {} + /// UserSavedConfig contains a subset of the config. It is meant to expose mcp /// client-configurable settings that can be specified in the NewConversation /// and SendUserTurn requests. From 90965fbc848b3795105f80fc3f5fa70f4115fa45 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 12 Sep 2025 08:44:44 -0700 Subject: [PATCH 15/18] chore: add `just test`, which runs `cargo nextest` (#3508) Since I can never seem to remember to add `--no-fail-fast` when running `cargo nextest run`, let's just create an alias for it. --- codex-rs/justfile | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/codex-rs/justfile b/codex-rs/justfile index e47ad1a2bd..850737efd6 100644 --- a/codex-rs/justfile +++ b/codex-rs/justfile @@ -31,6 +31,13 @@ install: rustup show active-toolchain cargo fetch +# Run `cargo nextest` since it's faster than `cargo test`, though including +# --no-fail-fast is important to ensure all tests are run. +# +# Run `cargo install cargo-nextest` if you don't have it installed. +test: + cargo nextest run --no-fail-fast + # Run the MCP server mcp-server-run *args: cargo run -p codex-mcp-server -- "$@" From f805d179304bd5934cf141f81299dbbc16218f1c Mon Sep 17 00:00:00 2001 From: Charlie Weems Date: Fri, 12 Sep 2025 09:04:49 -0700 Subject: [PATCH 16/18] MCP Documentation Changes Requests in Code Review (#3507) Add in review changes from @bolinfest that were dropped due to auto-merge (#3345). --- docs/advanced.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/advanced.md b/docs/advanced.md index 38fbd0db45..71ede3133a 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -54,22 +54,22 @@ Send a `tools/list` request and you will see that there are two tools available: Property | Type | Description -------------------|----------|---------------------------------------------------------------------------------------------------------- -approval-policy | string | Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `never`. -base-instructions | string | The set of instructions to use instead of the default ones. -config | object | Individual [config settings](https://github.com/openai/codex/blob/main/docs/config.md#config) that will override what is in CODEX_HOME/config.toml. -cwd | string | Working directory for the session. If relative, resolved against the server process's current directory. -include-plan-tool | boolean | Whether to include the plan tool in the conversation. -model | string | Optional override for the model name (e.g. "o3", "o4-mini"). -profile | string | Configuration profile from config.toml to specify default options. -**prompt** (required) | string | The initial user prompt to start the Codex conversation. -sandbox | string | Sandbox mode: `read-only`, `workspace-write`, or `danger-full-access`. +**`prompt`** (required) | string | The initial user prompt to start the Codex conversation. +`approval-policy` | string | Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `never`. +`base-instructions` | string | The set of instructions to use instead of the default ones. +`config` | object | Individual [config settings](https://github.com/openai/codex/blob/main/docs/config.md#config) that will override what is in `$CODEX_HOME/config.toml`. +`cwd` | string | Working directory for the session. If relative, resolved against the server process's current directory. +`include-plan-tool` | boolean | Whether to include the plan tool in the conversation. +`model` | string | Optional override for the model name (e.g. `o3`, `o4-mini`). +`profile` | string | Configuration profile from `config.toml` to specify default options. +`sandbox` | string | Sandbox mode: `read-only`, `workspace-write`, or `danger-full-access`. -**`codex-reply`** - Continue a Codex session by providing the session id and prompt. The `codex-reply` tool takes the following properties: +**`codex-reply`** - Continue a Codex session by providing the conversation id and prompt. The `codex-reply` tool takes the following properties: Property | Type | Description -----------|--------|--------------------------------------------------------------- -**prompt** (required) | string | The next user prompt to continue the Codex conversation. -**conversationId** (required) | string | The id of the conversation to continue. +**`prompt`** (required) | string | The next user prompt to continue the Codex conversation. +**`conversationId`** (required) | string | The id of the conversation to continue. ### Trying it Out > [!TIP] From ba6af23cb6ad59c150d233570a6930a97c2916ad Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 12 Sep 2025 12:05:57 -0400 Subject: [PATCH 17/18] Add spacing to timer duration formats (#3471) image --- codex-rs/common/src/elapsed.rs | 18 ++++++++++++------ codex-rs/tui/src/status_indicator_widget.rs | 20 ++++++++++---------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/codex-rs/common/src/elapsed.rs b/codex-rs/common/src/elapsed.rs index 3cc3d547ce..297832b1ec 100644 --- a/codex-rs/common/src/elapsed.rs +++ b/codex-rs/common/src/elapsed.rs @@ -2,7 +2,7 @@ use std::time::Duration; use std::time::Instant; /// Returns a string representing the elapsed time since `start_time` like -/// "1m15s" or "1.50s". +/// "1m 15s" or "1.50s". pub fn format_elapsed(start_time: Instant) -> String { format_duration(start_time.elapsed()) } @@ -12,7 +12,7 @@ pub fn format_elapsed(start_time: Instant) -> String { /// Formatting rules: /// * < 1 s -> "{milli}ms" /// * < 60 s -> "{sec:.2}s" (two decimal places) -/// * >= 60 s -> "{min}m{sec:02}s" +/// * >= 60 s -> "{min}m {sec:02}s" pub fn format_duration(duration: Duration) -> String { let millis = duration.as_millis() as i64; format_elapsed_millis(millis) @@ -26,7 +26,7 @@ fn format_elapsed_millis(millis: i64) -> String { } else { let minutes = millis / 60_000; let seconds = (millis % 60_000) / 1000; - format!("{minutes}m{seconds:02}s") + format!("{minutes}m {seconds:02}s") } } @@ -61,12 +61,18 @@ mod tests { fn test_format_duration_minutes() { // Durations ≥ 1 minute should be printed mmss. let dur = Duration::from_millis(75_000); // 1m15s - assert_eq!(format_duration(dur), "1m15s"); + assert_eq!(format_duration(dur), "1m 15s"); let dur_exact = Duration::from_millis(60_000); // 1m0s - assert_eq!(format_duration(dur_exact), "1m00s"); + assert_eq!(format_duration(dur_exact), "1m 00s"); let dur_long = Duration::from_millis(3_601_000); - assert_eq!(format_duration(dur_long), "60m01s"); + assert_eq!(format_duration(dur_long), "60m 01s"); + } + + #[test] + fn test_format_duration_one_hour_has_space() { + let dur_hour = Duration::from_millis(3_600_000); + assert_eq!(format_duration(dur_hour), "60m 00s"); } } diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 5b2fe720c3..bf392b6997 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -32,7 +32,7 @@ pub(crate) struct StatusIndicatorWidget { } // Format elapsed seconds into a compact human-friendly form used by the status line. -// Examples: 0s, 59s, 1m00s, 59m59s, 1h00m00s, 2h03m09s +// Examples: 0s, 59s, 1m 00s, 59m 59s, 1h 00m 00s, 2h 03m 09s fn fmt_elapsed_compact(elapsed_secs: u64) -> String { if elapsed_secs < 60 { return format!("{elapsed_secs}s"); @@ -40,12 +40,12 @@ fn fmt_elapsed_compact(elapsed_secs: u64) -> String { if elapsed_secs < 3600 { let minutes = elapsed_secs / 60; let seconds = elapsed_secs % 60; - return format!("{minutes}m{seconds:02}s"); + return format!("{minutes}m {seconds:02}s"); } let hours = elapsed_secs / 3600; let minutes = (elapsed_secs % 3600) / 60; let seconds = elapsed_secs % 60; - format!("{hours}h{minutes:02}m{seconds:02}s") + format!("{hours}h {minutes:02}m {seconds:02}s") } impl StatusIndicatorWidget { @@ -209,13 +209,13 @@ mod tests { assert_eq!(fmt_elapsed_compact(0), "0s"); assert_eq!(fmt_elapsed_compact(1), "1s"); assert_eq!(fmt_elapsed_compact(59), "59s"); - assert_eq!(fmt_elapsed_compact(60), "1m00s"); - assert_eq!(fmt_elapsed_compact(61), "1m01s"); - assert_eq!(fmt_elapsed_compact(3 * 60 + 5), "3m05s"); - assert_eq!(fmt_elapsed_compact(59 * 60 + 59), "59m59s"); - assert_eq!(fmt_elapsed_compact(3600), "1h00m00s"); - assert_eq!(fmt_elapsed_compact(3600 + 60 + 1), "1h01m01s"); - assert_eq!(fmt_elapsed_compact(25 * 3600 + 2 * 60 + 3), "25h02m03s"); + assert_eq!(fmt_elapsed_compact(60), "1m 00s"); + assert_eq!(fmt_elapsed_compact(61), "1m 01s"); + assert_eq!(fmt_elapsed_compact(3 * 60 + 5), "3m 05s"); + assert_eq!(fmt_elapsed_compact(59 * 60 + 59), "59m 59s"); + assert_eq!(fmt_elapsed_compact(3600), "1h 00m 00s"); + assert_eq!(fmt_elapsed_compact(3600 + 60 + 1), "1h 01m 01s"); + assert_eq!(fmt_elapsed_compact(25 * 3600 + 2 * 60 + 3), "25h 02m 03s"); } #[test] From bba567cee91c86d7e0f911587a91ded7a705b70a Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 12 Sep 2025 10:38:12 -0700 Subject: [PATCH 18/18] bug: fix model save (#3525) Fix those 2 behaviors: 1. The model does not get saved if we don't CTRL + S 2. The reasoning effort get saved --- codex-rs/core/src/codex.rs | 18 ---- codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/model_overrides.rs | 92 ++++++++++++++++++++ codex-rs/tui/src/app.rs | 81 ++++++++++++++++- codex-rs/tui/src/chatwidget.rs | 2 +- codex-rs/tui/src/chatwidget/tests.rs | 11 +++ 6 files changed, 185 insertions(+), 20 deletions(-) create mode 100644 codex-rs/core/tests/suite/model_overrides.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index f77e6a40c3..79bf72ec98 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -9,9 +9,6 @@ use std::sync::atomic::AtomicU64; use std::time::Duration; use crate::AuthManager; -use crate::config_edit::CONFIG_KEY_EFFORT; -use crate::config_edit::CONFIG_KEY_MODEL; -use crate::config_edit::persist_non_null_overrides; use crate::event_mapping::map_response_item_to_event_messages; use async_channel::Receiver; use async_channel::Sender; @@ -1170,21 +1167,6 @@ async fn submission_loop( turn_context = Arc::new(new_turn_context); // Optionally persist changes to model / effort - let effort_str = effort.map(|_| effective_effort.to_string()); - - if let Err(e) = persist_non_null_overrides( - &config.codex_home, - config.active_profile.as_deref(), - &[ - (&[CONFIG_KEY_MODEL], model.as_deref()), - (&[CONFIG_KEY_EFFORT], effort_str.as_deref()), - ], - ) - .await - { - warn!("failed to persist overrides: {e:#}"); - } - if cwd.is_some() || approval_policy.is_some() || sandbox_policy.is_some() { sess.record_conversation_items(&[ResponseItem::from(EnvironmentContext::new( cwd, diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index aabf83bbe3..10661413a4 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -7,6 +7,7 @@ mod exec; mod exec_stream_events; mod fork_conversation; mod live_cli; +mod model_overrides; mod prompt_caching; mod seatbelt; mod stream_error_allows_next_turn; diff --git a/codex-rs/core/tests/suite/model_overrides.rs b/codex-rs/core/tests/suite/model_overrides.rs new file mode 100644 index 0000000000..8bd3a8564d --- /dev/null +++ b/codex-rs/core/tests/suite/model_overrides.rs @@ -0,0 +1,92 @@ +use codex_core::CodexAuth; +use codex_core::ConversationManager; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_core::protocol_config_types::ReasoningEffort; +use core_test_support::load_default_config_for_test; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +const CONFIG_TOML: &str = "config.toml"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn override_turn_context_does_not_persist_when_config_exists() { + let codex_home = TempDir::new().unwrap(); + let config_path = codex_home.path().join(CONFIG_TOML); + let initial_contents = "model = \"gpt-4o\"\n"; + tokio::fs::write(&config_path, initial_contents) + .await + .expect("seed config.toml"); + + let mut config = load_default_config_for_test(&codex_home); + config.model = "gpt-4o".to_string(); + + let conversation_manager = + ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let codex = conversation_manager + .new_conversation(config) + .await + .expect("create conversation") + .conversation; + + codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: Some("o3".to_string()), + effort: Some(ReasoningEffort::High), + summary: None, + }) + .await + .expect("submit override"); + + codex.submit(Op::Shutdown).await.expect("request shutdown"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; + + let contents = tokio::fs::read_to_string(&config_path) + .await + .expect("read config.toml after override"); + assert_eq!(contents, initial_contents); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn override_turn_context_does_not_create_config_file() { + let codex_home = TempDir::new().unwrap(); + let config_path = codex_home.path().join(CONFIG_TOML); + assert!( + !config_path.exists(), + "test setup should start without config" + ); + + let config = load_default_config_for_test(&codex_home); + + let conversation_manager = + ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let codex = conversation_manager + .new_conversation(config) + .await + .expect("create conversation") + .conversation; + + codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: Some("o3".to_string()), + effort: Some(ReasoningEffort::Medium), + summary: None, + }) + .await + .expect("submit override"); + + codex.submit(Op::Shutdown).await.expect("request shutdown"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; + + assert!( + !config_path.exists(), + "override should not create config.toml" + ); +} diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 5680c2265c..d881927605 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -14,6 +14,7 @@ use codex_core::config::Config; use codex_core::config::persist_model_selection; use codex_core::model_family::find_family_for_model; use codex_core::protocol::TokenUsage; +use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use crossterm::event::KeyCode; @@ -297,7 +298,7 @@ impl App { self.chat_widget.apply_file_search_result(query, matches); } AppEvent::UpdateReasoningEffort(effort) => { - self.chat_widget.set_reasoning_effort(effort); + self.on_update_reasoning_effort(effort); } AppEvent::UpdateModel(model) => { self.chat_widget.set_model(model.clone()); @@ -336,6 +337,20 @@ impl App { } } + fn on_update_reasoning_effort(&mut self, effort: ReasoningEffortConfig) { + let changed = self.config.model_reasoning_effort != effort; + self.chat_widget.set_reasoning_effort(effort); + self.config.model_reasoning_effort = effort; + if changed { + let show_hint = self.model_saved_to_profile || self.model_saved_to_global; + self.model_saved_to_profile = false; + self.model_saved_to_global = false; + if show_hint { + self.show_model_save_hint(); + } + } + } + async fn persist_model_shortcut(&mut self) { enum SaveScope<'a> { Profile(&'a str), @@ -476,3 +491,67 @@ impl App { }; } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_backtrack::BacktrackState; + use crate::chatwidget::tests::make_chatwidget_manual_with_sender; + use crate::file_search::FileSearchManager; + use codex_core::CodexAuth; + use codex_core::ConversationManager; + use ratatui::text::Line; + use std::sync::Arc; + use std::sync::atomic::AtomicBool; + + fn make_test_app() -> App { + let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender(); + let config = chat_widget.config_ref().clone(); + + let server = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key( + "Test API Key", + ))); + let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + + App { + server, + app_event_tx, + chat_widget, + config, + active_profile: None, + model_saved_to_profile: false, + model_saved_to_global: false, + file_search, + transcript_lines: Vec::>::new(), + overlay: None, + deferred_history_lines: Vec::new(), + has_emitted_history_lines: false, + enhanced_keys_supported: false, + commit_anim_running: Arc::new(AtomicBool::new(false)), + backtrack: BacktrackState::default(), + } + } + + #[test] + fn update_reasoning_effort_updates_config_and_resets_flags() { + let mut app = make_test_app(); + app.model_saved_to_profile = true; + app.model_saved_to_global = true; + app.config.model_reasoning_effort = ReasoningEffortConfig::Medium; + app.chat_widget + .set_reasoning_effort(ReasoningEffortConfig::Medium); + + app.on_update_reasoning_effort(ReasoningEffortConfig::High); + + assert_eq!( + app.config.model_reasoning_effort, + ReasoningEffortConfig::High + ); + assert_eq!( + app.chat_widget.config_ref().model_reasoning_effort, + ReasoningEffortConfig::High + ); + assert!(!app.model_saved_to_profile); + assert!(!app.model_saved_to_global); + } +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f9987a68b3..1f33facdbf 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1451,4 +1451,4 @@ fn extract_first_bold(s: &str) -> Option { } #[cfg(test)] -mod tests; +pub(crate) mod tests; diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index dc1da26eb4..298fc84951 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -247,6 +247,17 @@ fn make_chatwidget_manual() -> ( (widget, rx, op_rx) } +pub(crate) fn make_chatwidget_manual_with_sender() -> ( + ChatWidget, + AppEventSender, + tokio::sync::mpsc::UnboundedReceiver, + tokio::sync::mpsc::UnboundedReceiver, +) { + let (widget, rx, op_rx) = make_chatwidget_manual(); + let app_event_tx = widget.app_event_tx.clone(); + (widget, app_event_tx, rx, op_rx) +} + fn drain_insert_history( rx: &mut tokio::sync::mpsc::UnboundedReceiver, ) -> Vec>> {