From 8b3521ee77ad2ac181afe61ff3b1c0c88e97e53d Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Thu, 22 Jan 2026 12:04:23 -0800 Subject: [PATCH] feat(core) update Personality on turn (#9644) ## Summary Support updating Personality mid-Thread via UserTurn/OverwriteTurn. This is explicitly unused by the clients so far, to simplify PRs - app-server and tui implementations will be follow-ups. ## Testing - [x] added integration tests --- .../app-server/src/codex_message_processor.rs | 2 + codex-rs/core/src/codex.rs | 57 ++- codex-rs/core/src/compact.rs | 2 + codex-rs/core/src/compact_remote.rs | 2 +- codex-rs/core/src/context_manager/history.rs | 4 +- codex-rs/core/tests/common/responses.rs | 7 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 7 + codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 1 + .../tests/suite/collaboration_instructions.rs | 12 + codex-rs/core/tests/suite/compact.rs | 1 + codex-rs/core/tests/suite/exec_policy.rs | 1 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/model_overrides.rs | 2 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/override_updates.rs | 3 + .../core/tests/suite/permissions_messages.rs | 3 + codex-rs/core/tests/suite/personality.rs | 400 ++++++++++++++++++ codex-rs/core/tests/suite/prompt_caching.rs | 7 + codex-rs/core/tests/suite/remote_models.rs | 4 + .../core/tests/suite/request_user_input.rs | 2 + codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/core/tests/suite/review.rs | 1 + codex-rs/core/tests/suite/rmcp_client.rs | 6 + codex-rs/core/tests/suite/shell_snapshot.rs | 3 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 25 ++ codex-rs/core/tests/suite/view_image.rs | 6 + codex-rs/docs/codex_mcp_interface.md | 2 +- codex-rs/docs/protocol_v1.md | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/protocol/src/models.rs | 7 + codex-rs/protocol/src/protocol.rs | 11 + codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/chatwidget.rs | 5 + codex-rs/tui/src/chatwidget/tests.rs | 5 + 42 files changed, 604 insertions(+), 4 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 57a13bdf1..d0021ef4a 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -3288,6 +3288,7 @@ impl CodexMessageProcessor { summary, final_output_json_schema: output_schema, collaboration_mode: None, + personality: None, }) .await; @@ -3415,6 +3416,7 @@ impl CodexMessageProcessor { effort: params.effort.map(Some), summary: params.summary, collaboration_mode: params.collaboration_mode, + personality: None, }) .await; } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 9ca11cd73..1edf67ae6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -166,6 +166,7 @@ use crate::util::backoff; use codex_async_utils::OrCancelExt; use codex_otel::OtelManager; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::ContentItem; use codex_protocol::models::DeveloperInstructions; @@ -301,6 +302,7 @@ impl Codex { model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions, + personality: config.model_personality, base_instructions, compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), @@ -415,6 +417,7 @@ pub(crate) struct TurnContext { pub(crate) developer_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, + pub(crate) personality: Option, pub(crate) approval_policy: AskForApproval, pub(crate) sandbox_policy: SandboxPolicy, pub(crate) shell_environment_policy: ShellEnvironmentPolicy, @@ -454,6 +457,9 @@ pub(crate) struct SessionConfiguration { /// Model instructions that are appended to the base instructions. user_instructions: Option, + /// Personality preference for the model. + personality: Option, + /// Base instructions for the session. base_instructions: String, @@ -489,6 +495,9 @@ impl SessionConfiguration { if let Some(summary) = updates.reasoning_summary { next_configuration.model_reasoning_summary = summary; } + if let Some(personality) = updates.personality { + next_configuration.personality = Some(personality); + } if let Some(approval_policy) = updates.approval_policy { next_configuration.approval_policy.set(approval_policy)?; } @@ -510,6 +519,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) collaboration_mode: Option, pub(crate) reasoning_summary: Option, pub(crate) final_output_json_schema: Option>, + pub(crate) personality: Option, } impl Session { @@ -521,6 +531,7 @@ impl Session { per_turn_config.model_reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; + per_turn_config.model_personality = session_configuration.personality; per_turn_config.features = config.features.clone(); per_turn_config } @@ -566,6 +577,7 @@ impl Session { developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), + personality: session_configuration.personality, approval_policy: session_configuration.approval_policy.value(), sandbox_policy: session_configuration.sandbox_policy.get().clone(), shell_environment_policy: per_turn_config.shell_environment_policy.clone(), @@ -1109,6 +1121,34 @@ impl Session { ) } + fn build_personality_update_item( + &self, + previous: Option<&Arc>, + next: &TurnContext, + ) -> Option { + let personality = next.personality?; + if let Some(prev) = previous + && prev.personality == Some(personality) + { + return None; + } + let model_info = next.client.get_model_info(); + let personality_message = Self::personality_message_for(&model_info, personality); + + personality_message.map(|personality_message| { + DeveloperInstructions::personality_spec_message(personality_message).into() + }) + } + + fn personality_message_for(model_info: &ModelInfo, personality: Personality) -> Option { + model_info + .model_instructions_template + .as_ref() + .and_then(|template| template.personality_messages.as_ref()) + .and_then(|messages| messages.0.get(&personality)) + .cloned() + } + fn build_collaboration_mode_update_item( &self, previous_collaboration_mode: &CollaborationMode, @@ -1150,6 +1190,11 @@ impl Session { ) { update_items.push(collaboration_mode_item); } + if let Some(personality_item) = + self.build_personality_update_item(previous_context, current_context) + { + update_items.push(personality_item); + } update_items } @@ -2014,6 +2059,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv effort, summary, collaboration_mode, + personality, } => { let collaboration_mode = if let Some(collab_mode) = collaboration_mode { collab_mode @@ -2034,6 +2080,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv sandbox_policy, collaboration_mode: Some(collaboration_mode), reasoning_summary: summary, + personality, ..Default::default() }, ) @@ -2218,6 +2265,7 @@ mod handlers { final_output_json_schema, items, collaboration_mode, + personality, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode::Custom(Settings { @@ -2235,6 +2283,7 @@ mod handlers { collaboration_mode, reasoning_summary: Some(summary), final_output_json_schema: Some(final_output_json_schema), + personality, }, ) } @@ -2708,6 +2757,7 @@ async fn spawn_review_thread( developer_instructions: None, user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), + personality: parent_turn_context.personality, approval_policy: parent_turn_context.approval_policy, sandbox_policy: parent_turn_context.sandbox_policy.clone(), shell_environment_policy: parent_turn_context.shell_environment_policy.clone(), @@ -2997,7 +3047,7 @@ async fn run_sampling_request( tools: router.specs(), parallel_tool_calls: model_supports_parallel, base_instructions, - personality: None, + personality: turn_context.personality, output_schema: turn_context.final_output_json_schema.clone(), }; @@ -3116,6 +3166,7 @@ async fn try_run_sampling_request( approval_policy: turn_context.approval_policy, sandbox_policy: turn_context.sandbox_policy.clone(), model: turn_context.client.get_model(), + personality: turn_context.personality, collaboration_mode: Some(collaboration_mode), effort: turn_context.client.get_reasoning_effort(), summary: turn_context.client.get_reasoning_summary(), @@ -3748,6 +3799,7 @@ mod tests { model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), + personality: config.model_personality, base_instructions: config .base_instructions .clone() @@ -3823,6 +3875,7 @@ mod tests { model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), + personality: config.model_personality, base_instructions: config .base_instructions .clone() @@ -4082,6 +4135,7 @@ mod tests { model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), + personality: config.model_personality, base_instructions: config .base_instructions .clone() @@ -4186,6 +4240,7 @@ mod tests { model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), + personality: config.model_personality, base_instructions: config .base_instructions .clone() diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 5c3d56f8d..c365d2cfd 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -94,6 +94,7 @@ async fn run_compact_task_inner( approval_policy: turn_context.approval_policy, sandbox_policy: turn_context.sandbox_policy.clone(), model: turn_context.client.get_model(), + personality: turn_context.personality, collaboration_mode: Some(collaboration_mode), effort: turn_context.client.get_reasoning_effort(), summary: turn_context.client.get_reasoning_summary(), @@ -111,6 +112,7 @@ async fn run_compact_task_inner( let prompt = Prompt { input: turn_input, base_instructions: sess.get_base_instructions().await, + personality: turn_context.personality, ..Default::default() }; let attempt_result = drain_to_completed(&sess, turn_context.as_ref(), &prompt).await; diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index ff5ca5301..aaa7fc68a 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -55,7 +55,7 @@ async fn run_remote_compact_task_inner_impl( tools: vec![], parallel_tool_calls: false, base_instructions: sess.get_base_instructions().await, - personality: None, + personality: turn_context.personality, output_schema: None, }; diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index ff1a65cf4..080b701d4 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -86,7 +86,9 @@ impl ContextManager { // This is a coarse lower bound, not a tokenizer-accurate count. pub(crate) fn estimate_token_count(&self, turn_context: &TurnContext) -> Option { let model_info = turn_context.client.get_model_info(); - let personality = turn_context.client.config().model_personality; + let personality = turn_context + .personality + .or(turn_context.client.config().model_personality); let base_instructions = model_info.get_model_instructions(personality); let base_tokens = i64::try_from(approx_token_count(&base_instructions)).unwrap_or(i64::MAX); diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 3896911cc..881e6f794 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -85,6 +85,13 @@ impl ResponsesRequest { self.0.body.clone() } + pub fn instructions_text(&self) -> String { + self.body_json()["instructions"] + .as_str() + .unwrap() + .to_string() + } + /// Returns all `input_text` spans from `message` inputs for the provided role. pub fn message_input_texts(&self, role: &str) -> Vec { self.inputs_of_type("message") diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 9a902b6d1..3d867d592 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -280,6 +280,7 @@ impl TestCodex { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 69147843c..ccfa9fe65 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -312,6 +312,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -900,6 +901,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -979,6 +981,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -1128,6 +1131,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -1190,6 +1194,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -1260,6 +1265,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -1330,6 +1336,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 18abb2f18..ad1881f94 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -502,6 +502,7 @@ async fn submit_turn( effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 223159989..eb08de075 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -907,6 +907,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re summary: config.model_reasoning_summary, collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, + personality: None, }) .await?; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index c0df7e5ec..330411169 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -104,6 +104,7 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu effort: None, summary: None, collaboration_mode: Some(collaboration_mode), + personality: None, }) .await?; @@ -151,6 +152,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { summary: test.config.model_reasoning_summary, collaboration_mode: Some(collaboration_mode), final_output_json_schema: None, + personality: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -183,6 +185,7 @@ async fn override_then_user_turn_uses_updated_collaboration_instructions() -> Re effort: None, summary: None, collaboration_mode: Some(collaboration_mode), + personality: None, }) .await?; @@ -200,6 +203,7 @@ async fn override_then_user_turn_uses_updated_collaboration_instructions() -> Re summary: test.config.model_reasoning_summary, collaboration_mode: None, final_output_json_schema: None, + personality: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -234,6 +238,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu effort: None, summary: None, collaboration_mode: Some(base_mode), + personality: None, }) .await?; @@ -251,6 +256,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu summary: test.config.model_reasoning_summary, collaboration_mode: Some(turn_mode), final_output_json_schema: None, + personality: None, }) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -286,6 +292,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> effort: None, summary: None, collaboration_mode: Some(collab_mode_with_instructions(Some(first_text))), + personality: None, }) .await?; @@ -309,6 +316,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> effort: None, summary: None, collaboration_mode: Some(collab_mode_with_instructions(Some(second_text))), + personality: None, }) .await?; @@ -353,6 +361,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { effort: None, summary: None, collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), + personality: None, }) .await?; @@ -376,6 +385,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { effort: None, summary: None, collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), + personality: None, }) .await?; @@ -422,6 +432,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { effort: None, summary: None, collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), + personality: None, }) .await?; @@ -476,6 +487,7 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { effort: None, summary: None, collaboration_mode: Some(collab_mode_with_instructions(Some(""))), + personality: None, }) .await?; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 94f768b77..230ab5784 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1292,6 +1292,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 70a315334..835d940cd 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -82,6 +82,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index e18f32412..b6dad6a22 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -128,6 +128,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -208,6 +209,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 06d42e8a9..b76d7ac89 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -86,6 +86,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_overrides.rs b/codex-rs/core/tests/suite/model_overrides.rs index 9501bc06d..d2190653e 100644 --- a/codex-rs/core/tests/suite/model_overrides.rs +++ b/codex-rs/core/tests/suite/model_overrides.rs @@ -41,6 +41,7 @@ async fn override_turn_context_does_not_persist_when_config_exists() { effort: Some(Some(ReasoningEffort::High)), summary: None, collaboration_mode: None, + personality: None, }) .await .expect("submit override"); @@ -84,6 +85,7 @@ async fn override_turn_context_does_not_create_config_file() { effort: Some(Some(ReasoningEffort::Medium)), summary: None, collaboration_mode: None, + personality: None, }) .await .expect("submit override"); diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 82fbe05cb..69506cffc 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -99,6 +99,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index c264b5da9..413616b9d 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -110,6 +110,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; diff --git a/codex-rs/core/tests/suite/override_updates.rs b/codex-rs/core/tests/suite/override_updates.rs index ddacd97ab..924fde733 100644 --- a/codex-rs/core/tests/suite/override_updates.rs +++ b/codex-rs/core/tests/suite/override_updates.rs @@ -118,6 +118,7 @@ async fn override_turn_context_records_permissions_update() -> Result<()> { effort: None, summary: None, collaboration_mode: None, + personality: None, }) .await?; @@ -160,6 +161,7 @@ async fn override_turn_context_records_environment_update() -> Result<()> { effort: None, summary: None, collaboration_mode: None, + personality: None, }) .await?; @@ -196,6 +198,7 @@ async fn override_turn_context_records_collaboration_update() -> Result<()> { effort: None, summary: None, collaboration_mode: Some(collaboration_mode), + personality: None, }) .await?; diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index af61e54a5..9a32a7d9f 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -110,6 +110,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { effort: None, summary: None, collaboration_mode: None, + personality: None, }) .await?; @@ -226,6 +227,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { effort: None, summary: None, collaboration_mode: None, + personality: None, }) .await?; @@ -303,6 +305,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { effort: None, summary: None, collaboration_mode: None, + personality: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index cca15cd8f..c17994cbc 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -1,8 +1,49 @@ use codex_core::config::types::Personality; +use codex_core::features::Feature; use codex_core::models_manager::manager::ModelsManager; +use codex_core::models_manager::manager::RefreshStrategy; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::openai_models::ConfigShellToolType; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelInstructionsTemplate; +use codex_protocol::openai_models::ModelVisibility; +use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::openai_models::PersonalityMessages; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; +use codex_protocol::openai_models::TruncationPolicyConfig; +use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_models_once; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; use pretty_assertions::assert_eq; +use std::collections::BTreeMap; +use std::sync::Arc; use tempfile::TempDir; +use tokio::time::Duration; +use tokio::time::Instant; +use tokio::time::sleep; +use wiremock::BodyPrintLimit; +use wiremock::MockServer; + +const LOCAL_FRIENDLY_TEMPLATE: &str = + "You optimize for team morale and being a supportive teammate as much as code quality."; + +fn sse_completed(id: &str) -> String { + sse(vec![ev_response_created(id), ev_completed(id)]) +} #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn model_personality_does_not_mutate_base_instructions_without_template() { @@ -32,3 +73,362 @@ async fn base_instructions_override_disables_personality_template() { "override instructions" ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let resp_mock = mount_sse_once(&server, sse_completed("resp-1")).await; + let mut builder = test_codex().with_model("gpt-5.2-codex"); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: test.config.approval_policy.value(), + sandbox_policy: SandboxPolicy::ReadOnly, + model: test.session_configured.model.clone(), + effort: test.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request = resp_mock.single_request(); + let developer_texts = request.message_input_texts("developer"); + assert!( + !developer_texts + .iter() + .any(|text| text.contains("")), + "did not expect a personality update message when personality is None" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_personality_some_sets_instructions_template() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let resp_mock = mount_sse_once(&server, sse_completed("resp-1")).await; + let mut builder = test_codex() + .with_model("gpt-5.2-codex") + .with_config(|config| { + config.model_personality = Some(Personality::Friendly); + config.features.disable(Feature::RemoteModels); + }); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: test.config.approval_policy.value(), + sandbox_policy: SandboxPolicy::ReadOnly, + model: test.session_configured.model.clone(), + effort: test.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request = resp_mock.single_request(); + let instructions_text = request.instructions_text(); + + assert!( + instructions_text.contains(LOCAL_FRIENDLY_TEMPLATE), + "expected personality update to include the local friendly template, got: {instructions_text:?}" + ); + + let developer_texts = request.message_input_texts("developer"); + for text in developer_texts { + assert!( + !text.contains(""), + "expected no personality update message in developer input" + ); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let resp_mock = mount_sse_sequence( + &server, + vec![sse_completed("resp-1"), sse_completed("resp-2")], + ) + .await; + let mut builder = test_codex() + .with_model("gpt-5.2-codex") + .with_config(|config| { + config.features.disable(Feature::RemoteModels); + }); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: test.config.approval_policy.value(), + sandbox_policy: SandboxPolicy::ReadOnly, + model: test.session_configured.model.clone(), + effort: test.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + collaboration_mode: None, + personality: Some(Personality::Friendly), + }) + .await?; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: test.config.approval_policy.value(), + sandbox_policy: SandboxPolicy::ReadOnly, + model: test.session_configured.model.clone(), + effort: test.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = resp_mock.requests(); + assert_eq!(requests.len(), 2, "expected two requests"); + let request = requests + .last() + .expect("expected personality update request"); + + let developer_texts = request.message_input_texts("developer"); + let personality_text = developer_texts + .iter() + .find(|text| text.contains("")) + .expect("expected personality update message in developer input"); + + assert!( + personality_text.contains("The user has requested a new communication style."), + "expected personality update preamble, got {personality_text:?}" + ); + assert!( + personality_text.contains(LOCAL_FRIENDLY_TEMPLATE), + "expected personality update to include the local friendly template, got: {personality_text:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn user_turn_personality_remote_model_template_includes_update_message() -> anyhow::Result<()> +{ + skip_if_no_network!(Ok(())); + + let server = MockServer::builder() + .body_print_limit(BodyPrintLimit::Limited(80_000)) + .start() + .await; + + let remote_slug = "codex-remote-personality"; + let remote_personality_message = "Friendly from remote template"; + let remote_model = ModelInfo { + slug: remote_slug.to_string(), + display_name: "Remote personality test".to_string(), + description: Some("Remote model with personality template".to_string()), + default_reasoning_level: Some(ReasoningEffort::Medium), + supported_reasoning_levels: vec![ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: ReasoningEffort::Medium.to_string(), + }], + shell_type: ConfigShellToolType::UnifiedExec, + visibility: ModelVisibility::List, + supported_in_api: true, + priority: 1, + upgrade: None, + base_instructions: "base instructions".to_string(), + model_instructions_template: Some(ModelInstructionsTemplate { + template: "Base instructions\n{{ personality_message }}\n".to_string(), + personality_messages: Some(PersonalityMessages(BTreeMap::from([( + Personality::Friendly, + remote_personality_message.to_string(), + )]))), + }), + supports_reasoning_summaries: false, + support_verbosity: false, + default_verbosity: None, + apply_patch_tool_type: None, + truncation_policy: TruncationPolicyConfig::bytes(10_000), + supports_parallel_tool_calls: false, + context_window: Some(128_000), + auto_compact_token_limit: None, + effective_context_window_percent: 95, + experimental_supported_tools: Vec::new(), + }; + + let _models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model], + }, + ) + .await; + + let resp_mock = mount_sse_sequence( + &server, + vec![sse_completed("resp-1"), sse_completed("resp-2")], + ) + .await; + + let mut builder = test_codex() + .with_auth(codex_core::CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + config.features.enable(Feature::RemoteModels); + config.model = Some("gpt-5.2-codex".to_string()); + }); + let test = builder.build(&server).await?; + + wait_for_model_available( + &test.thread_manager.get_models_manager(), + remote_slug, + &test.config, + ) + .await; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + model: test.session_configured.model.clone(), + effort: test.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + test.codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: Some(remote_slug.to_string()), + effort: None, + summary: None, + collaboration_mode: None, + personality: Some(Personality::Friendly), + }) + .await?; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + model: remote_slug.to_string(), + effort: test.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = resp_mock.requests(); + assert_eq!(requests.len(), 2, "expected two requests"); + let request = requests + .last() + .expect("expected personality update request"); + let developer_texts = request.message_input_texts("developer"); + let personality_text = developer_texts + .iter() + .find(|text| text.contains("")) + .expect("expected personality update message in developer input"); + + assert!( + personality_text.contains("The user has requested a new communication style."), + "expected personality update preamble, got {personality_text:?}" + ); + assert!( + personality_text.contains(remote_personality_message), + "expected personality update to include remote template, got: {personality_text:?}" + ); + + Ok(()) +} + +async fn wait_for_model_available( + manager: &Arc, + slug: &str, + config: &codex_core::config::Config, +) { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let models = manager + .list_models(config, RefreshStrategy::OnlineIfUncached) + .await; + if models.iter().any(|model| model.model == slug) { + return; + } + if Instant::now() >= deadline { + panic!("timed out waiting for the remote model {slug} to appear"); + } + sleep(Duration::from_millis(25)).await; + } +} diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4b0436412..c7a3eb78a 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -353,6 +353,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an effort: Some(Some(ReasoningEffort::High)), summary: Some(ReasoningSummary::Detailed), collaboration_mode: None, + personality: None, }) .await?; @@ -426,6 +427,7 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul effort: Some(Some(ReasoningEffort::Low)), summary: None, collaboration_mode: Some(collaboration_mode), + personality: None, }) .await?; @@ -583,6 +585,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res summary: ReasoningSummary::Detailed, collaboration_mode: None, final_output_json_schema: None, + personality: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -677,6 +680,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a summary: default_summary, collaboration_mode: None, final_output_json_schema: None, + personality: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -695,6 +699,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a summary: default_summary, collaboration_mode: None, final_output_json_schema: None, + personality: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -775,6 +780,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu summary: default_summary, collaboration_mode: None, final_output_json_schema: None, + personality: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -793,6 +799,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu summary: ReasoningSummary::Detailed, collaboration_mode: None, final_output_json_schema: None, + personality: None, }) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index e77861f84..7e5fab987 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -142,6 +142,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { effort: None, summary: None, collaboration_mode: None, + personality: None, }) .await?; @@ -178,6 +179,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -369,6 +371,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { effort: None, summary: None, collaboration_mode: None, + personality: None, }) .await?; @@ -386,6 +389,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 027d7dab7..59f694457 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -137,6 +137,7 @@ async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> reasoning_effort: None, developer_instructions: None, })), + personality: None, }) .await?; @@ -249,6 +250,7 @@ where effort: None, summary: ReasoningSummary::Auto, collaboration_mode: Some(collaboration_mode), + personality: None, }) .await?; diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 162e0e26e..5e5fc6d74 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -26,6 +26,7 @@ fn resume_history( approval_policy: config.approval_policy.value(), sandbox_policy: config.sandbox_policy.get().clone(), model: previous_model.to_string(), + personality: None, collaboration_mode: None, effort: config.model_reasoning_effort, summary: config.model_reasoning_summary, diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 1f0913470..3a66e320b 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -822,6 +822,7 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() { effort: None, summary: None, collaboration_mode: None, + personality: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index cba1ed622..4210a5c6b 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -119,6 +119,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -258,6 +259,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -455,6 +457,7 @@ async fn stdio_image_completions_round_trip() -> anyhow::Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -600,6 +603,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -756,6 +760,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -944,6 +949,7 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 2177ab9bd..42a2107cd 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -101,6 +101,7 @@ async fn run_snapshot_command(command: &str) -> Result { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -174,6 +175,7 @@ async fn run_shell_command_snapshot(command: &str) -> Result { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -308,6 +310,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 4c1c338bc..54cdba4d2 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -79,6 +79,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index d9bdf83ed..611476025 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -91,6 +91,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -159,6 +160,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -237,6 +239,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -327,6 +330,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -425,6 +429,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index 42a2a28ae..0e03bbc26 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -48,6 +48,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -365,6 +366,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index bd4a9a306..cf03e0992 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -548,6 +548,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index c3816aefd..10234cabb 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -210,6 +210,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -338,6 +339,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -415,6 +417,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -495,6 +498,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -587,6 +591,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -661,6 +666,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -736,6 +742,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -865,6 +872,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -1001,6 +1009,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -1160,6 +1169,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -1257,6 +1267,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -1374,6 +1385,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -1463,6 +1475,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -1543,6 +1556,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -1673,6 +1687,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -1840,6 +1855,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -1916,6 +1932,7 @@ async fn unified_exec_closes_long_running_session_at_turn_end() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -2037,6 +2054,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -2171,6 +2189,7 @@ PY effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; // This is a worst case scenario for the truncate logic. @@ -2284,6 +2303,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -2379,6 +2399,7 @@ PY effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -2460,6 +2481,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -2563,6 +2585,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -2657,6 +2680,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -2791,6 +2815,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index c6869323f..8c7919038 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -89,6 +89,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -182,6 +183,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -314,6 +316,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -388,6 +391,7 @@ async fn view_image_tool_placeholder_for_non_image_files() -> anyhow::Result<()> effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -481,6 +485,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; @@ -563,6 +568,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> effort: None, summary: ReasoningSummary::Auto, collaboration_mode: None, + personality: None, }) .await?; diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index 883c7321b..19e1d9158 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -71,7 +71,7 @@ Response: `{ conversationId, model, reasoningEffort?, rolloutPath }` Send input to the active turn: - `sendUserMessage` → enqueue items to the conversation -- `sendUserTurn` → structured turn with explicit `cwd`, `approvalPolicy`, `sandboxPolicy`, `model`, optional `effort`, `summary`, and optional `outputSchema` (JSON Schema for the final assistant message) +- `sendUserTurn` → structured turn with explicit `cwd`, `approvalPolicy`, `sandboxPolicy`, `model`, optional `effort`, `summary`, optional `personality`, and optional `outputSchema` (JSON Schema for the final assistant message) For v2 threads, `turn/start` also accepts `outputSchema` to constrain the final assistant message for that turn. diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 5133f6f75..f5579008b 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -71,6 +71,7 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `Op::ExecApproval` – Approve or deny code execution - `Op::UserInputAnswer` – Provide answers for a `request_user_input` tool call - `Op::ListSkills` – Request skills for one or more cwd values (optionally `force_reload`) + - `Op::UserTurn` and `Op::OverrideTurnContext` accept an optional `personality` override that updates the model’s communication style - `EventMsg` - `EventMsg::AgentMessage` – Messages from the `Model` - `EventMsg::ExecApprovalRequest` – Request approval from user to execute a command diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index a7629a107..ccd10355b 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -455,6 +455,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any summary: default_summary, final_output_json_schema: output_schema, collaboration_mode: None, + personality: None, }) .await?; info!("Sent prompt with event ID: {task_id}"); diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index ff440ac1c..f173d3964 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -225,6 +225,13 @@ impl DeveloperInstructions { Self { text } } + pub fn personality_spec_message(spec: String) -> Self { + let message = format!( + " The user has requested a new communication style. Future messages should adhere to the following personality: \n{spec} " + ); + DeveloperInstructions::new(message) + } + pub fn from_policy( sandbox_policy: &SandboxPolicy, approval_policy: AskForApproval, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 70bbf28ac..1f35fed60 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -14,6 +14,7 @@ use std::time::Duration; use crate::ThreadId; use crate::approvals::ElicitationRequestEvent; use crate::config_types::CollaborationMode; +use crate::config_types::Personality; use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; use crate::custom_prompts::CustomPrompt; use crate::items::TurnItem; @@ -129,6 +130,10 @@ pub enum Op { /// Takes precedence over model, effort, and developer instructions if set. #[serde(skip_serializing_if = "Option::is_none")] collaboration_mode: Option, + + /// Optional personality override for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + personality: Option, }, /// Override parts of the persistent turn context for subsequent turns. @@ -170,6 +175,10 @@ pub enum Op { /// Takes precedence over model, effort, and developer instructions if set. #[serde(skip_serializing_if = "Option::is_none")] collaboration_mode: Option, + + /// Updated personality preference. + #[serde(skip_serializing_if = "Option::is_none")] + personality: Option, }, /// Approve a command execution @@ -1618,6 +1627,8 @@ pub struct TurnContextItem { pub approval_policy: AskForApproval, pub sandbox_policy: SandboxPolicy, pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub personality: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub collaboration_mode: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 7f6b37f6c..c42b8f39d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1234,6 +1234,7 @@ impl App { effort: None, summary: None, collaboration_mode: None, + personality: None, }, )); self.app_event_tx diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 866c71d45..dfd1f07d2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2611,6 +2611,7 @@ impl ChatWidget { collaboration_mode: self .collaboration_modes_enabled() .then(|| self.stored_collaboration_mode.clone()), + personality: None, }; self.codex_op_tx.send(op).unwrap_or_else(|e| { @@ -3062,6 +3063,7 @@ impl ChatWidget { effort: Some(Some(default_effort)), summary: None, collaboration_mode: None, + personality: None, })); tx.send(AppEvent::UpdateModel(switch_model.clone())); tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort))); @@ -3380,6 +3382,7 @@ impl ChatWidget { effort: Some(effort_for_action), summary: None, collaboration_mode: None, + personality: None, })); tx.send(AppEvent::UpdateModel(model_for_action.clone())); tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); @@ -3552,6 +3555,7 @@ impl ChatWidget { effort: Some(effort), summary: None, collaboration_mode: None, + personality: None, })); self.app_event_tx.send(AppEvent::UpdateModel(model.clone())); self.app_event_tx @@ -3735,6 +3739,7 @@ impl ChatWidget { effort: None, summary: None, collaboration_mode: None, + personality: None, })); tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index e6d7e4a59..b97e232c2 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2084,6 +2084,7 @@ async fn collab_slash_command_opens_picker_and_updates_mode() { match next_submit_op(&mut op_rx) { Op::UserTurn { collaboration_mode: Some(CollaborationMode::PairProgramming(_)), + personality: None, .. } => {} other => { @@ -2097,6 +2098,7 @@ async fn collab_slash_command_opens_picker_and_updates_mode() { match next_submit_op(&mut op_rx) { Op::UserTurn { collaboration_mode: Some(CollaborationMode::PairProgramming(_)), + personality: None, .. } => {} other => { @@ -2117,6 +2119,7 @@ async fn collab_mode_defaults_to_pair_programming_when_enabled() { match next_submit_op(&mut op_rx) { Op::UserTurn { collaboration_mode: Some(CollaborationMode::PairProgramming(_)), + personality: None, .. } => {} other => { @@ -3132,6 +3135,7 @@ async fn approvals_popup_navigation_skips_disabled() { ev, AppEvent::CodexOp(Op::OverrideTurnContext { approval_policy: Some(AskForApproval::OnRequest), + personality: None, .. }) )), @@ -3142,6 +3146,7 @@ async fn approvals_popup_navigation_skips_disabled() { ev, AppEvent::CodexOp(Op::OverrideTurnContext { approval_policy: Some(AskForApproval::Never), + personality: None, .. }) )),