From 951a3896549c89187bdf04c848042d995e9c3f42 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 26 Feb 2026 14:37:38 -0800 Subject: [PATCH 1/7] Allow clients not to send summary as an option (#12950) Summary is a required parameter on UserTurn. Ideally we'd like the core to decide the appropriate summary level. Make the summary optional and don't send it when not needed. --- .../app-server/src/codex_message_processor.rs | 2 +- codex-rs/core/src/codex.rs | 2 +- codex-rs/core/tests/common/test_codex.rs | 3 +- codex-rs/core/tests/suite/apply_patch_cli.rs | 15 ++-- codex-rs/core/tests/suite/approvals.rs | 3 +- codex-rs/core/tests/suite/client.rs | 77 ++++++++++++++++++- .../tests/suite/collaboration_instructions.rs | 18 +++-- codex-rs/core/tests/suite/compact.rs | 15 ++-- codex-rs/core/tests/suite/exec_policy.rs | 5 +- codex-rs/core/tests/suite/image_rollout.rs | 5 +- codex-rs/core/tests/suite/items.rs | 10 +-- codex-rs/core/tests/suite/json_result.rs | 3 +- codex-rs/core/tests/suite/live_reload.rs | 3 +- codex-rs/core/tests/suite/model_switching.rs | 16 ++-- .../core/tests/suite/model_visible_layout.rs | 11 ++- codex-rs/core/tests/suite/models_cache_ttl.rs | 2 +- .../core/tests/suite/models_etag_responses.rs | 3 +- codex-rs/core/tests/suite/personality.rs | 26 +++---- codex-rs/core/tests/suite/prompt_caching.rs | 10 +-- codex-rs/core/tests/suite/remote_models.rs | 22 ++++-- .../core/tests/suite/request_permissions.rs | 3 +- .../core/tests/suite/request_user_input.rs | 5 +- codex-rs/core/tests/suite/rmcp_client.rs | 12 +-- .../tests/suite/safety_check_downgrade.rs | 9 +-- codex-rs/core/tests/suite/shell_snapshot.rs | 9 +-- codex-rs/core/tests/suite/skill_approval.rs | 3 +- codex-rs/core/tests/suite/skills.rs | 2 +- codex-rs/core/tests/suite/tool_harness.rs | 11 ++- codex-rs/core/tests/suite/tool_parallelism.rs | 5 +- codex-rs/core/tests/suite/truncation.rs | 3 +- codex-rs/core/tests/suite/unified_exec.rs | 53 +++++++------ codex-rs/core/tests/suite/user_shell_cmd.rs | 3 +- codex-rs/core/tests/suite/view_image.rs | 16 ++-- .../core/tests/suite/websocket_fallback.rs | 3 +- codex-rs/exec/src/lib.rs | 4 +- codex-rs/protocol/src/protocol.rs | 6 +- codex-rs/tui/src/chatwidget.rs | 5 +- codex-rs/tui/src/chatwidget/tests.rs | 5 +- 38 files changed, 233 insertions(+), 175 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0d3b98f03c..85dc08a5f9 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -5160,7 +5160,7 @@ impl CodexMessageProcessor { sandbox_policy, model, effort, - summary, + summary: Some(summary), final_output_json_schema: output_schema, collaboration_mode: None, personality: None, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a9d5d60f76..bb071393dd 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3924,7 +3924,7 @@ mod handlers { sandbox_policy: Some(sandbox_policy), windows_sandbox_level: None, collaboration_mode, - reasoning_summary: Some(summary), + reasoning_summary: summary, final_output_json_schema: Some(final_output_json_schema), personality, }, diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 173db5ca77..4253b4b686 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -11,7 +11,6 @@ use codex_core::ThreadManager; use codex_core::built_in_model_providers; use codex_core::config::Config; use codex_core::features::Feature; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; @@ -300,7 +299,7 @@ impl TestCodex { sandbox_policy, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 45494124e5..7f20f468d9 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -12,7 +12,6 @@ use std::sync::atomic::AtomicI32; use std::sync::atomic::Ordering; use codex_core::features::Feature; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; @@ -312,7 +311,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -907,7 +906,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -987,7 +986,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -1137,7 +1136,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -1200,7 +1199,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -1271,7 +1270,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -1342,7 +1341,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 34934b3d4b..a0530809db 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -13,7 +13,6 @@ use codex_core::sandboxing::SandboxPermissions; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::approvals::NetworkPolicyAmendment; use codex_protocol::approvals::NetworkPolicyRuleAction; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -551,7 +550,7 @@ async fn submit_turn( sandbox_policy, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index b4b7ef3870..082b41a373 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -981,9 +981,11 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re sandbox_policy: config.permissions.sandbox_policy.get().clone(), model: session_configured.model.clone(), effort: Some(ReasoningEffort::Low), - summary: config - .model_reasoning_summary - .unwrap_or(ReasoningSummary::Auto), + summary: Some( + config + .model_reasoning_summary + .unwrap_or(ReasoningSummary::Auto), + ), collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, @@ -1049,6 +1051,75 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() -> anyhow::Result<()> +{ + skip_if_no_network!(Ok(())); + let server = MockServer::start().await; + + let resp_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + + let mut model_catalog: ModelsResponse = + serde_json::from_str(include_str!("../../models.json")).expect("valid models.json"); + let model = model_catalog + .models + .iter_mut() + .find(|model| model.slug == "gpt-5.1") + .expect("gpt-5.1 exists in bundled models.json"); + model.supports_reasoning_summaries = true; + model.default_reasoning_summary = ReasoningSummary::Detailed; + + let TestCodex { + codex, + config, + session_configured, + .. + } = test_codex() + .with_model("gpt-5.1") + .with_config(move |config| { + config.model_catalog = Some(model_catalog); + }) + .build(&server) + .await?; + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + cwd: config.cwd.clone(), + approval_policy: config.permissions.approval_policy.value(), + sandbox_policy: config.permissions.sandbox_policy.get().clone(), + model: session_configured.model, + effort: None, + summary: Some(ReasoningSummary::Concise), + collaboration_mode: None, + final_output_json_schema: None, + personality: None, + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request_body = resp_mock.single_request().body_json(); + + pretty_assertions::assert_eq!( + request_body + .get("reasoning") + .and_then(|reasoning| reasoning.get("summary")) + .and_then(|value| value.as_str()), + Some("concise") + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 7f5732b3a3..b1dbd2d660 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -169,10 +169,11 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { sandbox_policy: test.config.permissions.sandbox_policy.get().clone(), model: test.session_configured.model.clone(), effort: None, - summary: test - .config - .model_reasoning_summary - .unwrap_or(codex_protocol::config_types::ReasoningSummary::Auto), + summary: Some( + test.config + .model_reasoning_summary + .unwrap_or(codex_protocol::config_types::ReasoningSummary::Auto), + ), collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, personality: None, @@ -278,10 +279,11 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu sandbox_policy: test.config.permissions.sandbox_policy.get().clone(), model: test.session_configured.model.clone(), effort: None, - summary: test - .config - .model_reasoning_summary - .unwrap_or(codex_protocol::config_types::ReasoningSummary::Auto), + summary: Some( + test.config + .model_reasoning_summary + .unwrap_or(codex_protocol::config_types::ReasoningSummary::Auto), + ), collaboration_mode: Some(turn_mode), final_output_json_schema: None, personality: None, diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 58a7095aa1..9e376ce494 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -5,7 +5,6 @@ use codex_core::built_in_model_providers; use codex_core::compact::SUMMARIZATION_PROMPT; use codex_core::compact::SUMMARY_PREFIX; use codex_core::config::Config; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::items::TurnItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; @@ -1659,7 +1658,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { sandbox_policy: SandboxPolicy::DangerFullAccess, model: resumed.session_configured.model.clone(), effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -1748,7 +1747,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { sandbox_policy: SandboxPolicy::DangerFullAccess, model: previous_model.to_string(), effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -1771,7 +1770,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { sandbox_policy: SandboxPolicy::DangerFullAccess, model: next_model.to_string(), effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -1880,7 +1879,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { sandbox_policy: SandboxPolicy::DangerFullAccess, model: previous_model.to_string(), effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -1927,7 +1926,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { sandbox_policy: SandboxPolicy::DangerFullAccess, model: next_model.to_string(), effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -3128,7 +3127,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch sandbox_policy: SandboxPolicy::DangerFullAccess, model: previous_model.to_string(), effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -3151,7 +3150,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch sandbox_policy: SandboxPolicy::DangerFullAccess, model: next_model.to_string(), effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 3fd06d83c7..52717f2880 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -4,7 +4,6 @@ use anyhow::Result; use codex_core::features::Feature; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -55,7 +54,7 @@ async fn submit_user_turn( sandbox_policy, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode, personality: None, }) @@ -134,7 +133,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 2349d9f5fa..c8f1f2eaf1 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -1,5 +1,4 @@ use anyhow::Context; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AskForApproval; @@ -126,7 +125,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -208,7 +207,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 0d93278af1..dfb5ac88b4 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -377,7 +377,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, - summary: codex_protocol::config_types::ReasoningSummary::Auto, + summary: None, collaboration_mode: Some(collaboration_mode), personality: None, }) @@ -452,7 +452,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, - summary: codex_protocol::config_types::ReasoningSummary::Auto, + summary: None, collaboration_mode: Some(collaboration_mode), personality: None, }) @@ -559,7 +559,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, - summary: codex_protocol::config_types::ReasoningSummary::Auto, + summary: None, collaboration_mode: Some(collaboration_mode), personality: None, }) @@ -744,7 +744,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, - summary: codex_protocol::config_types::ReasoningSummary::Auto, + summary: None, collaboration_mode: Some(collaboration_mode), personality: None, }) @@ -856,7 +856,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, - summary: codex_protocol::config_types::ReasoningSummary::Auto, + summary: None, collaboration_mode: Some(collaboration_mode), personality: None, }) diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index e32865e0c7..12c9845a75 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -1,6 +1,5 @@ #![cfg(not(target_os = "windows"))] -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; @@ -84,7 +83,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 3ea0a3cbe9..8ee4c56dda 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -7,7 +7,6 @@ use std::time::Duration; use anyhow::Result; use codex_core::config::ProjectConfig; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::TrustLevel; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -65,7 +64,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 8bf189eb42..159c1a5cab 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -58,7 +58,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -91,7 +91,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( sandbox_policy: SandboxPolicy::new_read_only_policy(), model: next_model.to_string(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -146,7 +146,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -179,7 +179,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul sandbox_policy: SandboxPolicy::new_read_only_policy(), model: next_model.to_string(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -294,7 +294,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< sandbox_policy: SandboxPolicy::new_read_only_policy(), model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -313,7 +313,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< sandbox_policy: SandboxPolicy::new_read_only_policy(), model: text_model_slug.to_string(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -471,7 +471,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< sandbox_policy: SandboxPolicy::new_read_only_policy(), model: large_model_slug.to_string(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -526,7 +526,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< sandbox_policy: SandboxPolicy::new_read_only_policy(), model: smaller_model_slug.to_string(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index c8a5a978ff..14503b3f5b 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -6,7 +6,6 @@ use std::sync::Arc; use anyhow::Result; use codex_core::config::types::Personality; use codex_core::features::Feature; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; @@ -122,7 +121,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -144,7 +143,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: Some(Personality::Friendly), }) @@ -221,7 +220,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -243,7 +242,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -348,7 +347,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul sandbox_policy: SandboxPolicy::new_read_only_policy(), model: resumed.session_configured.model.clone(), effort: resumed.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: Some(Personality::Friendly), }) diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 1abe6e5dc4..2ecf157512 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -99,7 +99,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: test.session_configured.model.clone(), effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index 7c40bec987..1cdc549012 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use anyhow::Result; use codex_core::CodexAuth; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -106,7 +105,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index d2e7fadd45..09d0137995 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -97,7 +97,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -143,7 +143,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -196,7 +196,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -255,7 +255,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -302,7 +302,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -336,7 +336,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -398,7 +398,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -432,7 +432,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -504,7 +504,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -538,7 +538,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -647,7 +647,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow sandbox_policy: SandboxPolicy::new_read_only_policy(), model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: Some(Personality::Friendly), }) @@ -758,7 +758,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - sandbox_policy: SandboxPolicy::new_read_only_policy(), model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -792,7 +792,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - sandbox_policy: SandboxPolicy::new_read_only_policy(), model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 72d1b54ce3..ff4adcb091 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -646,7 +646,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res sandbox_policy: new_policy.clone(), model: "o3".to_string(), effort: Some(ReasoningEffort::High), - summary: ReasoningSummary::Detailed, + summary: Some(ReasoningSummary::Detailed), collaboration_mode: None, final_output_json_schema: None, personality: None, @@ -761,7 +761,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a sandbox_policy: default_sandbox_policy.clone(), model: default_model.clone(), effort: default_effort, - summary: default_summary.unwrap_or(ReasoningSummary::Auto), + summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), collaboration_mode: None, final_output_json_schema: None, personality: None, @@ -780,7 +780,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a sandbox_policy: default_sandbox_policy.clone(), model: default_model.clone(), effort: default_effort, - summary: default_summary.unwrap_or(ReasoningSummary::Auto), + summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), collaboration_mode: None, final_output_json_schema: None, personality: None, @@ -875,7 +875,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu sandbox_policy: default_sandbox_policy.clone(), model: default_model, effort: default_effort, - summary: default_summary.unwrap_or(ReasoningSummary::Auto), + summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), collaboration_mode: None, final_output_json_schema: None, personality: None, @@ -894,7 +894,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu sandbox_policy: SandboxPolicy::DangerFullAccess, model: "o3".to_string(), effort: Some(ReasoningEffort::High), - summary: ReasoningSummary::Detailed, + summary: Some(ReasoningSummary::Detailed), collaboration_mode: None, final_output_json_schema: None, personality: None, diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 748c9146a0..c89d5341da 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -139,6 +139,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( }, ]; remote_model.supports_reasoning_summaries = true; + remote_model.default_reasoning_summary = ReasoningSummary::Detailed; mount_models_once( &server, ModelsResponse { @@ -175,9 +176,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( sandbox_policy: config.permissions.sandbox_policy.get().clone(), model: requested_model.to_string(), effort: None, - summary: config - .model_reasoning_summary - .unwrap_or(ReasoningSummary::Auto), + summary: None, collaboration_mode: None, personality: None, }) @@ -191,8 +190,13 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( .get("reasoning") .and_then(|reasoning| reasoning.get("effort")) .and_then(|value| value.as_str()); + let reasoning_summary = body + .get("reasoning") + .and_then(|reasoning| reasoning.get("summary")) + .and_then(|value| value.as_str()); assert_eq!(body["model"].as_str(), Some(requested_model)); assert_eq!(reasoning_effort, Some("high")); + assert_eq!(reasoning_summary, Some("detailed")); Ok(()) } @@ -229,9 +233,11 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() sandbox_policy: config.permissions.sandbox_policy.get().clone(), model: requested_model.to_string(), effort: None, - summary: config - .model_reasoning_summary - .unwrap_or(ReasoningSummary::Auto), + summary: Some( + config + .model_reasoning_summary + .unwrap_or(ReasoningSummary::Auto), + ), collaboration_mode: None, personality: None, }) @@ -384,7 +390,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: REMOTE_MODEL_SLUG.to_string(), effort: None, - summary: ReasoningSummary::Auto, + summary: Some(ReasoningSummary::Auto), collaboration_mode: None, personality: None, }) @@ -596,7 +602,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: model.to_string(), effort: None, - summary: ReasoningSummary::Auto, + summary: Some(ReasoningSummary::Auto), collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index cda482079a..43ed8941d1 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -4,7 +4,6 @@ use anyhow::Result; use codex_core::config::Constrained; use codex_core::features::Feature; use codex_core::sandboxing::SandboxPermissions; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; @@ -111,7 +110,7 @@ async fn submit_turn( sandbox_policy, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 64f2e0b6db..1f20f5dfdf 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -5,7 +5,6 @@ use std::collections::HashMap; use codex_core::features::Feature; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -138,7 +137,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: Some(CollaborationMode { mode, settings: Settings { @@ -254,7 +253,7 @@ where sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: Some(collaboration_mode), personality: None, }) diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index fcb0d49a14..cf56a1bb2d 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -128,7 +128,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -295,7 +295,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -493,7 +493,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re sandbox_policy: SandboxPolicy::new_read_only_policy(), model: text_only_model_slug.to_string(), effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -605,7 +605,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -764,7 +764,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -983,7 +983,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 247cfdaedc..263e4b96dc 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AskForApproval; @@ -49,7 +48,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> sandbox_policy: SandboxPolicy::DangerFullAccess, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -146,7 +145,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request sandbox_policy: SandboxPolicy::DangerFullAccess, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -230,7 +229,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul sandbox_policy: SandboxPolicy::DangerFullAccess, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -278,7 +277,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> sandbox_policy: SandboxPolicy::DangerFullAccess, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 3be9d2b579..a6f8ada10f 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -1,6 +1,5 @@ use anyhow::Result; use codex_core::features::Feature; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandBeginEvent; @@ -162,7 +161,7 @@ async fn run_snapshot_command_with_options( sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -248,7 +247,7 @@ async fn run_shell_command_snapshot_with_options( sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -317,7 +316,7 @@ async fn run_tool_turn_on_harness( sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -535,7 +534,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index c2c729be33..a51c69bcfa 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -4,7 +4,6 @@ use anyhow::Result; use codex_core::config::Config; use codex_core::features::Feature; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; @@ -59,7 +58,7 @@ async fn submit_turn_with_policies( sandbox_policy, model: test.session_configured.model.clone(), effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index f681ca1b64..2f786590b1 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -77,7 +77,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: codex_protocol::config_types::ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 507c8eb068..13191892ce 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -4,7 +4,6 @@ use std::fs; use assert_matches::assert_matches; use codex_core::features::Feature; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::plan_tool::StepStatus; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -89,7 +88,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -158,7 +157,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -237,7 +236,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -328,7 +327,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -427,7 +426,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index 357dcff0fd..1678e58349 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -5,7 +5,6 @@ use std::fs; use std::time::Duration; use std::time::Instant; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; @@ -46,7 +45,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -361,7 +360,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 8ede6c621f..eedfc02bbb 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -5,7 +5,6 @@ use anyhow::Context; use anyhow::Result; use codex_core::config::types::McpServerConfig; use codex_core::config::types::McpServerTransportConfig; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; @@ -488,7 +487,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 705e415620..55bc19e680 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -6,7 +6,6 @@ use std::sync::OnceLock; use anyhow::Context; use anyhow::Result; use codex_core::features::Feature; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandSource; @@ -209,7 +208,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -338,7 +337,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -416,7 +415,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -497,7 +496,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -590,7 +589,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -665,7 +664,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -741,7 +740,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -871,7 +870,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -1008,7 +1007,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -1168,7 +1167,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -1266,7 +1265,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -1384,7 +1383,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -1474,7 +1473,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -1555,7 +1554,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -1686,7 +1685,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -1854,7 +1853,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -1931,7 +1930,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -2019,7 +2018,7 @@ async fn unified_exec_interrupt_terminates_long_running_session() -> Result<()> sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -2116,7 +2115,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -2251,7 +2250,7 @@ PY sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -2365,7 +2364,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -2461,7 +2460,7 @@ PY sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -2543,7 +2542,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -2647,7 +2646,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -2742,7 +2741,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -2877,7 +2876,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index c38b86e443..766d79abb8 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -1,6 +1,5 @@ use anyhow::Context; use codex_core::features::Feature; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandEndEvent; @@ -178,7 +177,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> sandbox_policy: SandboxPolicy::DangerFullAccess, model: fixture.session_configured.model.clone(), effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 6cf9f54d4e..c4f8ebec36 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -112,7 +112,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -214,7 +214,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -344,7 +344,7 @@ console.log(out.output?.body?.text ?? ""); sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -447,7 +447,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -522,7 +522,7 @@ async fn view_image_tool_placeholder_for_non_image_files() -> anyhow::Result<()> sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -614,7 +614,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -737,7 +737,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an sandbox_policy: SandboxPolicy::DangerFullAccess, model: model_slug.to_string(), effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) @@ -813,7 +813,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index 20c7b3b2f2..9ff5cbe001 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -1,6 +1,5 @@ use anyhow::Result; use codex_core::features::Feature; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; @@ -161,7 +160,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, - summary: ReasoningSummary::Auto, + summary: None, collaboration_mode: None, personality: None, }) diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 9b3403f2e9..e8ab6f8af7 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -377,7 +377,6 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result let default_approval_policy = config.permissions.approval_policy.value(); let default_sandbox_policy = config.permissions.sandbox_policy.get(); let default_effort = config.model_reasoning_effort; - let default_summary = config.model_reasoning_summary; // When --yolo (dangerously_bypass_approvals_and_sandbox) is set, also skip the git repo check // since the user is explicitly running in an externally sandboxed environment. @@ -560,8 +559,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result sandbox_policy: default_sandbox_policy.clone(), model: default_model, effort: default_effort, - summary: default_summary - .unwrap_or(codex_protocol::config_types::ReasoningSummary::Auto), + summary: None, final_output_json_schema: output_schema, collaboration_mode: None, personality: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index a401cddb3e..7a0c2fb5e2 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -185,7 +185,11 @@ pub enum Op { effort: Option, /// Will only be honored if the model is configured to use reasoning. - summary: ReasoningSummaryConfig, + /// + /// When omitted, the session keeps the current setting (which allows core to + /// fall back to the selected model's default on new sessions). + #[serde(default, skip_serializing_if = "Option::is_none")] + summary: Option, // The JSON schema to use for the final assistant message final_output_json_schema: Option, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 5c6c51b7bc..9e5f1d8d80 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4139,10 +4139,7 @@ impl ChatWidget { sandbox_policy: self.config.permissions.sandbox_policy.get().clone(), model: effective_mode.model().to_string(), effort: effective_mode.reasoning_effort(), - summary: self - .config - .model_reasoning_summary - .unwrap_or(codex_protocol::config_types::ReasoningSummary::Auto), + summary: None, final_output_json_schema: None, collaboration_mode, personality, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 48de3e3681..cbbd40ec93 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -751,8 +751,8 @@ async fn enter_with_only_remote_images_submits_user_turn() { chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - let items = match next_submit_op(&mut op_rx) { - Op::UserTurn { items, .. } => items, + let (items, summary) = match next_submit_op(&mut op_rx) { + Op::UserTurn { items, summary, .. } => (items, summary), other => panic!("expected Op::UserTurn, got {other:?}"), }; assert_eq!( @@ -761,6 +761,7 @@ async fn enter_with_only_remote_images_submits_user_turn() { image_url: remote_url.clone(), }] ); + assert_eq!(summary, None); assert!(chat.remote_image_urls().is_empty()); let mut user_cell = None; From fd719d3828dbf3218d36bf7c9908355367fb0a9e Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 26 Feb 2026 14:44:39 -0800 Subject: [PATCH 2/7] fix: sort codex features list alphabetically (#12944) ## Why `codex features list` currently prints features in declaration order from `codex_core::features::FEATURES`. That makes the output harder to scan when looking for a specific flag, and the order can change for reasons unrelated to the CLI. ## What changed - Sort the `codex features list` rows by feature key before printing them in `codex-rs/cli/src/main.rs`. - Add an integration test in `codex-rs/cli/tests/features.rs` that runs `codex features list` and asserts the feature-name column is alphabetized. ## Verification - Added `features_list_is_sorted_alphabetically_by_feature_name`. - Ran `cargo test -p codex-cli`. --- codex-rs/cli/src/main.rs | 1 + codex-rs/cli/tests/features.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 42ded11c06..c271fe0b9e 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -808,6 +808,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { stage_width = stage_width.max(stage.len()); rows.push((name, stage, enabled)); } + rows.sort_unstable_by_key(|(name, _, _)| *name); for (name, stage, enabled) in rows { println!("{name: Result { @@ -58,3 +59,33 @@ async fn features_enable_under_development_feature_prints_warning() -> Result<() Ok(()) } + +#[tokio::test] +async fn features_list_is_sorted_alphabetically_by_feature_name() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut cmd = codex_command(codex_home.path())?; + let output = cmd + .args(["features", "list"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let stdout = String::from_utf8(output)?; + + let actual_names = stdout + .lines() + .map(|line| { + line.split_once(" ") + .map(|(name, _)| name.trim_end().to_string()) + .expect("feature list output should contain aligned columns") + }) + .collect::>(); + let mut expected_names = actual_names.clone(); + expected_names.sort(); + + assert_eq!(actual_names, expected_names); + + Ok(()) +} From a0e86c69fe35e8ccfbed7ed87f07914d467ec488 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 26 Feb 2026 15:08:21 -0800 Subject: [PATCH 3/7] Add realtime audio device config (#12849) ## Summary - add top-level realtime audio config for microphone and speaker selection - apply configured devices when starting realtime capture and playback - keep missing-device behavior on the system default fallback path ## Validation - just write-config-schema - cargo test -p codex-core realtime_audio - cargo test -p codex-tui - just fix -p codex-core - just fix -p codex-tui - just fmt --------- Co-authored-by: Codex --- codex-rs/core/config.schema.json | 21 ++++ codex-rs/core/src/config/edit.rs | 68 +++++++++++++ codex-rs/core/src/config/mod.rs | 63 ++++++++++++ codex-rs/tui/src/audio_device.rs | 122 ++++++++++++++++++++++++ codex-rs/tui/src/chatwidget/realtime.rs | 9 +- codex-rs/tui/src/lib.rs | 7 +- codex-rs/tui/src/voice.rs | 26 ++--- 7 files changed, 299 insertions(+), 17 deletions(-) create mode 100644 codex-rs/tui/src/audio_device.rs diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b9986e65f4..01ac97ef26 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1179,6 +1179,18 @@ }, "type": "object" }, + "RealtimeAudioToml": { + "additionalProperties": false, + "properties": { + "microphone": { + "type": "string" + }, + "speaker": { + "type": "string" + } + }, + "type": "object" + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -1540,6 +1552,15 @@ "default": null, "description": "Settings for app-specific controls." }, + "audio": { + "allOf": [ + { + "$ref": "#/definitions/RealtimeAudioToml" + } + ], + "default": null, + "description": "Machine-local realtime audio device preferences used by realtime voice." + }, "background_terminal_max_timeout": { "description": "Maximum poll window for background terminal output (`write_stdin`), in milliseconds. Default: `300000` (5 minutes).", "format": "uint64", diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index fceb965998..ad404a9f83 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -839,6 +839,30 @@ impl ConfigEditsBuilder { self } + pub fn set_realtime_microphone(mut self, microphone: Option<&str>) -> Self { + let segments = vec!["audio".to_string(), "microphone".to_string()]; + match microphone { + Some(microphone) => self.edits.push(ConfigEdit::SetPath { + segments, + value: value(microphone), + }), + None => self.edits.push(ConfigEdit::ClearPath { segments }), + } + self + } + + pub fn set_realtime_speaker(mut self, speaker: Option<&str>) -> Self { + let segments = vec!["audio".to_string(), "speaker".to_string()]; + match speaker { + Some(speaker) => self.edits.push(ConfigEdit::SetPath { + segments, + value: value(speaker), + }), + None => self.edits.push(ConfigEdit::ClearPath { segments }), + } + self + } + pub fn clear_legacy_windows_sandbox_keys(mut self) -> Self { for key in [ "experimental_windows_sandbox", @@ -1804,6 +1828,50 @@ model_reasoning_effort = "high" assert_eq!(notice, Some(true)); } + #[test] + fn blocking_builder_set_realtime_audio_persists_and_clears() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .set_realtime_microphone(Some("USB Mic")) + .set_realtime_speaker(Some("Desk Speakers")) + .apply_blocking() + .expect("persist realtime audio"); + + let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let config: TomlValue = toml::from_str(&raw).expect("parse config"); + let realtime_audio = config + .get("audio") + .and_then(TomlValue::as_table) + .expect("audio table should exist"); + assert_eq!( + realtime_audio.get("microphone").and_then(TomlValue::as_str), + Some("USB Mic") + ); + assert_eq!( + realtime_audio.get("speaker").and_then(TomlValue::as_str), + Some("Desk Speakers") + ); + + ConfigEditsBuilder::new(codex_home) + .set_realtime_microphone(None) + .apply_blocking() + .expect("clear realtime microphone"); + + let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let config: TomlValue = toml::from_str(&raw).expect("parse config"); + let realtime_audio = config + .get("audio") + .and_then(TomlValue::as_table) + .expect("audio table should exist"); + assert_eq!(realtime_audio.get("microphone"), None); + assert_eq!( + realtime_audio.get("speaker").and_then(TomlValue::as_str), + Some("Desk Speakers") + ); + } + #[test] fn replace_mcp_servers_blocking_clears_table_when_empty() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index c5b22c8292..e9da18b5c4 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -426,6 +426,9 @@ pub struct Config { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: String, + /// Machine-local realtime audio device preferences used by realtime voice. + pub realtime_audio: RealtimeAudioConfig, + /// Experimental / do not use. Overrides only the realtime conversation /// websocket transport base URL (the `Op::RealtimeConversation` `/ws` /// connection) without changing normal provider HTTP requests. @@ -1175,6 +1178,10 @@ pub struct ConfigToml { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: Option, + /// Machine-local realtime audio device preferences used by realtime voice. + #[serde(default)] + pub audio: Option, + /// Experimental / do not use. Overrides only the realtime conversation /// websocket transport base URL (the `Op::RealtimeConversation` `/ws` /// connection) without changing normal provider HTTP requests. @@ -1306,6 +1313,19 @@ impl ProjectConfig { } } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RealtimeAudioConfig { + pub microphone: Option, + pub speaker: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct RealtimeAudioToml { + pub microphone: Option, + pub speaker: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct ToolsToml { @@ -2146,6 +2166,12 @@ impl Config { .chatgpt_base_url .or(cfg.chatgpt_base_url) .unwrap_or("https://chatgpt.com/backend-api/".to_string()), + realtime_audio: cfg + .audio + .map_or_else(RealtimeAudioConfig::default, |audio| RealtimeAudioConfig { + microphone: audio.microphone, + speaker: audio.speaker, + }), experimental_realtime_ws_base_url: cfg.experimental_realtime_ws_base_url, experimental_realtime_ws_backend_prompt: cfg.experimental_realtime_ws_backend_prompt, forced_chatgpt_workspace_id, @@ -4766,6 +4792,7 @@ model_verbosity = "high" model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_ws_base_url: None, experimental_realtime_ws_backend_prompt: None, base_instructions: None, @@ -4892,6 +4919,7 @@ model_verbosity = "high" model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_ws_base_url: None, experimental_realtime_ws_backend_prompt: None, base_instructions: None, @@ -5016,6 +5044,7 @@ model_verbosity = "high" model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_ws_base_url: None, experimental_realtime_ws_backend_prompt: None, base_instructions: None, @@ -5126,6 +5155,7 @@ model_verbosity = "high" model_verbosity: Some(Verbosity::High), personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_ws_base_url: None, experimental_realtime_ws_backend_prompt: None, base_instructions: None, @@ -5970,6 +6000,39 @@ experimental_realtime_ws_backend_prompt = "prompt from config" ); Ok(()) } + + #[test] + fn realtime_audio_loads_from_config_toml() -> std::io::Result<()> { + let cfg: ConfigToml = toml::from_str( + r#" +[audio] +microphone = "USB Mic" +speaker = "Desk Speakers" +"#, + ) + .expect("TOML deserialization should succeed"); + + let realtime_audio = cfg + .audio + .as_ref() + .expect("realtime audio config should be present"); + assert_eq!(realtime_audio.microphone.as_deref(), Some("USB Mic")); + assert_eq!(realtime_audio.speaker.as_deref(), Some("Desk Speakers")); + + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!(config.realtime_audio.microphone.as_deref(), Some("USB Mic")); + assert_eq!( + config.realtime_audio.speaker.as_deref(), + Some("Desk Speakers") + ); + Ok(()) + } } #[cfg(test)] diff --git a/codex-rs/tui/src/audio_device.rs b/codex-rs/tui/src/audio_device.rs new file mode 100644 index 0000000000..ec8a86ff29 --- /dev/null +++ b/codex-rs/tui/src/audio_device.rs @@ -0,0 +1,122 @@ +use codex_core::config::Config; +use cpal::traits::DeviceTrait; +use cpal::traits::HostTrait; +use tracing::warn; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AudioDeviceKind { + Input, + Output, +} + +impl AudioDeviceKind { + fn noun(self) -> &'static str { + match self { + Self::Input => "input", + Self::Output => "output", + } + } + + fn configured_name(self, config: &Config) -> Option<&str> { + match self { + Self::Input => config.realtime_audio.microphone.as_deref(), + Self::Output => config.realtime_audio.speaker.as_deref(), + } + } +} + +pub(crate) fn select_configured_input_device_and_config( + config: &Config, +) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> { + select_device_and_config(AudioDeviceKind::Input, config) +} + +pub(crate) fn select_configured_output_device_and_config( + config: &Config, +) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> { + select_device_and_config(AudioDeviceKind::Output, config) +} + +fn select_device_and_config( + kind: AudioDeviceKind, + config: &Config, +) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> { + let host = cpal::default_host(); + let configured_name = kind.configured_name(config); + let selected = configured_name + .and_then(|name| find_device_by_name(&host, kind, name)) + .or_else(|| { + let default_device = default_device(&host, kind); + if let Some(name) = configured_name + && default_device.is_some() + { + warn!( + "configured {} audio device `{name}` was unavailable; falling back to system default", + kind.noun() + ); + } + default_device + }) + .ok_or_else(|| missing_device_error(kind, configured_name))?; + + let stream_config = default_config(&selected, kind)?; + Ok((selected, stream_config)) +} + +fn find_device_by_name( + host: &cpal::Host, + kind: AudioDeviceKind, + name: &str, +) -> Option { + let devices = devices(host, kind).ok()?; + devices + .into_iter() + .find(|device| device.name().ok().as_deref() == Some(name)) +} + +fn devices(host: &cpal::Host, kind: AudioDeviceKind) -> Result, String> { + match kind { + AudioDeviceKind::Input => host + .input_devices() + .map(|devices| devices.collect()) + .map_err(|err| format!("failed to enumerate input audio devices: {err}")), + AudioDeviceKind::Output => host + .output_devices() + .map(|devices| devices.collect()) + .map_err(|err| format!("failed to enumerate output audio devices: {err}")), + } +} + +fn default_device(host: &cpal::Host, kind: AudioDeviceKind) -> Option { + match kind { + AudioDeviceKind::Input => host.default_input_device(), + AudioDeviceKind::Output => host.default_output_device(), + } +} + +fn default_config( + device: &cpal::Device, + kind: AudioDeviceKind, +) -> Result { + match kind { + AudioDeviceKind::Input => device + .default_input_config() + .map_err(|err| format!("failed to get default input config: {err}")), + AudioDeviceKind::Output => device + .default_output_config() + .map_err(|err| format!("failed to get default output config: {err}")), + } +} + +fn missing_device_error(kind: AudioDeviceKind, configured_name: Option<&str>) -> String { + match (kind, configured_name) { + (AudioDeviceKind::Input, Some(name)) => format!( + "configured input audio device `{name}` was unavailable and no default input audio device was found" + ), + (AudioDeviceKind::Output, Some(name)) => format!( + "configured output audio device `{name}` was unavailable and no default output audio device was found" + ), + (AudioDeviceKind::Input, None) => "no input audio device available".to_string(), + (AudioDeviceKind::Output, None) => "no output audio device available".to_string(), + } +} diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index 2cf97188b3..e4fd2b6314 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -207,7 +207,7 @@ impl ChatWidget { { if self.realtime_conversation.audio_player.is_none() { self.realtime_conversation.audio_player = - crate::voice::RealtimeAudioPlayer::start().ok(); + crate::voice::RealtimeAudioPlayer::start(&self.config).ok(); } if let Some(player) = &self.realtime_conversation.audio_player && let Err(err) = player.enqueue_frame(frame) @@ -231,7 +231,10 @@ impl ChatWidget { self.realtime_conversation.meter_placeholder_id = Some(placeholder_id.clone()); self.request_redraw(); - let capture = match crate::voice::VoiceCapture::start_realtime(self.app_event_tx.clone()) { + let capture = match crate::voice::VoiceCapture::start_realtime( + &self.config, + self.app_event_tx.clone(), + ) { Ok(capture) => capture, Err(err) => { self.remove_transcription_placeholder(&placeholder_id); @@ -250,7 +253,7 @@ impl ChatWidget { self.realtime_conversation.capture = Some(capture); if self.realtime_conversation.audio_player.is_none() { self.realtime_conversation.audio_player = - crate::voice::RealtimeAudioPlayer::start().ok(); + crate::voice::RealtimeAudioPlayer::start(&self.config).ok(); } std::thread::spawn(move || { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index cf805d0b9b..5a4c87aef7 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -63,6 +63,8 @@ mod app_backtrack; mod app_event; mod app_event_sender; mod ascii_animation; +#[cfg(all(not(target_os = "linux"), feature = "voice-input"))] +mod audio_device; mod bottom_pane; mod chatwidget; mod cli; @@ -123,6 +125,7 @@ mod voice; mod voice { use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; + use codex_core::config::Config; use codex_protocol::protocol::RealtimeAudioFrame; use std::sync::Arc; use std::sync::Mutex; @@ -146,7 +149,7 @@ mod voice { Err("voice input is unavailable in this build".to_string()) } - pub fn start_realtime(_tx: AppEventSender) -> Result { + pub fn start_realtime(_config: &Config, _tx: AppEventSender) -> Result { Err("voice input is unavailable in this build".to_string()) } @@ -186,7 +189,7 @@ mod voice { } impl RealtimeAudioPlayer { - pub(crate) fn start() -> Result { + pub(crate) fn start(_config: &Config) -> Result { Err("voice output is unavailable in this build".to_string()) } diff --git a/codex-rs/tui/src/voice.rs b/codex-rs/tui/src/voice.rs index 443ccc88a0..6c4236ac81 100644 --- a/codex-rs/tui/src/voice.rs +++ b/codex-rs/tui/src/voice.rs @@ -51,7 +51,7 @@ pub struct VoiceCapture { impl VoiceCapture { pub fn start() -> Result { - let (device, config) = select_input_device_and_config()?; + let (device, config) = select_default_input_device_and_config()?; let sample_rate = config.sample_rate().0; let channels = config.channels(); @@ -74,8 +74,8 @@ impl VoiceCapture { }) } - pub fn start_realtime(tx: AppEventSender) -> Result { - let (device, config) = select_input_device_and_config()?; + pub fn start_realtime(config: &Config, tx: AppEventSender) -> Result { + let (device, config) = select_realtime_input_device_and_config(config)?; let sample_rate = config.sample_rate().0; let channels = config.channels(); @@ -262,7 +262,8 @@ pub fn transcribe_async( // Voice input helpers // ------------------------- -fn select_input_device_and_config() -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> { +fn select_default_input_device_and_config() +-> Result<(cpal::Device, cpal::SupportedStreamConfig), String> { let host = cpal::default_host(); let device = host .default_input_device() @@ -273,6 +274,12 @@ fn select_input_device_and_config() -> Result<(cpal::Device, cpal::SupportedStre Ok((device, config)) } +fn select_realtime_input_device_and_config( + config: &Config, +) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> { + crate::audio_device::select_configured_input_device_and_config(config) +} + fn build_input_stream( device: &cpal::Device, config: &cpal::SupportedStreamConfig, @@ -466,14 +473,9 @@ pub(crate) struct RealtimeAudioPlayer { } impl RealtimeAudioPlayer { - pub(crate) fn start() -> Result { - let host = cpal::default_host(); - let device = host - .default_output_device() - .ok_or_else(|| "no output audio device available".to_string())?; - let config = device - .default_output_config() - .map_err(|e| format!("failed to get default output config: {e}"))?; + pub(crate) fn start(config: &Config) -> Result { + let (device, config) = + crate::audio_device::select_configured_output_device_and_config(config)?; let output_sample_rate = config.sample_rate().0; let output_channels = config.channels(); let queue = Arc::new(Mutex::new(VecDeque::new())); From 4cb086d96fb394b18fbaa1629aea0e44c0521a57 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 26 Feb 2026 15:15:28 -0800 Subject: [PATCH 4/7] test: move unix_escalation tests into sibling file (#12957) ## Why `unix_escalation.rs` had a large inline `mod tests` block that made the implementation harder to scan. This change moves those tests into a sibling file while keeping them as a child module, so they can still exercise private items without widening visibility. ## What Changed - replaced the inline `#[cfg(test)] mod tests` block in `codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs` with a path-based test module declaration - moved the existing unit tests into `codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs` - kept the extracted tests using `super::...` imports so they continue to access private helpers and types from `unix_escalation.rs` ## Testing - `cargo test -p codex-core unix_escalation::tests` --- .../tools/runtimes/shell/unix_escalation.rs | 326 +----------------- .../runtimes/shell/unix_escalation_tests.rs | 320 +++++++++++++++++ 2 files changed, 322 insertions(+), 324 deletions(-) create mode 100644 codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 084b245072..ae1df34ece 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -799,327 +799,5 @@ fn join_program_and_argv(program: &AbsolutePathBuf, argv: &[String]) -> Vec reason, - _ => "".to_string(), - }, - "unexpected shell command format for zsh-fork execution" - ); - } - - #[test] - fn join_program_and_argv_replaces_original_argv_zero() { - assert_eq!( - join_program_and_argv( - &AbsolutePathBuf::from_absolute_path("/tmp/tool").unwrap(), - &["./tool".into(), "--flag".into(), "value".into()], - ), - vec!["/tmp/tool", "--flag", "value"] - ); - assert_eq!( - join_program_and_argv( - &AbsolutePathBuf::from_absolute_path("/tmp/tool").unwrap(), - &["./tool".into()] - ), - vec!["/tmp/tool"] - ); - } - - #[test] - fn map_exec_result_preserves_stdout_and_stderr() { - let out = map_exec_result( - SandboxType::None, - ExecResult { - exit_code: 0, - stdout: "out".to_string(), - stderr: "err".to_string(), - output: "outerr".to_string(), - duration: Duration::from_millis(1), - timed_out: false, - }, - ) - .unwrap(); - - assert_eq!(out.stdout.text, "out"); - assert_eq!(out.stderr.text, "err"); - assert_eq!(out.aggregated_output.text, "outerr"); - } - - #[test] - fn shell_request_escalation_execution_is_explicit() { - let requested_permissions = PermissionProfile { - file_system: Some(FileSystemPermissions { - read: None, - write: Some(vec![PathBuf::from("./output")]), - }), - ..Default::default() - }; - let sandbox_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![ - AbsolutePathBuf::from_absolute_path("/tmp/original/output").unwrap(), - ], - read_only_access: ReadOnlyAccess::FullAccess, - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }; - let macos_seatbelt_profile_extensions = MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - ..Default::default() - }; - - assert_eq!( - CoreShellActionProvider::shell_request_escalation_execution( - crate::sandboxing::SandboxPermissions::UseDefault, - &sandbox_policy, - None, - Some(&macos_seatbelt_profile_extensions), - ), - EscalationExecution::TurnDefault, - ); - assert_eq!( - CoreShellActionProvider::shell_request_escalation_execution( - crate::sandboxing::SandboxPermissions::RequireEscalated, - &sandbox_policy, - None, - Some(&macos_seatbelt_profile_extensions), - ), - EscalationExecution::Unsandboxed, - ); - assert_eq!( - CoreShellActionProvider::shell_request_escalation_execution( - crate::sandboxing::SandboxPermissions::WithAdditionalPermissions, - &sandbox_policy, - Some(&requested_permissions), - Some(&macos_seatbelt_profile_extensions), - ), - EscalationExecution::Permissions(EscalationPermissions::Permissions( - EscalatedPermissions { - sandbox_policy, - macos_seatbelt_profile_extensions: Some(macos_seatbelt_profile_extensions), - }, - )), - ); - } - - #[cfg(target_os = "macos")] - #[tokio::test] - async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions() { - let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir()).unwrap(); - let executor = CoreShellCommandExecutor { - command: vec!["echo".to_string(), "ok".to_string()], - cwd: cwd.to_path_buf(), - env: HashMap::new(), - network: None, - sandbox: SandboxType::None, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - windows_sandbox_level: WindowsSandboxLevel::Disabled, - sandbox_permissions: SandboxPermissions::UseDefault, - justification: None, - arg0: None, - sandbox_policy_cwd: cwd.to_path_buf(), - macos_seatbelt_profile_extensions: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - ..Default::default() - }), - codex_linux_sandbox_exe: None, - use_linux_sandbox_bwrap: false, - }; - - let prepared = executor - .prepare_escalated_exec( - &AbsolutePathBuf::from_absolute_path("/bin/echo").unwrap(), - &["echo".to_string(), "ok".to_string()], - &cwd, - HashMap::new(), - EscalationExecution::TurnDefault, - ) - .await - .unwrap(); - - assert_eq!( - prepared.command.first().map(String::as_str), - Some(MACOS_PATH_TO_SEATBELT_EXECUTABLE) - ); - assert_eq!(prepared.command.get(1).map(String::as_str), Some("-p")); - assert!( - prepared - .command - .get(2) - .is_some_and(|policy| policy.contains("(allow user-preference-write)")), - "expected seatbelt policy to include macOS extension profile: {:?}", - prepared.command - ); - } - - #[cfg(target_os = "macos")] - #[tokio::test] - async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions() { - let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir()).unwrap(); - let executor = CoreShellCommandExecutor { - command: vec!["echo".to_string(), "ok".to_string()], - cwd: cwd.to_path_buf(), - env: HashMap::new(), - network: None, - sandbox: SandboxType::None, - sandbox_policy: SandboxPolicy::DangerFullAccess, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - sandbox_permissions: SandboxPermissions::UseDefault, - justification: None, - arg0: None, - sandbox_policy_cwd: cwd.to_path_buf(), - macos_seatbelt_profile_extensions: None, - codex_linux_sandbox_exe: None, - use_linux_sandbox_bwrap: false, - }; - - let permissions = Permissions { - approval_policy: Constrained::allow_any(AskForApproval::Never), - sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - macos_seatbelt_profile_extensions: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - ..Default::default() - }), - }; - - let prepared = executor - .prepare_escalated_exec( - &AbsolutePathBuf::from_absolute_path("/bin/echo").unwrap(), - &["echo".to_string(), "ok".to_string()], - &cwd, - HashMap::new(), - EscalationExecution::Permissions(EscalationPermissions::Permissions( - EscalatedPermissions { - sandbox_policy: permissions.sandbox_policy.get().clone(), - macos_seatbelt_profile_extensions: permissions - .macos_seatbelt_profile_extensions - .clone(), - }, - )), - ) - .await - .unwrap(); - - assert_eq!( - prepared.command.first().map(String::as_str), - Some(MACOS_PATH_TO_SEATBELT_EXECUTABLE) - ); - assert_eq!(prepared.command.get(1).map(String::as_str), Some("-p")); - assert!( - prepared - .command - .get(2) - .is_some_and(|policy| policy.contains("(allow user-preference-write)")), - "expected seatbelt policy to include macOS extension profile: {:?}", - prepared.command - ); - } -} +#[path = "unix_escalation_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs new file mode 100644 index 0000000000..d2fd612670 --- /dev/null +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -0,0 +1,320 @@ +use super::CoreShellActionProvider; +#[cfg(target_os = "macos")] +use super::CoreShellCommandExecutor; +use super::ParsedShellCommand; +use super::extract_shell_script; +use super::join_program_and_argv; +use super::map_exec_result; +#[cfg(target_os = "macos")] +use crate::config::Constrained; +#[cfg(target_os = "macos")] +use crate::config::Permissions; +#[cfg(target_os = "macos")] +use crate::config::types::ShellEnvironmentPolicy; +use crate::exec::SandboxType; +#[cfg(target_os = "macos")] +use crate::protocol::AskForApproval; +use crate::protocol::ReadOnlyAccess; +use crate::protocol::SandboxPolicy; +#[cfg(target_os = "macos")] +use crate::sandboxing::SandboxPermissions; +#[cfg(target_os = "macos")] +use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; +#[cfg(target_os = "macos")] +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::MacOsPreferencesPermission; +use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::PermissionProfile; +use codex_shell_escalation::EscalationExecution; +use codex_shell_escalation::EscalationPermissions; +use codex_shell_escalation::ExecResult; +use codex_shell_escalation::Permissions as EscalatedPermissions; +#[cfg(target_os = "macos")] +use codex_shell_escalation::ShellCommandExecutor; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +#[cfg(target_os = "macos")] +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +#[test] +fn extract_shell_script_preserves_login_flag() { + assert_eq!( + extract_shell_script(&["/bin/zsh".into(), "-lc".into(), "echo hi".into()]).unwrap(), + ParsedShellCommand { + script: "echo hi".to_string(), + login: true, + } + ); + assert_eq!( + extract_shell_script(&["/bin/zsh".into(), "-c".into(), "echo hi".into()]).unwrap(), + ParsedShellCommand { + script: "echo hi".to_string(), + login: false, + } + ); +} + +#[test] +fn extract_shell_script_supports_wrapped_command_prefixes() { + assert_eq!( + extract_shell_script(&[ + "/usr/bin/env".into(), + "CODEX_EXECVE_WRAPPER=1".into(), + "/bin/zsh".into(), + "-lc".into(), + "echo hello".into() + ]) + .unwrap(), + ParsedShellCommand { + script: "echo hello".to_string(), + login: true, + } + ); + + assert_eq!( + extract_shell_script(&[ + "sandbox-exec".into(), + "-p".into(), + "sandbox_policy".into(), + "/bin/zsh".into(), + "-c".into(), + "pwd".into(), + ]) + .unwrap(), + ParsedShellCommand { + script: "pwd".to_string(), + login: false, + } + ); +} + +#[test] +fn extract_shell_script_rejects_unsupported_shell_invocation() { + let err = extract_shell_script(&[ + "sandbox-exec".into(), + "-fc".into(), + "echo not supported".into(), + ]) + .unwrap_err(); + assert!(matches!(err, super::ToolError::Rejected(_))); + assert_eq!( + match err { + super::ToolError::Rejected(reason) => reason, + _ => "".to_string(), + }, + "unexpected shell command format for zsh-fork execution" + ); +} + +#[test] +fn join_program_and_argv_replaces_original_argv_zero() { + assert_eq!( + join_program_and_argv( + &AbsolutePathBuf::from_absolute_path("/tmp/tool").unwrap(), + &["./tool".into(), "--flag".into(), "value".into()], + ), + vec!["/tmp/tool", "--flag", "value"] + ); + assert_eq!( + join_program_and_argv( + &AbsolutePathBuf::from_absolute_path("/tmp/tool").unwrap(), + &["./tool".into()] + ), + vec!["/tmp/tool"] + ); +} + +#[test] +fn map_exec_result_preserves_stdout_and_stderr() { + let out = map_exec_result( + SandboxType::None, + ExecResult { + exit_code: 0, + stdout: "out".to_string(), + stderr: "err".to_string(), + output: "outerr".to_string(), + duration: Duration::from_millis(1), + timed_out: false, + }, + ) + .unwrap(); + + assert_eq!(out.stdout.text, "out"); + assert_eq!(out.stderr.text, "err"); + assert_eq!(out.aggregated_output.text, "outerr"); +} + +#[test] +fn shell_request_escalation_execution_is_explicit() { + let requested_permissions = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: None, + write: Some(vec![PathBuf::from("./output")]), + }), + ..Default::default() + }; + let sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![AbsolutePathBuf::from_absolute_path("/tmp/original/output").unwrap()], + read_only_access: ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + let macos_seatbelt_profile_extensions = MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + ..Default::default() + }; + + assert_eq!( + CoreShellActionProvider::shell_request_escalation_execution( + crate::sandboxing::SandboxPermissions::UseDefault, + &sandbox_policy, + None, + Some(&macos_seatbelt_profile_extensions), + ), + EscalationExecution::TurnDefault, + ); + assert_eq!( + CoreShellActionProvider::shell_request_escalation_execution( + crate::sandboxing::SandboxPermissions::RequireEscalated, + &sandbox_policy, + None, + Some(&macos_seatbelt_profile_extensions), + ), + EscalationExecution::Unsandboxed, + ); + assert_eq!( + CoreShellActionProvider::shell_request_escalation_execution( + crate::sandboxing::SandboxPermissions::WithAdditionalPermissions, + &sandbox_policy, + Some(&requested_permissions), + Some(&macos_seatbelt_profile_extensions), + ), + EscalationExecution::Permissions(EscalationPermissions::Permissions( + EscalatedPermissions { + sandbox_policy, + macos_seatbelt_profile_extensions: Some(macos_seatbelt_profile_extensions), + }, + )), + ); +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions() { + let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir()).unwrap(); + let executor = CoreShellCommandExecutor { + command: vec!["echo".to_string(), "ok".to_string()], + cwd: cwd.to_path_buf(), + env: HashMap::new(), + network: None, + sandbox: SandboxType::None, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, + sandbox_permissions: SandboxPermissions::UseDefault, + justification: None, + arg0: None, + sandbox_policy_cwd: cwd.to_path_buf(), + macos_seatbelt_profile_extensions: Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + ..Default::default() + }), + codex_linux_sandbox_exe: None, + use_linux_sandbox_bwrap: false, + }; + + let prepared = executor + .prepare_escalated_exec( + &AbsolutePathBuf::from_absolute_path("/bin/echo").unwrap(), + &["echo".to_string(), "ok".to_string()], + &cwd, + HashMap::new(), + EscalationExecution::TurnDefault, + ) + .await + .unwrap(); + + assert_eq!( + prepared.command.first().map(String::as_str), + Some(MACOS_PATH_TO_SEATBELT_EXECUTABLE) + ); + assert_eq!(prepared.command.get(1).map(String::as_str), Some("-p")); + assert!( + prepared + .command + .get(2) + .is_some_and(|policy| policy.contains("(allow user-preference-write)")), + "expected seatbelt policy to include macOS extension profile: {:?}", + prepared.command + ); +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions() { + let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir()).unwrap(); + let executor = CoreShellCommandExecutor { + command: vec!["echo".to_string(), "ok".to_string()], + cwd: cwd.to_path_buf(), + env: HashMap::new(), + network: None, + sandbox: SandboxType::None, + sandbox_policy: SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + sandbox_permissions: SandboxPermissions::UseDefault, + justification: None, + arg0: None, + sandbox_policy_cwd: cwd.to_path_buf(), + macos_seatbelt_profile_extensions: None, + codex_linux_sandbox_exe: None, + use_linux_sandbox_bwrap: false, + }; + + let permissions = Permissions { + approval_policy: Constrained::allow_any(AskForApproval::Never), + sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + network: None, + allow_login_shell: true, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + macos_seatbelt_profile_extensions: Some(MacOsSeatbeltProfileExtensions { + macos_preferences: MacOsPreferencesPermission::ReadWrite, + ..Default::default() + }), + }; + + let prepared = executor + .prepare_escalated_exec( + &AbsolutePathBuf::from_absolute_path("/bin/echo").unwrap(), + &["echo".to_string(), "ok".to_string()], + &cwd, + HashMap::new(), + EscalationExecution::Permissions(EscalationPermissions::Permissions( + EscalatedPermissions { + sandbox_policy: permissions.sandbox_policy.get().clone(), + macos_seatbelt_profile_extensions: permissions + .macos_seatbelt_profile_extensions + .clone(), + }, + )), + ) + .await + .unwrap(); + + assert_eq!( + prepared.command.first().map(String::as_str), + Some(MACOS_PATH_TO_SEATBELT_EXECUTABLE) + ); + assert_eq!(prepared.command.get(1).map(String::as_str), Some("-p")); + assert!( + prepared + .command + .get(2) + .is_some_and(|policy| policy.contains("(allow user-preference-write)")), + "expected seatbelt policy to include macOS extension profile: {:?}", + prepared.command + ); +} From 90cc4e79a25f86e487a701732b7573139c683442 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Thu, 26 Feb 2026 15:17:35 -0800 Subject: [PATCH 5/7] feat: add local date/timezone to turn environment context (#12947) ## Summary This PR includes the session's local date and timezone in the model-visible environment context and persists that data in `TurnContextItem`. ## What changed - captures the current local date and IANA timezone when building a turn context, with a UTC fallback if the timezone lookup fails - includes current_date and timezone in the serialized payload - stores those fields on TurnContextItem so they survive rollout/history handling, subagent review threads, and resume flows - treats date/timezone changes as environment updates, so prompt caching and context refresh logic do not silently reuse stale time context - updates tests to validate the new environment fields without depending on a single hardcoded environment-context string ## test built a local build and saw it in the rollout file: ``` {"timestamp":"2026-02-26T21:39:50.737Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"\n zsh\n 2026-02-26\n America/Los_Angeles\n"}]}} ``` --- codex-rs/Cargo.lock | 1 + codex-rs/Cargo.toml | 1 + .../app-server/tests/suite/send_message.rs | 2 + codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/codex.rs | 70 +++++++- codex-rs/core/src/environment_context.rs | 155 +++++++++++++++--- codex-rs/core/src/rollout/recorder.rs | 2 + codex-rs/core/tests/suite/prompt_caching.rs | 89 +++++++--- codex-rs/core/tests/suite/resume_warning.rs | 2 + codex-rs/protocol/src/protocol.rs | 6 + codex-rs/state/src/extract.rs | 4 + codex-rs/tui/src/lib.rs | 2 + 12 files changed, 288 insertions(+), 47 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 43c33c96a1..a68f99425c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1791,6 +1791,7 @@ dependencies = [ "eventsource-stream", "futures", "http 1.4.0", + "iana-time-zone", "image", "indexmap 2.13.0", "insta", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 2784a1769f..01836cde05 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -186,6 +186,7 @@ icu_locale_core = "2.1" icu_provider = { version = "2.1", features = ["sync"] } ignore = "0.4.23" image = { version = "^0.25.9", default-features = false } +iana-time-zone = "0.1.64" include_dir = "0.7.4" indexmap = "2.12.0" insta = "1.46.3" diff --git a/codex-rs/app-server/tests/suite/send_message.rs b/codex-rs/app-server/tests/suite/send_message.rs index e5c023d9d1..e755a11b77 100644 --- a/codex-rs/app-server/tests/suite/send_message.rs +++ b/codex-rs/app-server/tests/suite/send_message.rs @@ -620,6 +620,8 @@ fn append_rollout_turn_context(path: &Path, timestamp: &str, model: &str) -> std item: RolloutItem::TurnContext(TurnContextItem { turn_id: None, cwd: PathBuf::from("/"), + current_date: None, + timezone: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, network: None, diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 47731291a4..87f18eb800 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -61,6 +61,7 @@ env-flags = { workspace = true } eventsource-stream = { workspace = true } futures = { workspace = true } http = { workspace = true } +iana-time-zone = { workspace = true } indexmap = { workspace = true } keyring = { workspace = true, features = ["crypto-rust"] } libc = { workspace = true } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index bb071393dd..6d583e46ed 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -53,6 +53,8 @@ use crate::util::error_or_panic; use crate::ws_version_from_features; use async_channel::Receiver; use async_channel::Sender; +use chrono::Local; +use chrono::Utc; use codex_hooks::HookEvent; use codex_hooks::HookEventAfterAgent; use codex_hooks::HookPayload; @@ -595,6 +597,8 @@ pub(crate) struct TurnContext { /// the model as well as sandbox policies are resolved against this path /// instead of `std::env::current_dir()`. pub(crate) cwd: PathBuf, + pub(crate) current_date: Option, + pub(crate) timezone: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -679,6 +683,8 @@ impl TurnContext { reasoning_summary: self.reasoning_summary, session_source: self.session_source.clone(), cwd: self.cwd.clone(), + current_date: self.current_date.clone(), + timezone: self.timezone.clone(), developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -719,6 +725,8 @@ impl TurnContext { TurnContextItem { turn_id: Some(self.sub_id.clone()), cwd: self.cwd.clone(), + current_date: self.current_date.clone(), + timezone: self.timezone.clone(), approval_policy: self.approval_policy.value(), sandbox_policy: self.sandbox_policy.get().clone(), network: self.turn_context_network_item(), @@ -748,6 +756,16 @@ impl TurnContext { } } +fn local_time_context() -> (String, String) { + match iana_time_zone::get_timezone() { + Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone), + Err(_) => ( + Utc::now().format("%Y-%m-%d").to_string(), + "Etc/UTC".to_string(), + ), + } +} + #[derive(Clone)] pub(crate) struct SessionConfiguration { /// Provider identifier ("openai", "openrouter", ...). @@ -1017,6 +1035,7 @@ impl Session { .features .enabled(Feature::UseLinuxSandboxBwrap), )); + let (current_date, timezone) = local_time_context(); TurnContext { sub_id, config: per_turn_config.clone(), @@ -1028,6 +1047,8 @@ impl Session { reasoning_summary, session_source, cwd, + current_date: Some(current_date), + timezone: Some(timezone), developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -4676,6 +4697,8 @@ async fn spawn_review_thread( tools_config, features: parent_turn_context.features.clone(), ghost_snapshot: parent_turn_context.ghost_snapshot.clone(), + current_date: parent_turn_context.current_date.clone(), + timezone: parent_turn_context.timezone.clone(), developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -7259,6 +7282,8 @@ mod tests { let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), cwd: turn_context.cwd.clone(), + current_date: turn_context.current_date.clone(), + timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, @@ -7296,6 +7321,8 @@ mod tests { let mut previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), cwd: turn_context.cwd.clone(), + current_date: turn_context.current_date.clone(), + timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, @@ -7490,7 +7517,10 @@ mod tests { .record_context_updates_and_set_reference_context_item(&turn_context, None) .await; let history_after_second_seed = session.clone_history().await; - assert_eq!(expected, history_after_second_seed.raw_items()); + assert_eq!( + history_after_seed.raw_items(), + history_after_second_seed.raw_items() + ); } #[tokio::test] @@ -7658,6 +7688,8 @@ mod tests { let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), cwd: turn_context.cwd.clone(), + current_date: turn_context.current_date.clone(), + timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, @@ -8823,6 +8855,42 @@ mod tests { assert!(environment_update.contains("blocked.example.com")); } + #[tokio::test] + async fn build_settings_update_items_emits_environment_item_for_time_changes() { + let (session, previous_context) = make_session_and_context().await; + let previous_context = Arc::new(previous_context); + let mut current_context = previous_context + .with_model( + previous_context.model_info.slug.clone(), + &session.services.models_manager, + ) + .await; + current_context.current_date = Some("2026-02-27".to_string()); + current_context.timezone = Some("Europe/Berlin".to_string()); + + let reference_context_item = previous_context.to_turn_context_item(); + let update_items = session.build_settings_update_items( + Some(&reference_context_item), + None, + ¤t_context, + ); + + let environment_update = update_items + .iter() + .find_map(|item| match item { + ResponseItem::Message { role, content, .. } if role == "user" => { + let [ContentItem::InputText { text }] = content.as_slice() else { + return None; + }; + text.contains("").then_some(text) + } + _ => None, + }) + .expect("environment update item should be emitted"); + assert!(environment_update.contains("2026-02-27")); + assert!(environment_update.contains("Europe/Berlin")); + } + #[tokio::test] async fn record_context_updates_and_set_reference_context_item_injects_full_context_when_baseline_missing() { diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index c450818d63..3e9ed871e3 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -13,6 +13,8 @@ use std::path::PathBuf; pub(crate) struct EnvironmentContext { pub cwd: Option, pub shell: Shell, + pub current_date: Option, + pub timezone: Option, pub network: Option, pub subagents: Option, } @@ -27,12 +29,16 @@ impl EnvironmentContext { pub fn new( cwd: Option, shell: Shell, + current_date: Option, + timezone: Option, network: Option, subagents: Option, ) -> Self { Self { cwd, shell, + current_date, + timezone, network, subagents, } @@ -44,11 +50,17 @@ impl EnvironmentContext { pub fn equals_except_shell(&self, other: &EnvironmentContext) -> bool { let EnvironmentContext { cwd, + current_date, + timezone, network, subagents, shell: _, } = other; - self.cwd == *cwd && self.network == *network && self.subagents == *subagents + self.cwd == *cwd + && self.current_date == *current_date + && self.timezone == *timezone + && self.network == *network + && self.subagents == *subagents } pub fn diff_from_turn_context_item( @@ -63,18 +75,22 @@ impl EnvironmentContext { } else { None }; + let current_date = after.current_date.clone(); + let timezone = after.timezone.clone(); let network = if before_network != after_network { after_network } else { before_network }; - EnvironmentContext::new(cwd, shell.clone(), network, None) + EnvironmentContext::new(cwd, shell.clone(), current_date, timezone, network, None) } pub fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { Self::new( Some(turn_context.cwd.clone()), shell.clone(), + turn_context.current_date.clone(), + turn_context.timezone.clone(), Self::network_from_turn_context(turn_context), None, ) @@ -84,6 +100,8 @@ impl EnvironmentContext { Self::new( Some(turn_context_item.cwd.clone()), shell.clone(), + turn_context_item.current_date.clone(), + turn_context_item.timezone.clone(), Self::network_from_turn_context_item(turn_context_item), None, ) @@ -143,6 +161,12 @@ impl EnvironmentContext { let shell_name = self.shell.name(); lines.push(format!(" {shell_name}")); + if let Some(current_date) = self.current_date { + lines.push(format!(" {current_date}")); + } + if let Some(timezone) = self.timezone { + lines.push(format!(" {timezone}")); + } match self.network { Some(ref network) => { lines.push(" ".to_string()); @@ -193,12 +217,21 @@ mod tests { #[test] fn serialize_workspace_write_environment_context() { let cwd = test_path_buf("/repo"); - let context = EnvironmentContext::new(Some(cwd.clone()), fake_shell(), None, None); + let context = EnvironmentContext::new( + Some(cwd.clone()), + fake_shell(), + Some("2026-02-26".to_string()), + Some("America/Los_Angeles".to_string()), + None, + None, + ); let expected = format!( r#" {cwd} bash + 2026-02-26 + America/Los_Angeles "#, cwd = cwd.display(), ); @@ -215,6 +248,8 @@ mod tests { let context = EnvironmentContext::new( Some(test_path_buf("/repo")), fake_shell(), + Some("2026-02-26".to_string()), + Some("America/Los_Angeles".to_string()), Some(network), None, ); @@ -223,6 +258,8 @@ mod tests { r#" {} bash + 2026-02-26 + America/Los_Angeles api.example.com *.openai.com @@ -237,10 +274,19 @@ mod tests { #[test] fn serialize_read_only_environment_context() { - let context = EnvironmentContext::new(None, fake_shell(), None, None); + let context = EnvironmentContext::new( + None, + fake_shell(), + Some("2026-02-26".to_string()), + Some("America/Los_Angeles".to_string()), + None, + None, + ); let expected = r#" bash + 2026-02-26 + America/Los_Angeles "#; assert_eq!(context.serialize_to_xml(), expected); @@ -248,10 +294,19 @@ mod tests { #[test] fn serialize_external_sandbox_environment_context() { - let context = EnvironmentContext::new(None, fake_shell(), None, None); + let context = EnvironmentContext::new( + None, + fake_shell(), + Some("2026-02-26".to_string()), + Some("America/Los_Angeles".to_string()), + None, + None, + ); let expected = r#" bash + 2026-02-26 + America/Los_Angeles "#; assert_eq!(context.serialize_to_xml(), expected); @@ -259,10 +314,19 @@ mod tests { #[test] fn serialize_external_sandbox_with_restricted_network_environment_context() { - let context = EnvironmentContext::new(None, fake_shell(), None, None); + let context = EnvironmentContext::new( + None, + fake_shell(), + Some("2026-02-26".to_string()), + Some("America/Los_Angeles".to_string()), + None, + None, + ); let expected = r#" bash + 2026-02-26 + America/Los_Angeles "#; assert_eq!(context.serialize_to_xml(), expected); @@ -270,10 +334,19 @@ mod tests { #[test] fn serialize_full_access_environment_context() { - let context = EnvironmentContext::new(None, fake_shell(), None, None); + let context = EnvironmentContext::new( + None, + fake_shell(), + Some("2026-02-26".to_string()), + Some("America/Los_Angeles".to_string()), + None, + None, + ); let expected = r#" bash + 2026-02-26 + America/Los_Angeles "#; assert_eq!(context.serialize_to_xml(), expected); @@ -281,29 +354,65 @@ mod tests { #[test] fn equals_except_shell_compares_cwd() { - let context1 = - EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell(), None, None); - let context2 = - EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell(), None, None); + let context1 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + fake_shell(), + None, + None, + None, + None, + ); + let context2 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + fake_shell(), + None, + None, + None, + None, + ); assert!(context1.equals_except_shell(&context2)); } #[test] fn equals_except_shell_ignores_sandbox_policy() { - let context1 = - EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell(), None, None); - let context2 = - EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell(), None, None); + let context1 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + fake_shell(), + None, + None, + None, + None, + ); + let context2 = EnvironmentContext::new( + Some(PathBuf::from("/repo")), + fake_shell(), + None, + None, + None, + None, + ); assert!(context1.equals_except_shell(&context2)); } #[test] fn equals_except_shell_compares_cwd_differences() { - let context1 = - EnvironmentContext::new(Some(PathBuf::from("/repo1")), fake_shell(), None, None); - let context2 = - EnvironmentContext::new(Some(PathBuf::from("/repo2")), fake_shell(), None, None); + let context1 = EnvironmentContext::new( + Some(PathBuf::from("/repo1")), + fake_shell(), + None, + None, + None, + None, + ); + let context2 = EnvironmentContext::new( + Some(PathBuf::from("/repo2")), + fake_shell(), + None, + None, + None, + None, + ); assert!(!context1.equals_except_shell(&context2)); } @@ -319,6 +428,8 @@ mod tests { }, None, None, + None, + None, ); let context2 = EnvironmentContext::new( Some(PathBuf::from("/repo")), @@ -329,6 +440,8 @@ mod tests { }, None, None, + None, + None, ); assert!(context1.equals_except_shell(&context2)); @@ -339,6 +452,8 @@ mod tests { let context = EnvironmentContext::new( Some(test_path_buf("/repo")), fake_shell(), + Some("2026-02-26".to_string()), + Some("America/Los_Angeles".to_string()), None, Some("- agent-1: atlas\n- agent-2".to_string()), ); @@ -347,6 +462,8 @@ mod tests { r#" {} bash + 2026-02-26 + America/Los_Angeles - agent-1: atlas - agent-2 diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 0e68b8b258..bc2421927b 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -1386,6 +1386,8 @@ mod tests { item: RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), cwd: latest_cwd.clone(), + current_date: None, + timezone: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), network: None, diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index ff4adcb091..54ee11fba4 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -44,14 +44,32 @@ fn text_user_input_parts(texts: Vec) -> serde_json::Value { }) } -fn default_env_context_str(cwd: &str, shell: &Shell) -> String { +fn assert_default_env_context(text: &str, cwd: &str, shell: &Shell) { let shell_name = shell.name(); - format!( - r#" - {cwd} - {shell_name} -"# - ) + assert!( + text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG), + "expected environment context fragment: {text}" + ); + assert!( + text.contains(&format!("{cwd}")), + "expected cwd in environment context: {text}" + ); + assert!( + text.contains(&format!("{shell_name}")), + "expected shell in environment context: {text}" + ); + assert!( + text.contains("") && text.contains(""), + "expected current_date in environment context: {text}" + ); + assert!( + text.contains("") && text.contains(""), + "expected timezone in environment context: {text}" + ); + assert!( + text.ends_with(""), + "expected closing environment_context tag: {text}" + ); } fn assert_tool_names(body: &serde_json::Value, expected_names: &[&str]) { @@ -318,10 +336,13 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests let shell = default_user_shell(); let cwd_str = config.cwd.to_string_lossy(); - let expected_env_text = default_env_context_str(&cwd_str, &shell); + let env_text = input1[1]["content"][1]["text"] + .as_str() + .expect("environment context text"); + assert_default_env_context(env_text, &cwd_str, &shell); assert_eq!( - input1[1]["content"][1]["text"].as_str(), - Some(expected_env_text.as_str()), + input1[1]["content"][1]["type"].as_str(), + Some("input_text"), "expected environment context bundled after UI message in cached contextual message" ); assert_eq!(input1[2], text_user_input("hello 1".to_string())); @@ -523,6 +544,18 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul !env_texts.is_empty(), "expected environment context to be emitted: {env_texts:?}" ); + assert!( + env_texts + .iter() + .any(|text| text.contains("") && text.contains("")), + "expected current_date in environment context: {env_texts:?}" + ); + assert!( + env_texts + .iter() + .any(|text| text.contains("") && text.contains("")), + "expected timezone in environment context: {env_texts:?}" + ); let env_count = input .iter() @@ -672,21 +705,6 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res "role": "user", "content": [ { "type": "input_text", "text": "hello 2" } ] }); - let shell = default_user_shell(); - - let expected_env_text_2 = format!( - r#" - {} - {} -"#, - new_cwd.path().display(), - shell.name() - ); - let expected_env_msg_2 = serde_json::json!({ - "type": "message", - "role": "user", - "content": [ { "type": "input_text", "text": expected_env_text_2 } ] - }); let expected_permissions_msg = body1["input"][0].clone(); let body1_input = body1["input"].as_array().expect("input array"); let expected_settings_update_msg = body2["input"][body1_input.len()].clone(); @@ -704,6 +722,14 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res }), "expected model switch section after model override: {expected_settings_update_msg:?}" ); + let expected_env_msg_2 = body2["input"][body1_input.len() + 1].clone(); + assert_eq!(expected_env_msg_2["role"].as_str(), Some("user")); + let env_text = expected_env_msg_2["content"][0]["text"] + .as_str() + .expect("environment context text"); + let shell = default_user_shell(); + let expected_cwd = new_cwd.path().display().to_string(); + assert_default_env_context(env_text, &expected_cwd, &shell); let mut expected_body2 = body1_input.to_vec(); expected_body2.push(expected_settings_update_msg); expected_body2.push(expected_env_msg_2); @@ -798,13 +824,18 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a let shell = default_user_shell(); let default_cwd_lossy = default_cwd.to_string_lossy(); + let expected_env_text_1 = expected_ui_msg["content"][1]["text"] + .as_str() + .expect("cached environment context text") + .to_string(); + assert_default_env_context(&expected_env_text_1, &default_cwd_lossy, &shell); let expected_contextual_user_msg_1 = text_user_input_parts(vec![ expected_ui_msg["content"][0]["text"] .as_str() .expect("cached user instructions text") .to_string(), - default_env_context_str(&default_cwd_lossy, &shell), + expected_env_text_1, ]); let expected_user_message_1 = text_user_input("hello 1".to_string()); @@ -911,7 +942,11 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu let expected_ui_msg = body1["input"][1].clone(); let shell = default_user_shell(); - let expected_env_text_1 = default_env_context_str(&default_cwd.to_string_lossy(), &shell); + let expected_env_text_1 = expected_ui_msg["content"][1]["text"] + .as_str() + .expect("cached environment context text") + .to_string(); + assert_default_env_context(&expected_env_text_1, &default_cwd.to_string_lossy(), &shell); let expected_contextual_user_msg_1 = text_user_input_parts(vec![ expected_ui_msg["content"][0]["text"] .as_str() diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index a5f22e9f8d..4742b417ae 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -28,6 +28,8 @@ fn resume_history( let turn_ctx = TurnContextItem { turn_id: Some(turn_id.clone()), cwd: config.cwd.clone(), + current_date: None, + timezone: None, approval_policy: config.permissions.approval_policy.value(), sandbox_policy: config.permissions.sandbox_policy.get().clone(), network: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7a0c2fb5e2..b5aaf47ea6 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2133,6 +2133,10 @@ pub struct TurnContextItem { #[serde(default, skip_serializing_if = "Option::is_none")] pub turn_id: Option, pub cwd: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_date: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timezone: Option, pub approval_policy: AskForApproval, pub sandbox_policy: SandboxPolicy, #[serde(skip_serializing_if = "Option::is_none")] @@ -3361,6 +3365,8 @@ mod tests { let item = TurnContextItem { turn_id: None, cwd: PathBuf::from("/tmp"), + current_date: None, + timezone: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, network: Some(TurnContextNetworkItem { diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 5d3afc86ad..f978238979 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -252,6 +252,8 @@ mod tests { &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), cwd: PathBuf::from("/parent/workspace"), + current_date: None, + timezone: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, network: None, @@ -286,6 +288,8 @@ mod tests { &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), cwd: PathBuf::from("/fallback/workspace"), + current_date: None, + timezone: None, approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), network: None, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5a4c87aef7..8dfdbcd871 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1272,6 +1272,8 @@ mod tests { TurnContextItem { turn_id: None, cwd, + current_date: None, + timezone: None, approval_policy: config.permissions.approval_policy.value(), sandbox_policy: config.permissions.sandbox_policy.get().clone(), network: None, From a11da86b376e21cd806d3847f79ea66d22f6011e Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 26 Feb 2026 16:09:00 -0800 Subject: [PATCH 6/7] Make realtime audio test deterministic (#12959) ## Summary\n- add a websocket test-server request waiter so tests can synchronize on recorded client messages\n- use that waiter in the realtime delegation test instead of a fixed audio timeout\n- add temporary timing logs in the test and websocket mock to inspect where the flake stalls --- codex-rs/core/tests/common/responses.rs | 68 +++++++++++++++++++ .../core/tests/suite/realtime_conversation.rs | 54 ++++++++++++--- 2 files changed, 111 insertions(+), 11 deletions(-) diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index ca50755722..6a073d9261 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -12,6 +12,7 @@ use futures::SinkExt; use futures::StreamExt; use serde_json::Value; use tokio::net::TcpListener; +use tokio::sync::Notify; use tokio::sync::oneshot; use tokio_tungstenite::accept_hdr_async_with_config; use tokio_tungstenite::tungstenite::Message; @@ -335,6 +336,7 @@ pub struct WebSocketTestServer { uri: String, connections: Arc>>>, handshakes: Arc>>, + request_log_updated: Arc, shutdown: oneshot::Sender<()>, task: tokio::task::JoinHandle<()>, } @@ -356,6 +358,26 @@ impl WebSocketTestServer { connections.first().cloned().unwrap_or_default() } + pub async fn wait_for_request( + &self, + connection_index: usize, + request_index: usize, + ) -> WebSocketRequest { + loop { + if let Some(request) = self + .connections + .lock() + .unwrap() + .get(connection_index) + .and_then(|connection| connection.get(request_index)) + .cloned() + { + return request; + } + self.request_log_updated.notified().await; + } + } + pub fn handshakes(&self) -> Vec { self.handshakes.lock().unwrap().clone() } @@ -1069,6 +1091,7 @@ pub async fn start_websocket_server(connections: Vec>>) -> WebSoc pub async fn start_websocket_server_with_headers( connections: Vec, ) -> WebSocketTestServer { + let start = std::time::Instant::now(); let listener = TcpListener::bind("127.0.0.1:0") .await .expect("bind websocket server"); @@ -1076,8 +1099,10 @@ pub async fn start_websocket_server_with_headers( let uri = format!("ws://{addr}"); let connections_log = Arc::new(Mutex::new(Vec::new())); let handshakes_log = Arc::new(Mutex::new(Vec::new())); + let request_log_updated = Arc::new(Notify::new()); let requests = Arc::clone(&connections_log); let handshakes = Arc::clone(&handshakes_log); + let request_log = Arc::clone(&request_log_updated); let connections = Arc::new(Mutex::new(VecDeque::from(connections))); let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); @@ -1159,9 +1184,51 @@ pub async fn start_websocket_server_with_headers( let mut log = requests.lock().unwrap(); if let Some(connection_log) = log.get_mut(connection_index) { connection_log.push(WebSocketRequest { body }); + let request_index = connection_log.len() - 1; + let request = &connection_log[request_index]; + let request_body = request.body_json(); + eprintln!( + "[ws test server +{}ms] connection={} received request={} type={:?} role={:?} text={:?} data={:?}", + start.elapsed().as_millis(), + connection_index, + request_index, + request_body.get("type").and_then(Value::as_str), + request_body + .get("item") + .and_then(|item| item.get("role")) + .and_then(Value::as_str), + request_body + .get("item") + .and_then(|item| item.get("content")) + .and_then(Value::as_array) + .and_then(|content| content.first()) + .and_then(|content| content.get("text")) + .and_then(Value::as_str), + request_body + .get("item") + .and_then(|item| item.get("content")) + .and_then(Value::as_array) + .and_then(|content| content.first()) + .and_then(|content| content.get("data")) + .and_then(Value::as_str), + ); } + request_log.notify_waiters(); } + eprintln!( + "[ws test server +{}ms] connection={} sending batch_size={} event_types={:?} audio_data={:?}", + start.elapsed().as_millis(), + connection_index, + request_events.len(), + request_events + .iter() + .map(|event| event.get("type").and_then(Value::as_str)) + .collect::>(), + request_events + .iter() + .find_map(|event| event.get("delta").and_then(Value::as_str)), + ); for event in &request_events { let Ok(payload) = serde_json::to_string(event) else { continue; @@ -1184,6 +1251,7 @@ pub async fn start_websocket_server_with_headers( uri, connections: connections_log, handshakes: handshakes_log, + request_log_updated, shutdown: shutdown_tx, task, } diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 98848e911c..1f7504d41b 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -715,6 +715,7 @@ async fn inbound_realtime_text_ignores_user_role_and_still_forwards_audio() -> R async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_audio() -> Result<()> { skip_if_no_network!(Ok(())); + let start = std::time::Instant::now(); let (gate_completed_tx, gate_completed_rx) = oneshot::channel(); let first_chunks = vec![ @@ -806,18 +807,45 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au _ => None, }) .await; + eprintln!( + "[realtime test +{}ms] saw trigger text={:?}", + start.elapsed().as_millis(), + "delegate now" + ); - let audio_out = tokio::time::timeout( - Duration::from_millis(500), - wait_for_event_match(&test.codex, |msg| match msg { - EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { - payload: RealtimeEvent::AudioOut(frame), - }) => Some(frame.clone()), - _ => None, - }), - ) - .await - .expect("timed out waiting for realtime audio after echoed user-role message"); + let mirrored_request = realtime_server.wait_for_request(0, 1).await; + let mirrored_request_body = mirrored_request.body_json(); + eprintln!( + "[realtime test +{}ms] saw mirrored request type={:?} role={:?} text={:?} data={:?}", + start.elapsed().as_millis(), + mirrored_request_body["type"].as_str(), + mirrored_request_body["item"]["role"].as_str(), + mirrored_request_body["item"]["content"][0]["text"].as_str(), + mirrored_request_body["item"]["content"][0]["data"].as_str(), + ); + assert_eq!( + mirrored_request_body["type"].as_str(), + Some("conversation.item.create") + ); + assert_eq!( + mirrored_request_body["item"]["content"][0]["text"].as_str(), + Some("assistant says hi") + ); + + let audio_out = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::AudioOut(frame), + }) => Some(frame.clone()), + _ => None, + }) + .await; + eprintln!( + "[realtime test +{}ms] saw audio out data={} sample_rate={} num_channels={}", + start.elapsed().as_millis(), + audio_out.data, + audio_out.sample_rate, + audio_out.num_channels + ); assert_eq!(audio_out.data, "AQID"); let completion = completions @@ -828,6 +856,10 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au completion .await .expect("delegated turn request did not complete"); + eprintln!( + "[realtime test +{}ms] delegated completion resolved", + start.elapsed().as_millis() + ); wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) }) From ce54e4725b0a69d2943a33eca69b3034265f743e Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 26 Feb 2026 17:11:06 -0800 Subject: [PATCH 7/7] notify: include client in legacy hook payload --- .../app-server/src/codex_message_processor.rs | 72 ++++++++++-- codex-rs/app-server/src/message_processor.rs | 4 +- .../app-server/tests/suite/v2/initialize.rs | 103 ++++++++++++++++++ codex-rs/core/src/codex.rs | 30 +++++ codex-rs/core/src/codex_thread.rs | 5 + codex-rs/core/src/tools/registry.rs | 1 + codex-rs/hooks/src/registry.rs | 2 + codex-rs/hooks/src/types.rs | 4 + codex-rs/hooks/src/user_notification.rs | 42 ++++--- codex-rs/tui/src/chatwidget/agent.rs | 15 +++ docs/config.md | 2 + 11 files changed, 255 insertions(+), 25 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 85dc08a5f9..a962d5cee0 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -560,7 +560,12 @@ impl CodexMessageProcessor { Ok((review_request, hint)) } - pub async fn process_request(&mut self, connection_id: ConnectionId, request: ClientRequest) { + pub async fn process_request( + &mut self, + connection_id: ConnectionId, + request: ClientRequest, + client_name: Option, + ) { let to_connection_request_id = |request_id| ConnectionRequestId { connection_id, request_id, @@ -647,8 +652,12 @@ impl CodexMessageProcessor { .await; } ClientRequest::TurnStart { request_id, params } => { - self.turn_start(to_connection_request_id(request_id), params) - .await; + self.turn_start( + to_connection_request_id(request_id), + params, + client_name.clone(), + ) + .await; } ClientRequest::TurnSteer { request_id, params } => { self.turn_steer(to_connection_request_id(request_id), params) @@ -767,12 +776,20 @@ impl CodexMessageProcessor { .await; } ClientRequest::SendUserMessage { request_id, params } => { - self.send_user_message(to_connection_request_id(request_id), params) - .await; + self.send_user_message( + to_connection_request_id(request_id), + params, + client_name.clone(), + ) + .await; } ClientRequest::SendUserTurn { request_id, params } => { - self.send_user_turn(to_connection_request_id(request_id), params) - .await; + self.send_user_turn( + to_connection_request_id(request_id), + params, + client_name.clone(), + ) + .await; } ClientRequest::InterruptConversation { request_id, params } => { self.interrupt_conversation(to_connection_request_id(request_id), params) @@ -5062,6 +5079,7 @@ impl CodexMessageProcessor { &self, request_id: ConnectionRequestId, params: SendUserMessageParams, + client_name: Option, ) { let SendUserMessageParams { conversation_id, @@ -5080,6 +5098,10 @@ impl CodexMessageProcessor { self.outgoing.send_error(request_id, error).await; return; }; + if let Err(error) = Self::set_notify_client(conversation.as_ref(), client_name).await { + self.outgoing.send_error(request_id, error).await; + return; + } let mapped_items: Vec = items .into_iter() @@ -5110,7 +5132,12 @@ impl CodexMessageProcessor { .await; } - async fn send_user_turn(&self, request_id: ConnectionRequestId, params: SendUserTurnParams) { + async fn send_user_turn( + &self, + request_id: ConnectionRequestId, + params: SendUserTurnParams, + client_name: Option, + ) { let SendUserTurnParams { conversation_id, items, @@ -5136,6 +5163,10 @@ impl CodexMessageProcessor { self.outgoing.send_error(request_id, error).await; return; }; + if let Err(error) = Self::set_notify_client(conversation.as_ref(), client_name).await { + self.outgoing.send_error(request_id, error).await; + return; + } let mapped_items: Vec = items .into_iter() @@ -5607,7 +5638,12 @@ impl CodexMessageProcessor { let _ = conversation.submit(Op::Interrupt).await; } - async fn turn_start(&self, request_id: ConnectionRequestId, params: TurnStartParams) { + async fn turn_start( + &self, + request_id: ConnectionRequestId, + params: TurnStartParams, + client_name: Option, + ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { self.outgoing.send_error(request_id, error).await; return; @@ -5619,6 +5655,10 @@ impl CodexMessageProcessor { return; } }; + if let Err(error) = Self::set_notify_client(thread.as_ref(), client_name).await { + self.outgoing.send_error(request_id, error).await; + return; + } let collaboration_modes_config = CollaborationModesConfig { default_mode_request_user_input: thread.enabled(Feature::DefaultModeRequestUserInput), @@ -5700,6 +5740,20 @@ impl CodexMessageProcessor { } } + async fn set_notify_client( + thread: &CodexThread, + client_name: Option, + ) -> Result<(), JSONRPCErrorError> { + match thread.set_notify_client(client_name).await { + Ok(()) => Ok(()), + Err(err) => Err(JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to set notify client: {err}"), + data: None, + }), + } + } + async fn turn_steer(&self, request_id: ConnectionRequestId, params: TurnSteerParams) { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 9f1b182cfa..aaa75252c2 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -140,6 +140,7 @@ pub(crate) struct ConnectionSessionState { pub(crate) initialized: bool, pub(crate) experimental_api_enabled: bool, pub(crate) opted_out_notification_methods: HashSet, + pub(crate) client_name: Option, } pub(crate) struct MessageProcessorArgs { @@ -329,6 +330,7 @@ impl MessageProcessor { if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() { *suffix = Some(user_agent_suffix); } + session.client_name = Some(name.clone()); let user_agent = get_codex_user_agent(); let response = InitializeResponse { user_agent }; @@ -430,7 +432,7 @@ impl MessageProcessor { // inline the full `CodexMessageProcessor::process_request` future, which // can otherwise push worker-thread stack usage over the edge. self.codex_message_processor - .process_request(connection_id, other) + .process_request(connection_id, other, session.client_name.clone()) .boxed() .await; } diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index 2edc83a7f4..52da448a4c 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -1,16 +1,24 @@ use anyhow::Result; use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::to_response; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeResponse; use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use core_test_support::fs_wait; use pretty_assertions::assert_eq; +use serde_json::Value; use std::path::Path; +use std::time::Duration; use tempfile::TempDir; use tokio::time::timeout; @@ -178,11 +186,100 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu Ok(()) } +#[tokio::test] +async fn turn_start_notify_payload_includes_initialize_client_name() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + let codex_home = TempDir::new()?; + let notify_script = codex_home.path().join("notify.py"); + std::fs::write( + ¬ify_script, + r#"from pathlib import Path +import sys + +Path(__file__).with_name("notify.json").write_text(sys.argv[-1], encoding="utf-8") +"#, + )?; + let notify_file = codex_home.path().join("notify.json"); + let notify_script = notify_script + .to_str() + .expect("notify script path should be valid UTF-8"); + create_config_toml_with_extra( + codex_home.path(), + &server.uri(), + "never", + &format!( + "notify = [\"python3\", {}]", + toml_basic_string(notify_script) + ), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.initialize_with_client_info(ClientInfo { + name: "xcode".to_string(), + title: Some("Xcode".to_string()), + version: "1.0.0".to_string(), + }), + ) + .await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _: TurnStartResponse = to_response(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + fs_wait::wait_for_path_exists(¬ify_file, Duration::from_secs(5)).await?; + let payload_raw = tokio::fs::read_to_string(¬ify_file).await?; + let payload: Value = serde_json::from_str(&payload_raw)?; + assert_eq!(payload["client"], "xcode"); + + Ok(()) +} + // Helper to create a config.toml pointing at the mock model server. fn create_config_toml( codex_home: &Path, server_uri: &str, approval_policy: &str, +) -> std::io::Result<()> { + create_config_toml_with_extra(codex_home, server_uri, approval_policy, "") +} + +fn create_config_toml_with_extra( + codex_home: &Path, + server_uri: &str, + approval_policy: &str, + extra: &str, ) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); std::fs::write( @@ -195,6 +292,8 @@ sandbox_mode = "read-only" model_provider = "mock_provider" +{extra} + [model_providers.mock_provider] name = "Mock provider for test" base_url = "{server_uri}/v1" @@ -205,3 +304,7 @@ stream_max_retries = 0 ), ) } + +fn toml_basic_string(value: &str) -> String { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) +} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6d583e46ed..9d42b42768 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -443,6 +443,7 @@ impl Codex { thread_name: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name, + notify_client: None, session_source, dynamic_tools, persist_extended_history, @@ -530,6 +531,18 @@ impl Codex { self.session.steer_input(input, expected_turn_id).await } + pub(crate) async fn set_notify_client( + &self, + notify_client: Option, + ) -> ConstraintResult<()> { + self.session + .update_settings(SessionSettingsUpdate { + notify_client, + ..Default::default() + }) + .await + } + pub(crate) async fn agent_status(&self) -> AgentStatus { self.agent_status.borrow().clone() } @@ -599,6 +612,7 @@ pub(crate) struct TurnContext { pub(crate) cwd: PathBuf, pub(crate) current_date: Option, pub(crate) timezone: Option, + pub(crate) notify_client: Option, pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, @@ -685,6 +699,7 @@ impl TurnContext { cwd: self.cwd.clone(), current_date: self.current_date.clone(), timezone: self.timezone.clone(), + notify_client: self.notify_client.clone(), developer_instructions: self.developer_instructions.clone(), compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), @@ -812,6 +827,7 @@ pub(crate) struct SessionConfiguration { original_config_do_not_use: Arc, /// Optional service name tag for session metrics. metrics_service_name: Option, + notify_client: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, dynamic_tools: Vec, @@ -859,6 +875,9 @@ impl SessionConfiguration { if let Some(cwd) = updates.cwd.clone() { next_configuration.cwd = cwd; } + if let Some(notify_client) = updates.notify_client.clone() { + next_configuration.notify_client = Some(notify_client); + } Ok(next_configuration) } } @@ -873,6 +892,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) reasoning_summary: Option, pub(crate) final_output_json_schema: Option>, pub(crate) personality: Option, + pub(crate) notify_client: Option, } impl Session { @@ -1049,6 +1069,7 @@ impl Session { cwd, current_date: Some(current_date), timezone: Some(timezone), + notify_client: session_configuration.notify_client.clone(), developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), @@ -3948,6 +3969,7 @@ mod handlers { reasoning_summary: summary, final_output_json_schema: Some(final_output_json_schema), personality, + notify_client: None, }, ) } @@ -4699,6 +4721,7 @@ async fn spawn_review_thread( ghost_snapshot: parent_turn_context.ghost_snapshot.clone(), current_date: parent_turn_context.current_date.clone(), timezone: parent_turn_context.timezone.clone(), + notify_client: parent_turn_context.notify_client.clone(), developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), @@ -5084,6 +5107,7 @@ pub(crate) async fn run_turn( .dispatch(HookPayload { session_id: sess.conversation_id, cwd: turn_context.cwd.clone(), + client: turn_context.notify_client.clone(), triggered_at: chrono::Utc::now(), hook_event: HookEvent::AfterAgent { event: HookEventAfterAgent { @@ -7893,6 +7917,7 @@ mod tests { thread_name: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, + notify_client: None, session_source: SessionSource::Exec, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -7985,6 +8010,7 @@ mod tests { thread_name: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, + notify_client: None, session_source: SessionSource::Exec, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -8296,6 +8322,7 @@ mod tests { thread_name: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, + notify_client: None, session_source: SessionSource::Exec, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -8349,6 +8376,7 @@ mod tests { thread_name: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, + notify_client: None, session_source: SessionSource::Exec, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -8430,6 +8458,7 @@ mod tests { thread_name: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, + notify_client: None, session_source: SessionSource::Exec, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -8589,6 +8618,7 @@ mod tests { thread_name: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, + notify_client: None, session_source: SessionSource::Exec, dynamic_tools, persist_extended_history: false, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index b493075d4a..f2701011fe 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -1,6 +1,7 @@ use crate::agent::AgentStatus; use crate::codex::Codex; use crate::codex::SteerInputError; +use crate::config::ConstraintResult; use crate::error::Result as CodexResult; use crate::features::Feature; use crate::file_watcher::WatchRegistration; @@ -67,6 +68,10 @@ impl CodexThread { self.codex.steer_input(input, expected_turn_id).await } + pub async fn set_notify_client(&self, notify_client: Option) -> ConstraintResult<()> { + self.codex.set_notify_client(notify_client).await + } + /// Use sparingly: this is intended to be removed soon. pub async fn submit_with_id(&self, sub: Submission) -> CodexResult<()> { self.codex.submit_with_id(sub).await diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 7a0be68df3..cd39e9458c 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -379,6 +379,7 @@ async fn dispatch_after_tool_use_hook( .dispatch(HookPayload { session_id: session.conversation_id, cwd: turn.cwd.clone(), + client: turn.notify_client.clone(), triggered_at: chrono::Utc::now(), hook_event: HookEvent::AfterToolUse { event: HookEventAfterToolUse { diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 6568f5374f..1648fa0462 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -104,6 +104,7 @@ mod tests { HookPayload { session_id: ThreadId::new(), cwd: PathBuf::from(CWD), + client: None, triggered_at: Utc .with_ymd_and_hms(2025, 1, 1, 0, 0, 0) .single() @@ -172,6 +173,7 @@ mod tests { HookPayload { session_id: ThreadId::new(), cwd: PathBuf::from(CWD), + client: None, triggered_at: Utc .with_ymd_and_hms(2025, 1, 1, 0, 0, 0) .single() diff --git a/codex-rs/hooks/src/types.rs b/codex-rs/hooks/src/types.rs index 5131580128..b7ef3c2141 100644 --- a/codex-rs/hooks/src/types.rs +++ b/codex-rs/hooks/src/types.rs @@ -65,6 +65,8 @@ impl Hook { pub struct HookPayload { pub session_id: ThreadId, pub cwd: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + pub client: Option, #[serde(serialize_with = "serialize_triggered_at")] pub triggered_at: DateTime, pub hook_event: HookEvent, @@ -181,6 +183,7 @@ mod tests { let payload = HookPayload { session_id, cwd: PathBuf::from("tmp"), + client: None, triggered_at: Utc .with_ymd_and_hms(2025, 1, 1, 0, 0, 0) .single() @@ -218,6 +221,7 @@ mod tests { let payload = HookPayload { session_id, cwd: PathBuf::from("tmp"), + client: None, triggered_at: Utc .with_ymd_and_hms(2025, 1, 1, 0, 0, 0) .single() diff --git a/codex-rs/hooks/src/user_notification.rs b/codex-rs/hooks/src/user_notification.rs index 50472225e4..caeca59b50 100644 --- a/codex-rs/hooks/src/user_notification.rs +++ b/codex-rs/hooks/src/user_notification.rs @@ -1,4 +1,3 @@ -use std::path::Path; use std::process::Stdio; use std::sync::Arc; @@ -19,6 +18,8 @@ enum UserNotification { thread_id: String, turn_id: String, cwd: String, + #[serde(skip_serializing_if = "Option::is_none")] + client: Option, /// Messages that the user sent to the agent to initiate the turn. input_messages: Vec, @@ -28,13 +29,14 @@ enum UserNotification { }, } -pub fn legacy_notify_json(hook_event: &HookEvent, cwd: &Path) -> Result { - match hook_event { +pub fn legacy_notify_json(payload: &HookPayload) -> Result { + match &payload.hook_event { HookEvent::AfterAgent { event } => { serde_json::to_string(&UserNotification::AgentTurnComplete { thread_id: event.thread_id.to_string(), turn_id: event.turn_id.clone(), - cwd: cwd.display().to_string(), + cwd: payload.cwd.display().to_string(), + client: payload.client.clone(), input_messages: event.input_messages.clone(), last_assistant_message: event.last_assistant_message.clone(), }) @@ -56,7 +58,7 @@ pub fn notify_hook(argv: Vec) -> Hook { Some(command) => command, None => return HookResult::Success, }; - if let Ok(notify_payload) = legacy_notify_json(&payload.hook_event, &payload.cwd) { + if let Ok(notify_payload) = legacy_notify_json(payload) { command.arg(notify_payload); } @@ -91,6 +93,7 @@ mod tests { "thread-id": "b5f6c1c2-1111-2222-3333-444455556666", "turn-id": "12345", "cwd": "/Users/example/project", + "client": "codex-tui", "input-messages": ["Rename `foo` to `bar` and update the callsites."], "last-assistant-message": "Rename complete and verified `cargo build` succeeds.", }) @@ -102,6 +105,7 @@ mod tests { thread_id: "b5f6c1c2-1111-2222-3333-444455556666".to_string(), turn_id: "12345".to_string(), cwd: "/Users/example/project".to_string(), + client: Some("codex-tui".to_string()), input_messages: vec!["Rename `foo` to `bar` and update the callsites.".to_string()], last_assistant_message: Some( "Rename complete and verified `cargo build` succeeds.".to_string(), @@ -115,19 +119,27 @@ mod tests { #[test] fn legacy_notify_json_matches_historical_wire_shape() -> Result<()> { - let hook_event = HookEvent::AfterAgent { - event: crate::HookEventAfterAgent { - thread_id: ThreadId::from_string("b5f6c1c2-1111-2222-3333-444455556666") - .expect("valid thread id"), - turn_id: "12345".to_string(), - input_messages: vec!["Rename `foo` to `bar` and update the callsites.".to_string()], - last_assistant_message: Some( - "Rename complete and verified `cargo build` succeeds.".to_string(), - ), + let payload = HookPayload { + session_id: ThreadId::new(), + cwd: std::path::Path::new("/Users/example/project").to_path_buf(), + client: Some("codex-tui".to_string()), + triggered_at: chrono::Utc::now(), + hook_event: HookEvent::AfterAgent { + event: crate::HookEventAfterAgent { + thread_id: ThreadId::from_string("b5f6c1c2-1111-2222-3333-444455556666") + .expect("valid thread id"), + turn_id: "12345".to_string(), + input_messages: vec![ + "Rename `foo` to `bar` and update the callsites.".to_string(), + ], + last_assistant_message: Some( + "Rename complete and verified `cargo build` succeeds.".to_string(), + ), + }, }, }; - let serialized = legacy_notify_json(&hook_event, Path::new("/Users/example/project"))?; + let serialized = legacy_notify_json(&payload)?; let actual: Value = serde_json::from_str(&serialized)?; assert_eq!(actual, expected_notification_json()); diff --git a/codex-rs/tui/src/chatwidget/agent.rs b/codex-rs/tui/src/chatwidget/agent.rs index 63d519a7a5..7ed5cd1cff 100644 --- a/codex-rs/tui/src/chatwidget/agent.rs +++ b/codex-rs/tui/src/chatwidget/agent.rs @@ -13,6 +13,17 @@ use tokio::sync::mpsc::unbounded_channel; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +const TUI_NOTIFY_CLIENT: &str = "codex-tui"; + +async fn initialize_notify_client(thread: &CodexThread) { + if let Err(err) = thread + .set_notify_client(Some(TUI_NOTIFY_CLIENT.to_string())) + .await + { + tracing::error!("failed to set notify client: {err}"); + } +} + /// Spawn the agent bootstrapper and op forwarding loop, returning the /// `UnboundedSender` used by the UI to submit operations. pub(crate) fn spawn_agent( @@ -42,6 +53,7 @@ pub(crate) fn spawn_agent( return; } }; + initialize_notify_client(thread.as_ref()).await; // Forward the captured `SessionConfigured` event so it can be rendered in the UI. let ev = codex_protocol::protocol::Event { @@ -87,6 +99,8 @@ pub(crate) fn spawn_agent_from_existing( let app_event_tx_clone = app_event_tx; tokio::spawn(async move { + initialize_notify_client(thread.as_ref()).await; + // Forward the captured `SessionConfigured` event so it can be rendered in the UI. let ev = codex_protocol::protocol::Event { id: "".to_string(), @@ -123,6 +137,7 @@ pub(crate) fn spawn_op_forwarder(thread: std::sync::Arc) -> Unbound let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); tokio::spawn(async move { + initialize_notify_client(thread.as_ref()).await; while let Some(op) = codex_op_rx.recv().await { if let Err(e) = thread.submit(op).await { tracing::error!("failed to submit op: {e}"); diff --git a/docs/config.md b/docs/config.md index 30665bb11b..fc9d62b8e8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -24,6 +24,8 @@ Codex can run a notification hook when the agent finishes a turn. See the config - https://developers.openai.com/codex/config-reference +When Codex knows which client started the turn, the legacy notify JSON payload also includes a top-level `client` field. The TUI reports `codex-tui`, and the app server reports the `clientInfo.name` value from `initialize`. + ## JSON Schema The generated JSON Schema for `config.toml` lives at `codex-rs/core/config.schema.json`.