diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index d5302d9c430a..e6db749c7f52 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -164,6 +164,28 @@ fn merge_persisted_resume_metadata( } } +fn merge_persisted_approvals_reviewer( + thread_history: &InitialHistory, + request_overrides: Option<&HashMap>, + typesafe_overrides: &mut ConfigOverrides, +) { + if typesafe_overrides.approvals_reviewer.is_some() + || request_overrides.is_some_and(|overrides| overrides.contains_key("approvals_reviewer")) + { + return; + } + + let InitialHistory::Resumed(resumed_history) = thread_history else { + return; + }; + typesafe_overrides.approvals_reviewer = resumed_history.history.iter().rev().find_map(|item| { + let RolloutItem::TurnContext(turn_context) = item else { + return None; + }; + turn_context.approvals_reviewer + }); +} + fn normalize_thread_list_cwd_filters( cwd: Option, ) -> Result>, JSONRPCErrorError> { @@ -2936,6 +2958,11 @@ impl ThreadRequestProcessor { request_overrides: &mut Option>, typesafe_overrides: &mut ConfigOverrides, ) -> Option { + merge_persisted_approvals_reviewer( + thread_history, + request_overrides.as_ref(), + typesafe_overrides, + ); let InitialHistory::Resumed(resumed_history) = thread_history else { return None; }; diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index a6e851f1d7ad..b411dba73a83 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -14,6 +14,7 @@ use app_test_support::test_absolute_path; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use chrono::Utc; +use codex_app_server_protocol::ApprovalsReviewer; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::CommandExecutionApprovalDecision; @@ -415,6 +416,87 @@ async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Resul Ok(()) } +#[tokio::test] +async fn thread_resume_preserves_persisted_approvals_reviewer() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let thread_id = { + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![UserInput::Text { + text: "materialize this thread".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + thread.id + }; + + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + config_path, + config.replace( + "approval_policy = \"never\"\n", + "approval_policy = \"never\"\napprovals_reviewer = \"user\"\n", + ), + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + approvals_reviewer, .. + } = to_response::(resume_resp)?; + + assert_eq!(approvals_reviewer, ApprovalsReviewer::AutoReview); + + Ok(()) +} + #[tokio::test] async fn thread_goal_get_rejects_unmaterialized_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 5c320b798d09..cd98a347661a 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -221,6 +221,7 @@ fn reference_context_item() -> TurnContextItem { current_date: Some("2026-03-23".to_string()), timezone: Some("America/Los_Angeles".to_string()), approval_policy: AskForApproval::OnRequest, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), permission_profile: None, network: None, diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index a7d2a7efc1b1..460ae21d1c39 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -172,6 +172,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: turn_context.sandbox_policy(), permission_profile: None, network: None, @@ -218,6 +219,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: turn_context.sandbox_policy(), permission_profile: None, network: None, @@ -1252,6 +1254,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: turn_context.sandbox_policy(), permission_profile: None, network: None, @@ -1338,6 +1341,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: turn_context.sandbox_policy(), permission_profile: None, network: None, @@ -1369,6 +1373,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: turn_context.sandbox_policy(), permission_profile: None, network: None, @@ -1495,6 +1500,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: turn_context.sandbox_policy(), permission_profile: None, network: None, @@ -1616,6 +1622,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: turn_context.sandbox_policy(), permission_profile: None, network: None, @@ -1783,6 +1790,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: turn_context.sandbox_policy(), permission_profile: None, network: None, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 20f32ddfcdbc..43083f18429a 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3022,6 +3022,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: turn_context.sandbox_policy(), permission_profile: None, network: None, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 5efbeb8ef396..b581bc71812f 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -361,6 +361,7 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), approval_policy: self.approval_policy.value(), + approvals_reviewer: Some(self.config.approvals_reviewer), sandbox_policy: self.sandbox_policy(), permission_profile: Some(self.permission_profile()), network: self.turn_context_network_item(), diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 50395b2ecb5e..3e135abf4d9b 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -34,6 +34,7 @@ fn resume_history( current_date: None, timezone: None, approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: config.legacy_sandbox_policy(), permission_profile: None, network: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 96a793bcd38f..b6e44743385e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3242,6 +3242,8 @@ pub struct TurnContextItem { #[serde(default, skip_serializing_if = "Option::is_none")] pub timezone: Option, pub approval_policy: AskForApproval, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approvals_reviewer: Option, pub sandbox_policy: SandboxPolicy, #[serde(default, skip_serializing_if = "Option::is_none")] pub permission_profile: Option, @@ -5750,6 +5752,7 @@ mod tests { current_date: None, timezone: None, approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, permission_profile: None, network: Some(TurnContextNetworkItem { diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 7fcaff08abb8..ba07a4f5c0c2 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -1212,6 +1212,7 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re current_date: None, timezone: None, approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), permission_profile: None, network: None, diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index f944e3e389a8..1c75f05b6b28 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -368,6 +368,7 @@ mod tests { current_date: None, timezone: None, approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, permission_profile: None, network: None, @@ -413,6 +414,7 @@ mod tests { current_date: None, timezone: None, approval_policy: AskForApproval::OnRequest, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, permission_profile: Some(permission_profile.clone()), network: None, @@ -454,6 +456,7 @@ mod tests { current_date: None, timezone: None, approval_policy: AskForApproval::OnRequest, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), permission_profile: None, network: None, @@ -492,6 +495,7 @@ mod tests { current_date: None, timezone: None, approval_policy: AskForApproval::OnRequest, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), permission_profile: None, network: None,