Merge origin/main into etraut/alarm

This commit is contained in:
Eric Traut
2026-04-06 15:51:08 -07:00
602 changed files with 26312 additions and 10748 deletions
+374 -160
View File
@@ -1,5 +1,4 @@
use super::*;
use crate::CodexAuth;
use crate::alarms::AlarmDelivery;
use crate::alarms::AlarmsState;
use crate::config::ConfigBuilder;
@@ -12,15 +11,19 @@ use crate::config_loader::NetworkDomainPermissionsToml;
use crate::config_loader::RequirementSource;
use crate::config_loader::Sourced;
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecToolCallOutput;
use crate::function_tool::FunctionCallError;
use crate::mcp_connection_manager::ToolInfo;
use crate::models_manager::model_info;
use crate::shell::default_user_shell;
use crate::tools::format_exec_output_str;
use codex_features::Features;
use codex_login::CodexAuth;
use codex_mcp::mcp_connection_manager::ToolInfo;
use codex_model_provider_info::ModelProviderInfo;
use codex_models_manager::bundled_models_response;
use codex_models_manager::model_info;
use codex_protocol::AgentPath;
use codex_protocol::ThreadId;
use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::permissions::FileSystemAccessMode;
@@ -35,20 +38,6 @@ use codex_protocol::request_permissions::PermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionProfile;
use tracing::Span;
use crate::protocol::CompactedItem;
use crate::protocol::CreditsSnapshot;
use crate::protocol::InitialHistory;
use crate::protocol::NetworkApprovalProtocol;
use crate::protocol::RateLimitSnapshot;
use crate::protocol::RateLimitWindow;
use crate::protocol::ResumedHistory;
use crate::protocol::RolloutItem;
use crate::protocol::TokenCountEvent;
use crate::protocol::TokenUsage;
use crate::protocol::TokenUsageInfo;
use crate::protocol::TurnCompleteEvent;
use crate::protocol::TurnStartedEvent;
use crate::protocol::UserMessageEvent;
use crate::rollout::policy::EventPersistenceMode;
use crate::rollout::recorder::RolloutRecorder;
use crate::rollout::recorder::RolloutRecorderParams;
@@ -56,7 +45,6 @@ use crate::state::TaskKind;
use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use crate::tools::ToolRouter;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::ShellHandler;
@@ -78,11 +66,28 @@ use codex_protocol::models::ContentItem;
use codex_protocol::models::DeveloperInstructions;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::CompactedItem;
use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::GranularApprovalConfig;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::NetworkApprovalProtocol;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::RealtimeAudioFrame;
use codex_protocol::protocol::ResumedHistory;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::Submission;
use codex_protocol::protocol::ThreadRolledBackEvent;
use codex_protocol::protocol::TokenCountEvent;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::protocol::TurnStartedEvent;
use codex_protocol::protocol::UserMessageEvent;
use codex_protocol::protocol::W3cTraceContext;
use core_test_support::PathBufExt;
use core_test_support::context_snapshot;
@@ -116,12 +121,6 @@ use std::time::Duration as StdDuration;
#[path = "codex_tests_guardian.rs"]
mod guardian_tests;
use codex_protocol::models::function_call_output_content_items_to_text;
fn expect_text_tool_output(output: &FunctionToolOutput) -> String {
function_call_output_content_items_to_text(&output.body).unwrap_or_default()
}
struct InstructionsTestCase {
slug: &'static str,
expects_apply_patch_instructions: bool,
@@ -240,7 +239,7 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted
.expect("channel open");
assert!(matches!(
second.msg,
EventMsg::TurnAborted(crate::protocol::TurnAbortedEvent {
EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some(turn_id),
reason: TurnAbortReason::Interrupted,
}) if turn_id == tc.sub_id
@@ -252,9 +251,7 @@ fn test_model_client_session() -> crate::client::ModelClientSession {
/*auth_manager*/ None,
ThreadId::try_from("00000000-0000-4000-8000-000000000001")
.expect("test thread id should be valid"),
crate::model_provider_info::ModelProviderInfo::create_openai_provider(
/* base_url */ /*base_url*/ None,
),
ModelProviderInfo::create_openai_provider(/* base_url */ /*base_url*/ None),
codex_protocol::protocol::SessionSource::Exec,
/*model_verbosity*/ None,
/*enable_request_compression*/ false,
@@ -281,6 +278,23 @@ fn developer_input_texts(items: &[ResponseItem]) -> Vec<&str> {
.collect()
}
fn user_input_texts(items: &[ResponseItem]) -> Vec<&str> {
items
.iter()
.filter_map(|item| match item {
ResponseItem::Message { role, content, .. } if role == "user" => {
Some(content.as_slice())
}
_ => None,
})
.flat_map(|content| content.iter())
.filter_map(|item| match item {
ContentItem::InputText { text } => Some(text.as_str()),
_ => None,
})
.collect()
}
fn test_tool_runtime(session: Arc<Session>, turn_context: Arc<TurnContext>) -> ToolCallRuntime {
let router = Arc::new(ToolRouter::from_config(
&turn_context.tools_config,
@@ -529,8 +543,8 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules()
async fn get_base_instructions_no_user_content() {
let prompt_with_apply_patch_instructions =
include_str!("../prompt_with_apply_patch_instructions.md");
let models_response: ModelsResponse =
serde_json::from_str(include_str!("../models.json")).expect("valid models.json");
let models_response = bundled_models_response()
.unwrap_or_else(|err| panic!("bundled models.json should parse: {err}"));
let model_info_for_slug = |slug: &str, config: &Config| {
let model = models_response
.models
@@ -538,7 +552,7 @@ async fn get_base_instructions_no_user_content() {
.find(|candidate| candidate.slug == slug)
.cloned()
.unwrap_or_else(|| panic!("model slug {slug} is missing from models.json"));
model_info::with_config_overrides(model, config)
model_info::with_config_overrides(model, &config.to_models_manager_config())
};
let test_cases = vec![
InstructionsTestCase {
@@ -603,7 +617,7 @@ async fn reload_user_config_layer_updates_effective_apps_config() {
.and_then(|table| table.get("apps"))
.cloned()
.expect("apps table");
let apps = crate::config::types::AppsConfigToml::deserialize(apps_toml)
let apps = codex_config::types::AppsConfigToml::deserialize(apps_toml)
.expect("deserialize apps config");
let app = apps
.apps
@@ -1786,7 +1800,10 @@ async fn set_rate_limits_retains_previous_credits() {
let config = build_test_config(codex_home.path()).await;
let config = Arc::new(config);
let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref());
let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config);
let model_info = ModelsManager::construct_model_info_offline_for_tests(
model.as_str(),
&config.to_models_manager_config(),
);
let reasoning_effort = config.model_reasoning_effort;
let collaboration_mode = CollaborationMode {
mode: ModeKind::Default,
@@ -1821,6 +1838,7 @@ async fn set_rate_limits_retains_previous_credits() {
original_config_do_not_use: Arc::clone(&config),
metrics_service_name: None,
app_server_client_name: None,
app_server_client_version: None,
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
@@ -1884,7 +1902,10 @@ async fn set_rate_limits_updates_plan_type_when_present() {
let config = build_test_config(codex_home.path()).await;
let config = Arc::new(config);
let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref());
let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config);
let model_info = ModelsManager::construct_model_info_offline_for_tests(
model.as_str(),
&config.to_models_manager_config(),
);
let reasoning_effort = config.model_reasoning_effort;
let collaboration_mode = CollaborationMode {
mode: ModeKind::Default,
@@ -1919,6 +1940,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
original_config_do_not_use: Arc::clone(&config),
metrics_service_name: None,
app_server_client_name: None,
app_server_client_version: None,
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
@@ -2034,7 +2056,10 @@ async fn turn_context_with_model_updates_model_fields() {
let expected_model_info = session
.services
.models_manager
.get_model_info("gpt-5.1", updated.config.as_ref())
.get_model_info(
"gpt-5.1",
&updated.config.as_ref().to_models_manager_config(),
)
.await;
assert_eq!(updated.config.model.as_deref(), Some("gpt-5.1"));
@@ -2122,9 +2147,7 @@ fn success_flag_true_with_no_error_and_content_used() {
assert_eq!(expected, got);
}
async fn wait_for_thread_rolled_back(
rx: &async_channel::Receiver<Event>,
) -> crate::protocol::ThreadRolledBackEvent {
async fn wait_for_thread_rolled_back(rx: &async_channel::Receiver<Event>) -> ThreadRolledBackEvent {
let deadline = StdDuration::from_secs(2);
let start = std::time::Instant::now();
loop {
@@ -2195,7 +2218,7 @@ fn text_block(s: &str) -> serde_json::Value {
}
async fn build_test_config(codex_home: &Path) -> Config {
ConfigBuilder::default()
ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.to_path_buf())
.build()
.await
@@ -2227,7 +2250,10 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
let config = build_test_config(codex_home.path()).await;
let config = Arc::new(config);
let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref());
let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config);
let model_info = ModelsManager::construct_model_info_offline_for_tests(
model.as_str(),
&config.to_models_manager_config(),
);
let reasoning_effort = config.model_reasoning_effort;
let collaboration_mode = CollaborationMode {
mode: ModeKind::Default,
@@ -2263,6 +2289,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
original_config_do_not_use: Arc::clone(&config),
metrics_service_name: None,
app_server_client_name: None,
app_server_client_version: None,
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
@@ -2491,7 +2518,10 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
CollaborationModesConfig::default(),
));
let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref());
let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config);
let model_info = ModelsManager::construct_model_info_offline_for_tests(
model.as_str(),
&config.to_models_manager_config(),
);
let collaboration_mode = CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
@@ -2525,6 +2555,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
original_config_do_not_use: Arc::clone(&config),
metrics_service_name: None,
app_server_client_name: None,
app_server_client_version: None,
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
@@ -2587,7 +2618,10 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
let exec_policy = Arc::new(ExecPolicyManager::default());
let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit);
let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref());
let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config);
let model_info = ModelsManager::construct_model_info_offline_for_tests(
model.as_str(),
&config.to_models_manager_config(),
);
let reasoning_effort = config.model_reasoning_effort;
let collaboration_mode = CollaborationMode {
mode: ModeKind::Default,
@@ -2622,6 +2656,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
original_config_do_not_use: Arc::clone(&config),
metrics_service_name: None,
app_server_client_name: None,
app_server_client_version: None,
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
@@ -2631,7 +2666,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
let per_turn_config = Session::build_per_turn_config(&session_configuration);
let model_info = ModelsManager::construct_model_info_offline_for_tests(
session_configuration.collaboration_mode.model(),
&per_turn_config,
&per_turn_config.to_models_manager_config(),
);
let session_telemetry = session_telemetry(
conversation_id,
@@ -2656,11 +2691,9 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
let skills_watcher = Arc::new(SkillsWatcher::noop());
let services = SessionServices {
mcp_connection_manager: Arc::new(RwLock::new(
McpConnectionManager::new_mcp_connection_manager_for_tests(
&config.permissions.approval_policy,
),
)),
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized(
&config.permissions.approval_policy,
))),
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
unified_exec_manager: UnifiedExecProcessManager::new(
config.background_terminal_max_timeout,
@@ -2795,15 +2828,13 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests()
Arc::get_mut(&mut turn_context)
.expect("single turn context ref")
.approval_policy
.set(crate::protocol::AskForApproval::Granular(
crate::protocol::GranularApprovalConfig {
sandbox_approval: true,
rules: true,
skill_approval: true,
request_permissions: true,
mcp_elicitations: true,
},
))
.set(AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: true,
rules: true,
skill_approval: true,
request_permissions: true,
mcp_elicitations: true,
}))
.expect("test setup should allow updating approval policy");
let session = Arc::new(session);
@@ -2870,15 +2901,13 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req
Arc::get_mut(&mut turn_context)
.expect("single turn context ref")
.approval_policy
.set(crate::protocol::AskForApproval::Granular(
crate::protocol::GranularApprovalConfig {
sandbox_approval: true,
rules: true,
skill_approval: true,
request_permissions: false,
mcp_elicitations: true,
},
))
.set(AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: true,
rules: true,
skill_approval: true,
request_permissions: false,
mcp_elicitations: true,
}))
.expect("test setup should allow updating approval policy");
let session = Arc::new(session);
@@ -3101,7 +3130,7 @@ async fn user_turn_updates_approvals_reviewer() {
}],
cwd: config.cwd.to_path_buf(),
approval_policy: config.permissions.approval_policy.value(),
approvals_reviewer: Some(crate::config::types::ApprovalsReviewer::GuardianSubagent),
approvals_reviewer: Some(codex_config::types::ApprovalsReviewer::GuardianSubagent),
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
model: turn_context.model_info.slug.clone(),
effort: config.model_reasoning_effort,
@@ -3117,7 +3146,7 @@ async fn user_turn_updates_approvals_reviewer() {
let state = session.state.lock().await;
assert_eq!(
state.session_configuration.approvals_reviewer,
crate::config::types::ApprovalsReviewer::GuardianSubagent
codex_config::types::ApprovalsReviewer::GuardianSubagent
);
}
@@ -3127,7 +3156,6 @@ async fn spawn_task_turn_span_inherits_dispatch_trace_context() {
captured_trace: Arc<std::sync::Mutex<Option<W3cTraceContext>>>,
}
#[async_trait::async_trait]
impl SessionTask for TraceCaptureTask {
fn kind(&self) -> TaskKind {
TaskKind::Regular
@@ -3433,7 +3461,10 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
let exec_policy = Arc::new(ExecPolicyManager::default());
let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit);
let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref());
let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config);
let model_info = ModelsManager::construct_model_info_offline_for_tests(
model.as_str(),
&config.to_models_manager_config(),
);
let reasoning_effort = config.model_reasoning_effort;
let collaboration_mode = CollaborationMode {
mode: ModeKind::Default,
@@ -3468,6 +3499,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
original_config_do_not_use: Arc::clone(&config),
metrics_service_name: None,
app_server_client_name: None,
app_server_client_version: None,
session_source: SessionSource::Exec,
dynamic_tools,
persist_extended_history: false,
@@ -3477,7 +3509,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
let per_turn_config = Session::build_per_turn_config(&session_configuration);
let model_info = ModelsManager::construct_model_info_offline_for_tests(
session_configuration.collaboration_mode.model(),
&per_turn_config,
&per_turn_config.to_models_manager_config(),
);
let session_telemetry = session_telemetry(
conversation_id,
@@ -3502,11 +3534,9 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
let skills_watcher = Arc::new(SkillsWatcher::noop());
let services = SessionServices {
mcp_connection_manager: Arc::new(RwLock::new(
McpConnectionManager::new_mcp_connection_manager_for_tests(
&config.permissions.approval_policy,
),
)),
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized(
&config.permissions.approval_policy,
))),
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
unified_exec_manager: UnifiedExecProcessManager::new(
config.background_terminal_max_timeout,
@@ -3829,17 +3859,9 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes(
.build_settings_update_items(Some(&reference_context_item), &current_context)
.await;
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("<environment_context>").then_some(text)
}
_ => None,
})
let environment_update = user_input_texts(&update_items)
.into_iter()
.find(|text| text.contains("<environment_context>"))
.expect("environment update item should be emitted");
assert!(environment_update.contains("<network enabled=\"true\">"));
assert!(environment_update.contains("<allowed>api.example.com</allowed>"));
@@ -3864,22 +3886,43 @@ async fn build_settings_update_items_emits_environment_item_for_time_changes() {
.build_settings_update_items(Some(&reference_context_item), &current_context)
.await;
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("<environment_context>").then_some(text)
}
_ => None,
})
let environment_update = user_input_texts(&update_items)
.into_iter()
.find(|text| text.contains("<environment_context>"))
.expect("environment update item should be emitted");
assert!(environment_update.contains("<current_date>2026-02-27</current_date>"));
assert!(environment_update.contains("<timezone>Europe/Berlin</timezone>"));
}
#[tokio::test]
async fn build_settings_update_items_omits_environment_item_when_disabled() {
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;
let mut config = (*current_context.config).clone();
config.include_environment_context = false;
current_context.config = Arc::new(config);
current_context.current_date = Some("2026-02-27".to_string());
let reference_context_item = previous_context.to_turn_context_item();
let update_items = session
.build_settings_update_items(Some(&reference_context_item), &current_context)
.await;
let user_texts = user_input_texts(&update_items);
assert!(
!user_texts
.iter()
.any(|text| text.contains("<environment_context>")),
"did not expect environment context updates when disabled, got {user_texts:?}"
);
}
#[tokio::test]
async fn build_settings_update_items_emits_realtime_start_when_session_becomes_live() {
let (session, previous_context) = make_session_and_context().await;
@@ -4449,7 +4492,6 @@ struct NeverEndingTask {
listen_to_cancellation_token: bool,
}
#[async_trait::async_trait]
impl SessionTask for NeverEndingTask {
fn kind(&self) -> TaskKind {
self.kind
@@ -4857,6 +4899,214 @@ async fn queued_response_items_for_next_turn_move_into_next_active_turn() {
assert_eq!(sess.get_pending_input().await, vec![queued_item]);
}
#[tokio::test]
async fn queue_only_mailbox_mail_waits_for_next_turn_after_answer_boundary() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
let communication = InterAgentCommunication::new(
AgentPath::try_from("/root/worker").expect("worker path should parse"),
AgentPath::root(),
Vec::new(),
"late queue-only update".to_string(),
/*trigger_turn*/ false,
);
sess.spawn_task(
Arc::clone(&tc),
Vec::new(),
NeverEndingTask {
kind: TaskKind::Regular,
listen_to_cancellation_token: true,
},
)
.await;
sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await;
sess.enqueue_mailbox_communication(communication.clone());
assert!(
!sess.has_pending_input().await,
"queue-only mailbox mail should stay buffered once the current turn emitted its answer"
);
assert_eq!(sess.get_pending_input().await, Vec::new());
sess.abort_all_tasks(TurnAbortReason::Replaced).await;
assert_eq!(
sess.get_pending_input().await,
vec![communication.to_response_input_item()],
);
}
#[tokio::test]
async fn trigger_turn_mailbox_mail_waits_for_next_turn_after_answer_boundary() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
sess.spawn_task(
Arc::clone(&tc),
Vec::new(),
NeverEndingTask {
kind: TaskKind::Regular,
listen_to_cancellation_token: true,
},
)
.await;
sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await;
sess.enqueue_mailbox_communication(InterAgentCommunication::new(
AgentPath::try_from("/root/worker").expect("worker path should parse"),
AgentPath::root(),
Vec::new(),
"late trigger update".to_string(),
/*trigger_turn*/ true,
));
assert!(
!sess.has_pending_input().await,
"trigger-turn mailbox mail should not extend the current turn after its answer boundary"
);
sess.abort_all_tasks(TurnAbortReason::Replaced).await;
assert!(sess.has_trigger_turn_mailbox_items().await);
}
#[tokio::test]
async fn steered_input_reopens_mailbox_delivery_for_current_turn() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
let communication = InterAgentCommunication::new(
AgentPath::try_from("/root/worker").expect("worker path should parse"),
AgentPath::root(),
Vec::new(),
"queued child update".to_string(),
/*trigger_turn*/ false,
);
sess.spawn_task(
Arc::clone(&tc),
Vec::new(),
NeverEndingTask {
kind: TaskKind::Regular,
listen_to_cancellation_token: true,
},
)
.await;
sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await;
sess.enqueue_mailbox_communication(communication.clone());
sess.steer_input(
vec![UserInput::Text {
text: "follow up".to_string(),
text_elements: Vec::new(),
}],
Some(&tc.sub_id),
)
.await
.expect("steered input should be accepted");
assert_eq!(
sess.get_pending_input().await,
vec![
ResponseInputItem::from(vec![UserInput::Text {
text: "follow up".to_string(),
text_elements: Vec::new(),
}]),
communication.to_response_input_item(),
],
);
}
#[tokio::test]
async fn stale_defer_mailbox_delivery_does_not_override_steered_input() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
let communication = InterAgentCommunication::new(
AgentPath::try_from("/root/worker").expect("worker path should parse"),
AgentPath::root(),
Vec::new(),
"queued child update".to_string(),
/*trigger_turn*/ false,
);
sess.spawn_task(
Arc::clone(&tc),
Vec::new(),
NeverEndingTask {
kind: TaskKind::Regular,
listen_to_cancellation_token: true,
},
)
.await;
sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await;
sess.enqueue_mailbox_communication(communication.clone());
sess.steer_input(
vec![UserInput::Text {
text: "follow up".to_string(),
text_elements: Vec::new(),
}],
Some(&tc.sub_id),
)
.await
.expect("steered input should be accepted");
sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await;
assert_eq!(
sess.get_pending_input().await,
vec![
ResponseInputItem::from(vec![UserInput::Text {
text: "follow up".to_string(),
text_elements: Vec::new(),
}]),
communication.to_response_input_item(),
],
);
}
#[tokio::test]
async fn tool_calls_reopen_mailbox_delivery_for_current_turn() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
let communication = InterAgentCommunication::new(
AgentPath::try_from("/root/worker").expect("worker path should parse"),
AgentPath::root(),
Vec::new(),
"queued child update".to_string(),
/*trigger_turn*/ false,
);
sess.spawn_task(
Arc::clone(&tc),
Vec::new(),
NeverEndingTask {
kind: TaskKind::Regular,
listen_to_cancellation_token: true,
},
)
.await;
sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await;
sess.enqueue_mailbox_communication(communication.clone());
let item = ResponseItem::FunctionCall {
id: None,
name: "test_tool".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
};
let mut ctx = HandleOutputCtx {
sess: Arc::clone(&sess),
turn_context: Arc::clone(&tc),
tool_runtime: test_tool_runtime(Arc::clone(&sess), Arc::clone(&tc)),
cancellation_token: CancellationToken::new(),
};
let output = handle_output_item_done(&mut ctx, item, /*previously_active_item*/ None)
.await
.expect("tool call should be handled");
assert!(output.needs_follow_up);
assert!(output.tool_future.is_some());
assert_eq!(
sess.get_pending_input().await,
vec![communication.to_response_input_item()],
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn abort_review_task_emits_exited_then_aborted_and_records_history() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
@@ -5160,10 +5410,12 @@ async fn sample_rollout(
#[tokio::test]
async fn rejects_escalated_permissions_when_policy_not_on_request() {
use crate::exec::ExecParams;
use crate::protocol::AskForApproval;
use crate::protocol::SandboxPolicy;
use crate::exec_policy::ExecApprovalRequest;
use crate::sandboxing::SandboxPermissions;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use std::collections::HashMap;
let (session, mut turn_context_raw) = make_session_and_context().await;
@@ -5206,23 +5458,6 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
arg0: None,
};
let params2 = ExecParams {
sandbox_permissions: SandboxPermissions::UseDefault,
command: params.command.clone(),
cwd: params.cwd.clone(),
expiration: timeout_ms.into(),
capture_policy: ExecCapturePolicy::ShellTool,
env: HashMap::new(),
network: None,
windows_sandbox_level: turn_context.windows_sandbox_level,
windows_sandbox_private_desktop: turn_context
.config
.permissions
.windows_sandbox_private_desktop,
justification: params.justification.clone(),
arg0: None,
};
let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let tool_name = "shell";
@@ -5260,9 +5495,11 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
);
pretty_assertions::assert_eq!(output, expected);
pretty_assertions::assert_eq!(session.granted_turn_permissions().await, None);
// Now retry the same command WITHOUT escalated permissions; should succeed.
// Force DangerFullAccess to avoid platform sandbox dependencies in tests.
// The rejection should not poison the non-escalated path for the same
// command. Force DangerFullAccess so this check stays focused on approval
// policy rather than platform-specific sandbox behavior.
let turn_context_mut = Arc::get_mut(&mut turn_context).expect("unique turn context Arc");
turn_context_mut
.sandbox_policy
@@ -5273,51 +5510,28 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
turn_context_mut.network_sandbox_policy =
NetworkSandboxPolicy::from(turn_context_mut.sandbox_policy.get());
let resp2 = handler
.handle(ToolInvocation {
session: Arc::clone(&session),
turn: Arc::clone(&turn_context),
tracker: Arc::clone(&turn_diff_tracker),
call_id: "test-call-2".to_string(),
tool_name: tool_name.to_string(),
tool_namespace: None,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"command": params2.command.clone(),
"workdir": Some(turn_context.cwd.to_string_lossy().to_string()),
"timeout_ms": params2.expiration.timeout_ms(),
"sandbox_permissions": params2.sandbox_permissions,
"justification": params2.justification.clone(),
})
.to_string(),
},
let exec_approval_requirement = session
.services
.exec_policy
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &params.command,
approval_policy: turn_context.approval_policy.value(),
sandbox_policy: turn_context.sandbox_policy.get(),
file_system_sandbox_policy: &turn_context.file_system_sandbox_policy,
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
.await;
let output = expect_text_tool_output(&resp2.expect("expected Ok result"));
#[derive(Deserialize, PartialEq, Eq, Debug)]
struct ResponseExecMetadata {
exit_code: i32,
}
#[derive(Deserialize)]
struct ResponseExecOutput {
output: String,
metadata: ResponseExecMetadata,
}
let exec_output: ResponseExecOutput =
serde_json::from_str(&output).expect("valid exec output json");
pretty_assertions::assert_eq!(exec_output.metadata, ResponseExecMetadata { exit_code: 0 });
assert!(exec_output.output.contains("hi"));
assert!(matches!(
exec_approval_requirement,
ExecApprovalRequirement::Skip { .. }
));
}
#[tokio::test]
async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() {
use crate::protocol::AskForApproval;
use crate::sandboxing::SandboxPermissions;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_protocol::protocol::AskForApproval;
let (session, mut turn_context_raw) = make_session_and_context().await;
turn_context_raw