| #[path = "notification_tests.rs"] |
| mod notification_tests; |
|
|
| use super::mcp_refresh::McpRefresh; |
| use super::step_settings::ResolvedStepSettings; |
| use super::step_settings::StepSettings; |
| use super::step_settings::StepSettingsUpdate; |
| pub(crate) use super::step_settings::tests::update_selected_settings_for_test; |
| use super::turn_context::TurnEnvironment; |
| use super::*; |
| use crate::agents_md_manager::AgentsMdManager; |
| use crate::agents_md_manager::SessionInstructions; |
| use crate::compact::InitialContextInjection; |
| use crate::config::ConfigBuilder; |
| use crate::config::ConfigOverrides; |
| use crate::context::ContextualUserFragment; |
| use crate::context::DeveloperInstructions; |
| use crate::context::GuardianContextMode; |
| use crate::context::TurnAborted; |
| use crate::environment_selection::EnvironmentConfigOrigin; |
| use crate::environment_selection::ThreadEnvironments; |
| use crate::environment_selection::TurnEnvironmentState; |
| use crate::function_tool::FunctionCallError; |
| use crate::hook_mcp_executor::CoreHookMcpExecutor; |
| use crate::plugins::plugins_manager_for_config; |
| use crate::session::step_context::StepContext; |
| use crate::shell::default_user_shell; |
| use crate::shell_snapshot::ShellSnapshot; |
| use crate::test_support::models_manager_with_provider; |
| use crate::tools::format_exec_output_str; |
| use crate::tools::registry::ToolRegistry; |
| use codex_analytics::CompactionPhase; |
| use codex_analytics::CompactionReason; |
| use codex_config::ConfigLayerStack; |
| use codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID; |
| use codex_config::LoaderOverrides; |
| use codex_config::NetworkConstraints; |
| use codex_config::NetworkDomainPermissionToml; |
| use codex_config::NetworkDomainPermissionsToml; |
| use codex_config::RequirementSource; |
| use codex_config::Sourced; |
| use codex_config::loader::project_trust_key; |
| use codex_config::types::McpServerConfig; |
| use codex_config::types::McpServerTransportConfig; |
| use codex_config::types::ToolSuggestDisabledTool; |
| use codex_config::types::WindowsSandboxModeToml; |
| use core_test_support::test_codex::TurnInputRequest as ExternalTurnInputRequest; |
|
|
| use codex_features::Feature; |
| use codex_file_system::FileSystemSandboxContext; |
| use codex_http_client::ClientRouteClass; |
| use codex_http_client::HttpClientFactory; |
| use codex_http_client::OutboundProxyPolicy; |
| use codex_http_client::RouteAwareClientPool; |
| use codex_login::CodexAuth; |
| use codex_login::auth::AgentIdentityAuthPolicy; |
| use codex_model_provider::create_model_provider; |
| use codex_model_provider_info::ModelProviderInfo; |
| use codex_model_provider_info::built_in_model_providers; |
| use codex_models_manager::model_info; |
| use codex_models_manager::test_support::construct_model_info_offline_for_tests; |
| use codex_models_manager::test_support::get_model_offline_for_tests; |
| use codex_protocol::AgentPath; |
| use codex_protocol::ResponseItemId; |
| use codex_protocol::SessionId; |
| use codex_protocol::ThreadId; |
| use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; |
| use codex_protocol::config_types::ServiceTier; |
| use codex_protocol::config_types::TrustLevel; |
| use codex_protocol::exec_output::ExecToolCallOutput; |
| use codex_protocol::models::AgentMessageInputContent; |
| use codex_protocol::models::FileSystemPermissions; |
| use codex_protocol::models::FunctionCallOutputBody; |
| use codex_protocol::models::FunctionCallOutputContentItem; |
| use codex_protocol::models::FunctionCallOutputPayload; |
| use codex_protocol::models::ImageDetail; |
| use codex_protocol::models::ImageReference; |
| use codex_protocol::models::PermissionProfile; |
| use codex_protocol::models::SandboxEnforcement; |
| use codex_protocol::openai_models::ModelServiceTier; |
| use codex_protocol::openai_models::ToolMode; |
| use codex_protocol::permissions::FileSystemAccessMode; |
| use codex_protocol::permissions::FileSystemPath; |
| use codex_protocol::permissions::FileSystemSandboxEntry; |
| use codex_protocol::permissions::FileSystemSandboxPolicy; |
| use codex_protocol::permissions::FileSystemSandboxPolicyContext; |
| use codex_protocol::permissions::FileSystemSpecialPath; |
| use codex_protocol::protocol::EnvironmentConfigState; |
| use codex_protocol::protocol::SandboxPolicy; |
| use codex_protocol::protocol::TurnEnvironmentSelections; |
| use codex_protocol::request_permissions::PermissionGrantScope; |
| use codex_protocol::request_permissions::RequestPermissionProfile; |
| use codex_protocol::turn_input::TurnInput as SubmittedTurnInput; |
| use codex_protocol::turn_input::TurnInputMode; |
| use codex_protocol::turn_input::TurnInputRequest; |
| use codex_protocol::turn_input::TurnInputSubmission; |
| use codex_tools::ToolSpec; |
| use codex_utils_path_uri::PathUri; |
| use std::collections::BTreeMap; |
| use tracing::Span; |
|
|
| use crate::connectors::AppInfo; |
| use crate::responses_metadata::CodexResponsesRequestKind; |
| use crate::rollout::recorder::RolloutRecorder; |
| use crate::state::ActiveTurn; |
| use crate::state::TaskKind; |
| use crate::tasks::SessionTask; |
| use crate::tasks::SessionTaskResult; |
| use crate::tasks::UserShellCommandMode; |
| use crate::tasks::execute_user_shell_command; |
| use crate::tools::ToolRouter; |
| use crate::tools::context::ToolInvocation; |
| use crate::tools::context::ToolPayload; |
| use crate::tools::handlers::ExecCommandHandler; |
| use crate::tools::handlers::RequestPermissionsHandler; |
| use crate::tools::registry::ToolExecutor; |
| use crate::tools::router::ToolCallSource; |
| use crate::turn_diff_tracker::TurnDiffTracker; |
| use codex_config::config_toml::ConfigToml; |
| use codex_config::config_toml::ProjectConfig; |
| use codex_config::permissions_toml::FilesystemPermissionToml; |
| use codex_config::permissions_toml::FilesystemPermissionsToml; |
| use codex_config::permissions_toml::NetworkToml; |
| use codex_config::permissions_toml::PermissionProfileToml; |
| use codex_config::permissions_toml::PermissionsToml; |
| use codex_execpolicy::Decision; |
| use codex_execpolicy::NetworkRuleProtocol; |
| use codex_execpolicy::Policy; |
| use codex_history::CodexHarnessMetadata; |
| use codex_history::CompactedItem; |
| use codex_history::InitialHistory; |
| use codex_history::ResponseItemEnvelope; |
| use codex_history::ResumedHistory; |
| use codex_history::RolloutItem; |
| #[cfg(windows)] |
| use codex_network_proxy::ManagedProxyRouting; |
| use codex_network_proxy::NetworkProxyConfig; |
| use codex_otel::MetricsClient; |
| use codex_otel::MetricsConfig; |
| use codex_otel::TelemetryAuthMode; |
| use codex_protocol::config_types::CollaborationMode; |
| use codex_protocol::config_types::ModeKind; |
| use codex_protocol::config_types::Settings; |
| use codex_protocol::items::HookPromptFragment; |
| use codex_protocol::items::build_hook_prompt_message; |
| use codex_protocol::models::BaseInstructions; |
| use codex_protocol::models::ContentItem; |
| use codex_protocol::models::ContentItemKind; |
| use codex_protocol::models::InternalChatMessageMetadataPassthrough; |
| use codex_protocol::models::ResponseItem; |
| use codex_protocol::protocol::AskForApproval; |
| use codex_protocol::protocol::CodexErrorInfo; |
| use codex_protocol::protocol::ConversationAudioParams; |
| use codex_protocol::protocol::CreditsSnapshot; |
| use codex_protocol::protocol::GranularApprovalConfig; |
| use codex_protocol::protocol::HistoryPosition; |
| use codex_protocol::protocol::InterAgentCommunication; |
| use codex_protocol::protocol::MultiAgentVersion; |
| use codex_protocol::protocol::NetworkApprovalProtocol; |
| use codex_protocol::protocol::RateLimitSnapshot; |
| use codex_protocol::protocol::RateLimitWindow; |
| use codex_protocol::protocol::RealtimeAudioFrame; |
| use codex_protocol::protocol::RealtimeConversationListVoicesResponseEvent; |
| use codex_protocol::protocol::RealtimeVoice; |
| use codex_protocol::protocol::RealtimeVoicesList; |
| use codex_protocol::protocol::SessionMeta; |
| use codex_protocol::protocol::SessionMetaLine; |
| use codex_protocol::protocol::Submission; |
| use codex_protocol::protocol::ThreadSettingsOverrides; |
| use codex_protocol::protocol::TokenCountEvent; |
| use codex_protocol::protocol::TokenUsage; |
| use codex_protocol::protocol::TokenUsageInfo; |
| use codex_protocol::protocol::TokenUsageRecord; |
| 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 codex_rmcp_client::ElicitationAction; |
| use core_test_support::PathBufExt; |
| use core_test_support::PathExt; |
| use core_test_support::context_snapshot; |
| use core_test_support::context_snapshot::ContextSnapshotOptions; |
| use core_test_support::responses; |
| use core_test_support::responses::ev_assistant_message; |
| use core_test_support::responses::ev_completed; |
| use core_test_support::responses::ev_response_created; |
| use core_test_support::responses::mount_sse_once; |
| use core_test_support::responses::sse; |
| use core_test_support::responses::start_mock_server; |
| use core_test_support::responses::strip_metadata_from_items; |
| use core_test_support::responses::strip_response_item_ids; |
| use core_test_support::responses::strip_response_item_ids_from_json; |
| use core_test_support::streaming_sse::StreamingSseChunk; |
| use core_test_support::streaming_sse::start_streaming_sse_server; |
| use core_test_support::test_codex::local; |
| use core_test_support::test_codex::test_codex; |
| use core_test_support::test_path_buf; |
| use core_test_support::tracing::install_test_tracing; |
| use core_test_support::wait_for_event; |
| use opentelemetry::trace::TraceContextExt; |
| use opentelemetry::trace::TraceId; |
| use opentelemetry_sdk::metrics::InMemoryMetricExporter; |
| use opentelemetry_sdk::metrics::data::AggregatedMetrics; |
| use opentelemetry_sdk::metrics::data::Metric; |
| use opentelemetry_sdk::metrics::data::MetricData; |
| use opentelemetry_sdk::metrics::data::ResourceMetrics; |
| use std::path::Path; |
| use std::time::Duration; |
| use test_case::test_case; |
| use tokio::sync::Notify; |
| use tokio::sync::Semaphore; |
| use tokio::time::sleep; |
| use tokio::time::timeout; |
| use tracing_opentelemetry::OpenTelemetrySpanExt; |
| use wiremock::ResponseTemplate; |
|
|
| use uuid::Uuid; |
|
|
| use codex_protocol::mcp::CallToolResult as McpCallToolResult; |
| use pretty_assertions::assert_eq; |
| use serde::Deserialize; |
| use serde_json::json; |
| use std::path::PathBuf; |
| use std::sync::Arc; |
| use std::sync::OnceLock; |
| use std::time::Duration as StdDuration; |
|
|
| pub(crate) fn mcp_config_for_test(config: &crate::config::Config) -> Arc<codex_mcp::McpConfig> { |
| Arc::new(config.to_mcp_config_with_loaded_plugins( |
| &codex_core_plugins::PluginLoadOutcome::default(), |
| std::iter::empty(), |
| )) |
| } |
|
|
| |
| pub(crate) fn update_turn_settings_for_test( |
| turn: &mut TurnContext, |
| update: impl FnOnce(&mut super::step_settings::ResolvedStepSettings), |
| ) { |
| let mut settings = turn.initial_settings.as_ref().clone(); |
| update(&mut settings); |
| let settings = Arc::new(settings); |
| turn.initial_settings = Arc::clone(&settings); |
| turn.current_settings.store(settings); |
| } |
|
|
| impl StepContext { |
| pub(crate) fn for_test(turn: Arc<TurnContext>) -> Arc<Self> { |
| let environments = turn.environments.clone(); |
| |
| |
| let mut settings = turn.initial_settings.as_ref().clone(); |
| update_selected_settings_for_test(&mut settings, |selected| { |
| selected.approval_policy = turn.config.permissions.approval_policy.clone(); |
| selected.approvals_reviewer = turn.config.approvals_reviewer; |
| }); |
| settings.service_tier = turn.config.service_tier.clone(); |
| Arc::new(Self { |
| token_budget: token_budget::resolve_token_budget( |
| turn.configured_token_budget.as_ref(), |
| turn.use_model_token_budget_defaults, |
| settings.model_info.as_ref(), |
| ), |
| settings: Arc::new(settings), |
| session_telemetry: turn.session_telemetry.clone(), |
| turn: Arc::clone(&turn), |
| environments, |
| selected_capability_roots: Vec::new(), |
| executor_capability_discovery: None, |
| mcp: Arc::new(codex_mcp::McpBinding::empty(mcp_config_for_test( |
| &turn.config, |
| ))), |
| tool_router: Arc::new(ToolRouter::from_parts( |
| ToolRegistry::empty_for_test(), |
| Vec::new(), |
| ToolMode::Direct, |
| BTreeMap::new(), |
| None, |
| &[], |
| )), |
| loaded_agents_md: None, |
| }) |
| } |
|
|
| pub(crate) fn with_tool_router_for_test( |
| mut self: Arc<Self>, |
| tool_router: Arc<ToolRouter>, |
| ) -> Arc<Self> { |
| Arc::get_mut(&mut self) |
| .expect("test step context must not be shared before its tool router is set") |
| .tool_router = tool_router; |
| self |
| } |
| } |
|
|
| mod guardian_tests; |
|
|
| fn user_message(text: &str) -> ResponseItem { |
| ResponseItem::Message { |
| id: None, |
| role: "user".to_string(), |
| content: vec![ContentItem::InputText { |
| text: text.to_string(), |
| }], |
| phase: None, |
| internal_chat_message_metadata_passthrough: Some(InternalChatMessageMetadataPassthrough { |
| content_item_kinds: Some(vec![ContentItemKind("unknown".to_string())]), |
| ..Default::default() |
| }), |
| } |
| } |
|
|
| #[test] |
| fn assign_missing_response_item_ids_assigns_agent_message_ids() { |
| let items = Cow::Owned(vec![ |
| ResponseItem::AgentMessage { |
| id: None, |
| author: "worker".to_string(), |
| recipient: "root".to_string(), |
| content: vec![AgentMessageInputContent::InputText { |
| text: "done".to_string(), |
| }], |
| internal_chat_message_metadata_passthrough: None, |
| }, |
| user_message("hello"), |
| ]); |
|
|
| let items = Session::assign_missing_response_item_ids(items); |
|
|
| assert!(items[0].id().is_some_and(|id| id.starts_with("amsg_"))); |
| assert!(items[1].id().is_some_and(|id| id.starts_with("msg_"))); |
| } |
|
|
| #[test] |
| fn assign_missing_response_item_ids_assigns_additional_tools_ids() { |
| let items = Cow::Owned(vec![ResponseItem::AdditionalTools { |
| id: None, |
| role: "developer".to_string(), |
| tools: Vec::new(), |
| }]); |
|
|
| let items = Session::assign_missing_response_item_ids(items); |
|
|
| assert!(items[0].id().is_some_and(|id| id.starts_with("at_"))); |
| } |
|
|
| #[tokio::test] |
| async fn default_turn_context_assigns_missing_response_item_ids() { |
| let (session, turn_context) = make_session_and_context().await; |
| let response_item = user_message("hello"); |
|
|
| let (items, _) = session.prepare_conversation_items_for_history( |
| &turn_context, |
| turn_context.model_info(), |
| std::slice::from_ref(&response_item), |
| ); |
|
|
| assert!( |
| items[0] |
| .id() |
| .is_some_and(|item_id| item_id.starts_with("msg_")) |
| ); |
| } |
|
|
| fn assistant_message(text: &str) -> ResponseItem { |
| ResponseItem::Message { |
| id: None, |
| role: "assistant".to_string(), |
| content: vec![ContentItem::OutputText { |
| text: text.to_string(), |
| }], |
| phase: None, |
| internal_chat_message_metadata_passthrough: Some(InternalChatMessageMetadataPassthrough { |
| content_item_kinds: Some(vec![ContentItemKind("unknown".to_string())]), |
| ..Default::default() |
| }), |
| } |
| } |
|
|
| fn find_metric<'a>(resource_metrics: &'a ResourceMetrics, name: &str) -> &'a Metric { |
| for scope_metrics in resource_metrics.scope_metrics() { |
| for metric in scope_metrics.metrics() { |
| if metric.name() == name { |
| return metric; |
| } |
| } |
| } |
| panic!("metric {name} missing"); |
| } |
|
|
| fn single_histogram_attributes( |
| resource_metrics: &ResourceMetrics, |
| name: &str, |
| ) -> BTreeMap<String, String> { |
| let metric = find_metric(resource_metrics, name); |
| let AggregatedMetrics::F64(data) = metric.data() else { |
| panic!("expected floating-point histogram"); |
| }; |
| let MetricData::Histogram(histogram) = data else { |
| panic!("expected histogram"); |
| }; |
| let points = histogram.data_points().collect::<Vec<_>>(); |
| assert_eq!(points.len(), 1); |
| points[0] |
| .attributes() |
| .map(|attribute| { |
| ( |
| attribute.key.as_str().to_string(), |
| attribute.value.as_str().to_string(), |
| ) |
| }) |
| .collect() |
| } |
|
|
| #[test] |
| fn extension_metrics_preserve_session_metadata_tags() { |
| let metrics = MetricsClient::new( |
| MetricsConfig::in_memory( |
| "test", |
| "codex-core", |
| env!("CARGO_PKG_VERSION"), |
| InMemoryMetricExporter::default(), |
| ) |
| .with_runtime_reader(), |
| ) |
| .expect("in-memory metrics client"); |
| let session_telemetry = SessionTelemetry::new( |
| ThreadId::new(), |
| "gpt-5.4", |
| "gpt-5.4", |
| None, |
| None, |
| Some(TelemetryAuthMode::Chatgpt), |
| "test_originator".to_string(), |
| false, |
| "tty".to_string(), |
| SessionSource::Cli, |
| ) |
| .with_metrics_service_name("test_service") |
| .with_metrics(metrics.clone()); |
| let extension_metrics = super::extension_metrics::from_session_telemetry(session_telemetry); |
|
|
| extension_metrics.histogram( |
| "codex.test.extension", |
| 7, |
| &[ |
| ("component", "skills"), |
| ("app.version", "extension-version"), |
| ("auth_mode", "extension-auth"), |
| ("model", "extension-model"), |
| ("originator", "extension-originator"), |
| ("service_name", "extension-service"), |
| ("session_source", "extension-source"), |
| ], |
| ); |
|
|
| extension_metrics.counter( |
| "codex.test.extension.counter", |
| 2, |
| &[("component", "skills"), ("model", "extension-model")], |
| ); |
|
|
| let snapshot = metrics.snapshot().expect("metrics snapshot"); |
| let attributes = single_histogram_attributes(&snapshot, "codex.test.extension"); |
| let counter = find_metric(&snapshot, "codex.test.extension.counter"); |
| let AggregatedMetrics::U64(MetricData::Sum(sum)) = counter.data() else { |
| panic!("expected counter"); |
| }; |
| let points = sum.data_points().collect::<Vec<_>>(); |
| assert_eq!(points.len(), 1); |
| assert_eq!(points[0].value(), 2); |
| assert_eq!( |
| points[0] |
| .attributes() |
| .map(|attribute| ( |
| attribute.key.as_str().to_string(), |
| attribute.value.as_str().to_string(), |
| )) |
| .collect::<BTreeMap<_, _>>(), |
| attributes, |
| ); |
| assert_eq!( |
| attributes, |
| BTreeMap::from([ |
| ( |
| "app.version".to_string(), |
| env!("CARGO_PKG_VERSION").to_string(), |
| ), |
| ( |
| "auth_mode".to_string(), |
| TelemetryAuthMode::Chatgpt.to_string(), |
| ), |
| ("component".to_string(), "skills".to_string()), |
| ("model".to_string(), "gpt-5.4".to_string()), |
| ("originator".to_string(), "test_originator".to_string()), |
| ("service_name".to_string(), "test_service".to_string()), |
| ("session_source".to_string(), "cli".to_string()), |
| ]) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn world_state_extension_metrics_follow_turn_model_switch() { |
| struct WorldStateMetricsRecorder; |
|
|
| impl codex_extension_api::ContextContributor for WorldStateMetricsRecorder { |
| fn contribute_world_state<'a>( |
| &'a self, |
| input: codex_extension_api::WorldStateContributionInput<'a>, |
| ) -> codex_extension_api::ExtensionFuture< |
| 'a, |
| Vec<codex_extension_api::WorldStateSectionContribution>, |
| > { |
| Box::pin(async move { |
| input |
| .extension_metrics |
| .expect("turn metrics should be available") |
| .histogram("codex.test.extension.turn", 1, &[]); |
| Vec::new() |
| }) |
| } |
| } |
|
|
| let metrics = MetricsClient::new( |
| MetricsConfig::in_memory( |
| "test", |
| "codex-core", |
| env!("CARGO_PKG_VERSION"), |
| InMemoryMetricExporter::default(), |
| ) |
| .with_runtime_reader(), |
| ) |
| .expect("in-memory metrics client"); |
| let (mut session, mut turn_context) = make_session_and_context().await; |
| turn_context.session_telemetry = turn_context |
| .session_telemetry |
| .clone() |
| .with_metrics(metrics.clone()); |
| let next_model = if turn_context.model_info().slug == "gpt-5.4" { |
| "gpt-5.2" |
| } else { |
| "gpt-5.4" |
| }; |
| let turn_context = Arc::new( |
| turn_context |
| .with_model(next_model.to_string(), &session.services.models_manager) |
| .await, |
| ); |
| let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new(); |
| builder.prompt_contributor(Arc::new(WorldStateMetricsRecorder)); |
| session.services.extensions = Arc::new(builder.build()); |
|
|
| let _world_state = build_world_state_from_turn_context(&session, &turn_context).await; |
|
|
| let snapshot = metrics.snapshot().expect("metrics snapshot"); |
| let attributes = single_histogram_attributes(&snapshot, "codex.test.extension.turn"); |
| assert_eq!( |
| attributes.get("model").map(String::as_str), |
| Some(next_model) |
| ); |
| } |
|
|
| fn skill_message(text: &str) -> ResponseItem { |
| ResponseItem::Message { |
| id: None, |
| role: "user".to_string(), |
| content: vec![ContentItem::InputText { |
| text: text.to_string(), |
| }], |
| phase: None, |
| internal_chat_message_metadata_passthrough: None, |
| } |
| } |
|
|
| #[tokio::test] |
| async fn regular_turn_emits_turn_started_with_trace_id_without_waiting_for_startup_prewarm() { |
| let _trace_test_context = install_test_tracing("codex-core-tests"); |
| let request_parent = W3cTraceContext { |
| traceparent: Some("00-00000000000000000000000000000011-0000000000000022-01".into()), |
| tracestate: Some("vendor=value".into()), |
| }; |
| let request_span = info_span!("app_server.request"); |
| assert!(set_parent_from_w3c_trace_context( |
| &request_span, |
| &request_parent |
| )); |
| let (sess, tc, rx) = make_session_and_context_with_rx() |
| .instrument(request_span) |
| .await; |
| assert_eq!( |
| tc.trace_id.as_deref(), |
| Some("00000000000000000000000000000011") |
| ); |
| let (_tx, startup_prewarm_rx) = tokio::sync::oneshot::channel::<()>(); |
| let handle = tokio::spawn(async move { |
| let _ = startup_prewarm_rx.await; |
| Ok(test_model_client_session()) |
| }); |
|
|
| sess.set_session_startup_prewarm( |
| crate::session_startup_prewarm::SessionStartupPrewarmHandle::new( |
| handle, |
| std::time::Instant::now(), |
| crate::client::WEBSOCKET_CONNECT_TIMEOUT, |
| ), |
| ) |
| .await; |
| sess.spawn_task( |
| Arc::clone(&tc), |
| Vec::new(), |
| crate::tasks::RegularTask::new(), |
| ) |
| .await; |
|
|
| let first = tokio::time::timeout(std::time::Duration::from_millis(200), rx.recv()) |
| .await |
| .expect("expected turn started event without waiting for startup prewarm") |
| .expect("channel open"); |
| let EventMsg::TurnStarted(turn_started) = first.msg else { |
| panic!("expected turn started event"); |
| }; |
| assert_eq!(turn_started.turn_id, tc.sub_id); |
| assert_eq!(turn_started.trace_id, tc.trace_id); |
| assert_eq!(turn_started.root_turn_id, Some(tc.sub_id.clone())); |
|
|
| sess.abort_all_tasks(TurnAbortReason::Interrupted).await; |
| } |
|
|
| #[tokio::test] |
| async fn request_mcp_server_elicitation_auto_accepts_when_auto_deny_is_enabled() { |
| let (session, turn_context, rx) = make_session_and_context_with_rx().await; |
| session |
| .services |
| .mcp_runtime |
| .set_elicitations_auto_deny( true); |
|
|
| let response = session |
| .request_mcp_server_elicitation( |
| turn_context.as_ref(), |
| "codex_apps".to_string(), |
| RequestId::String("request-1".into()), |
| ElicitationRequest::Form { |
| meta: None, |
| message: "Allow this request?".to_string(), |
| requested_schema: json!({ |
| "type": "object", |
| "properties": {}, |
| }), |
| }, |
| ) |
| .await; |
|
|
| assert_eq!( |
| response.response, |
| Some(ElicitationResponse { |
| action: ElicitationAction::Accept, |
| content: Some(json!({})), |
| meta: None, |
| }) |
| ); |
| assert!(!response.sent); |
| assert!(rx.try_recv().is_err()); |
| } |
|
|
| #[tokio::test] |
| async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted() { |
| let (sess, tc, rx) = make_session_and_context_with_rx().await; |
| let (_tx, startup_prewarm_rx) = tokio::sync::oneshot::channel::<()>(); |
| let handle = tokio::spawn(async move { |
| let _ = startup_prewarm_rx.await; |
| Ok(test_model_client_session()) |
| }); |
|
|
| sess.set_session_startup_prewarm( |
| crate::session_startup_prewarm::SessionStartupPrewarmHandle::new( |
| handle, |
| std::time::Instant::now(), |
| crate::client::WEBSOCKET_CONNECT_TIMEOUT, |
| ), |
| ) |
| .await; |
| sess.spawn_task( |
| Arc::clone(&tc), |
| Vec::new(), |
| crate::tasks::RegularTask::new(), |
| ) |
| .await; |
|
|
| let first = tokio::time::timeout(std::time::Duration::from_millis(200), rx.recv()) |
| .await |
| .expect("expected turn started event without waiting for startup prewarm") |
| .expect("channel open"); |
| assert!(matches!( |
| first.msg, |
| EventMsg::TurnStarted(TurnStartedEvent { turn_id, .. }) if turn_id == tc.sub_id |
| )); |
|
|
| sess.abort_all_tasks(TurnAbortReason::Interrupted).await; |
|
|
| let marker_evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) |
| .await |
| .expect("expected turn aborted marker event") |
| .expect("channel open"); |
| assert!(matches!(marker_evt.msg, EventMsg::RawResponseItem(_))); |
|
|
| let second = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) |
| .await |
| .expect("expected turn aborted event") |
| .expect("channel open"); |
| let EventMsg::TurnAborted(TurnAbortedEvent { |
| turn_id, |
| reason, |
| started_at, |
| completed_at, |
| duration_ms, |
| }) = second.msg |
| else { |
| panic!("expected turn aborted event"); |
| }; |
| assert_eq!(turn_id, Some(tc.sub_id.clone())); |
| assert_eq!(reason, TurnAbortReason::Interrupted); |
| assert!(started_at.is_some()); |
| assert!(completed_at.is_some()); |
| assert!(duration_ms.is_some()); |
| } |
|
|
| fn test_model_client_session() -> crate::client::ModelClientSession { |
| let thread_id = ThreadId::try_from("00000000-0000-4000-8000-000000000001") |
| .expect("test thread id should be valid"); |
| crate::client::ModelClient::new( |
| None, |
| AgentIdentityAuthPolicy::JwtOnly, |
| thread_id, |
| ModelProviderInfo::create_openai_provider( None), |
| codex_protocol::protocol::SessionSource::Exec, |
| "test_originator".to_string(), |
| None, |
| true, |
| false, |
| false, |
| None, |
| false, |
| None, |
| HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), |
| codex_model_provider::WorkspaceRoutingContext::new( |
| "https://chatgpt.com/backend-api".into(), |
| ), |
| ) |
| .new_session() |
| } |
|
|
| pub(super) fn raw_history_items(history: &ContextManager) -> Vec<ResponseItem> { |
| history.raw_items().cloned().collect() |
| } |
|
|
| fn raw_envelopes(items: &[ResponseItemEnvelope]) -> Vec<ResponseItem> { |
| items.iter().map(|envelope| envelope.item.clone()).collect() |
| } |
|
|
| fn developer_input_texts(items: &[ResponseItem]) -> Vec<&str> { |
| items |
| .iter() |
| .filter_map(|item| match item { |
| ResponseItem::Message { role, content, .. } if role == "developer" => { |
| 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 developer_message_texts(items: &[ResponseItem]) -> Vec<Vec<&str>> { |
| items |
| .iter() |
| .filter_map(|item| match item { |
| ResponseItem::Message { role, content, .. } if role == "developer" => { |
| Some(content.as_slice()) |
| } |
| _ => None, |
| }) |
| .map(|content| { |
| content |
| .iter() |
| .filter_map(|item| match item { |
| ContentItem::InputText { text } => Some(text.as_str()), |
| _ => None, |
| }) |
| .collect() |
| }) |
| .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 write_project_hooks(dot_codex: &Path) -> std::io::Result<()> { |
| std::fs::create_dir_all(dot_codex)?; |
| std::fs::write( |
| dot_codex.join("hooks.json"), |
| r#"{ |
| "hooks": { |
| "SessionStart": [ |
| { |
| "hooks": [ |
| { |
| "type": "command", |
| "command": "echo hello from hook" |
| } |
| ] |
| } |
| ] |
| } |
| }"#, |
| ) |
| } |
|
|
| async fn write_project_trust_config( |
| codex_home: &Path, |
| trusted_projects: &[(&Path, TrustLevel)], |
| ) -> std::io::Result<()> { |
| tokio::fs::write( |
| codex_home.join(codex_config::CONFIG_TOML_FILE), |
| toml::to_string(&ConfigToml { |
| projects: Some( |
| trusted_projects |
| .iter() |
| .map(|(project, trust_level)| { |
| ( |
| project_trust_key(project), |
| ProjectConfig { |
| trust_level: Some(*trust_level), |
| }, |
| ) |
| }) |
| .collect::<std::collections::HashMap<_, _>>(), |
| ), |
| ..Default::default() |
| }) |
| .expect("serialize config"), |
| ) |
| .await |
| } |
|
|
| async fn preview_session_start_hooks( |
| config: &crate::config::Config, |
| ) -> std::io::Result<Vec<codex_protocol::protocol::HookRunSummary>> { |
| let thread_id = ThreadId::new(); |
| let (hooks, _result_receiver) = Hooks::new( |
| HooksConfig { |
| feature_enabled: true, |
| config_layer_stack: Some(config.config_layer_stack.clone()), |
| ..HooksConfig::default() |
| }, |
| thread_id, |
| Arc::new(CoreHookMcpExecutor { |
| runtime: Arc::new(McpRuntime::empty(config.prefix_mcp_tool_names())), |
| thread_id, |
| }), |
| ) |
| .expect("initialize hooks for session-start preview"); |
|
|
| Ok( |
| hooks.preview_session_start(&codex_hooks::SessionStartRequest { |
| session_id: thread_id, |
| cwd: config.cwd.clone(), |
| transcript_path: None, |
| model: "gpt-5.2".to_string(), |
| permission_mode: "default".to_string(), |
| target: codex_hooks::StartHookTarget::SessionStart { |
| source: codex_hooks::SessionStartSource::Startup, |
| }, |
| }), |
| ) |
| } |
|
|
| pub(crate) fn tool_registry_for_test_step( |
| step_context: &StepContext, |
| ) -> (ToolRegistry, Vec<ToolSpec>) { |
| let mut registry = crate::tools::spec_plan::build_core_tool_registry( |
| step_context.turn.as_ref(), |
| step_context.turn.model_info(), |
| &step_context.environments, |
| step_context.mcp.as_ref(), |
| None, |
| None, |
| ); |
| let hosted_specs = crate::tools::spec_plan::append_source_tools( |
| step_context.turn.as_ref(), |
| step_context.turn.model_info(), |
| &mut registry, |
| Vec::new(), |
| Vec::new(), |
| &step_context.turn.dynamic_tools, |
| ); |
| (registry, hosted_specs) |
| } |
|
|
| fn test_tool_runtime(session: Arc<Session>, turn_context: Arc<TurnContext>) -> ToolCallRuntime { |
| let step_context = StepContext::for_test(Arc::clone(&turn_context)); |
| let (registry, hosted_specs) = tool_registry_for_test_step(step_context.as_ref()); |
| let router = Arc::new(ToolRouter::from_registry( |
| step_context.turn.as_ref(), |
| step_context.turn.model_info(), |
| registry, |
| hosted_specs, |
| &Default::default(), |
| )); |
| let step_context = step_context.with_tool_router_for_test(router); |
| let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); |
| ToolCallRuntime::new(session, step_context, tracker) |
| } |
|
|
| fn make_connector(id: &str, name: &str) -> AppInfo { |
| AppInfo { |
| id: id.to_string(), |
| name: name.to_string(), |
| description: None, |
| logo_url: None, |
| logo_url_dark: None, |
| icon_assets: None, |
| icon_dark_assets: None, |
| distribution_channel: None, |
| branding: None, |
| app_metadata: None, |
| labels: None, |
| install_url: None, |
| is_accessible: true, |
| is_enabled: true, |
| plugin_display_names: Vec::new(), |
| } |
| } |
|
|
| #[test] |
| fn assistant_message_stream_parsers_can_be_seeded_from_output_item_added_text() { |
| let mut parsers = AssistantMessageStreamParsers::new( false); |
| let item_id = "msg-1"; |
|
|
| let seeded = parsers.seed_item_text(item_id, "hello <oai-mem-citation>doc"); |
| let parsed = parsers.parse_delta(item_id, "1</oai-mem-citation> world"); |
| let tail = parsers.finish_item(item_id); |
|
|
| assert_eq!(seeded.visible_text, "hello "); |
| assert_eq!(seeded.citations, Vec::<String>::new()); |
| assert_eq!(parsed.visible_text, " world"); |
| assert_eq!(parsed.citations, vec!["doc1".to_string()]); |
| assert_eq!(tail.visible_text, ""); |
| assert_eq!(tail.citations, Vec::<String>::new()); |
| } |
|
|
| #[test] |
| fn assistant_message_stream_parsers_seed_buffered_prefix_stays_out_of_finish_tail() { |
| let mut parsers = AssistantMessageStreamParsers::new( false); |
| let item_id = "msg-1"; |
|
|
| let seeded = parsers.seed_item_text(item_id, "hello <oai-mem-"); |
| let parsed = parsers.parse_delta(item_id, "citation>doc</oai-mem-citation> world"); |
| let tail = parsers.finish_item(item_id); |
|
|
| assert_eq!(seeded.visible_text, "hello "); |
| assert_eq!(seeded.citations, Vec::<String>::new()); |
| assert_eq!(parsed.visible_text, " world"); |
| assert_eq!(parsed.citations, vec!["doc".to_string()]); |
| assert_eq!(tail.visible_text, ""); |
| assert_eq!(tail.citations, Vec::<String>::new()); |
| } |
|
|
| #[test] |
| fn assistant_message_stream_parsers_seed_plan_parser_across_added_and_delta_boundaries() { |
| let mut parsers = AssistantMessageStreamParsers::new( true); |
| let item_id = "msg-1"; |
|
|
| let seeded = parsers.seed_item_text(item_id, "Intro\n<proposed"); |
| let parsed = parsers.parse_delta(item_id, "_plan>\n- step\n</proposed_plan>\nOutro"); |
| let tail = parsers.finish_item(item_id); |
|
|
| assert_eq!(seeded.visible_text, "Intro\n"); |
| assert_eq!( |
| seeded.plan_segments, |
| vec![ProposedPlanSegment::Normal("Intro\n".to_string())] |
| ); |
| assert_eq!(parsed.visible_text, "Outro"); |
| assert_eq!( |
| parsed.plan_segments, |
| vec![ |
| ProposedPlanSegment::ProposedPlanStart, |
| ProposedPlanSegment::ProposedPlanDelta("- step\n".to_string()), |
| ProposedPlanSegment::ProposedPlanEnd, |
| ProposedPlanSegment::Normal("Outro".to_string()), |
| ] |
| ); |
| assert_eq!(tail.visible_text, ""); |
| assert!(tail.plan_segments.is_empty()); |
| } |
|
|
| #[test] |
| fn validated_network_policy_amendment_host_allows_normalized_match() { |
| let amendment = NetworkPolicyAmendment { |
| host: "ExAmPlE.Com.:443".to_string(), |
| action: NetworkPolicyRuleAction::Allow, |
| }; |
| let context = NetworkApprovalContext { |
| host: "example.com".to_string(), |
| protocol: NetworkApprovalProtocol::Https, |
| }; |
|
|
| let host = Session::validated_network_policy_amendment_host(&amendment, &context) |
| .expect("normalized hosts should match"); |
|
|
| assert_eq!(host, "example.com"); |
| } |
|
|
| #[test] |
| fn validated_network_policy_amendment_host_rejects_mismatch() { |
| let amendment = NetworkPolicyAmendment { |
| host: "evil.example.com".to_string(), |
| action: NetworkPolicyRuleAction::Deny, |
| }; |
| let context = NetworkApprovalContext { |
| host: "api.example.com".to_string(), |
| protocol: NetworkApprovalProtocol::Https, |
| }; |
|
|
| let err = Session::validated_network_policy_amendment_host(&amendment, &context) |
| .expect_err("mismatched hosts should be rejected"); |
|
|
| let message = err.to_string(); |
| assert!(message.contains("does not match approved host")); |
| } |
|
|
| #[tokio::test] |
| async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyhow::Result<()> { |
| let permission_profile = PermissionProfile::workspace_write(); |
| let spec = crate::config::NetworkProxySpec::from_config_and_constraints( |
| NetworkProxyConfig::default(), |
| None, |
| &permission_profile, |
| )?; |
| let mut exec_policy = Policy::empty(); |
| exec_policy.add_network_rule( |
| "example.com", |
| NetworkRuleProtocol::Https, |
| Decision::Allow, |
| None, |
| )?; |
|
|
| let (started_proxy, _) = Session::start_managed_network_proxy( |
| &spec, |
| &exec_policy, |
| &permission_profile, |
| SandboxType::None, |
| None, |
| None, |
| false, |
| crate::config::NetworkProxyAuditMetadata::default(), |
| ) |
| .await?; |
|
|
| let current_cfg = started_proxy.proxy().current_cfg().await?; |
| assert_eq!( |
| current_cfg.allowed_domains(), |
| Some(vec!["example.com".to_string()]) |
| ); |
| Ok(()) |
| } |
|
|
| #[tokio::test] |
| async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() -> anyhow::Result<()> |
| { |
| let permission_profile = PermissionProfile::workspace_write(); |
| let spec = crate::config::NetworkProxySpec::from_config_and_constraints( |
| NetworkProxyConfig::default(), |
| Some(NetworkConstraints { |
| domains: Some(NetworkDomainPermissionsToml { |
| entries: std::collections::BTreeMap::from([( |
| "managed.example.com".to_string(), |
| NetworkDomainPermissionToml::Allow, |
| )]), |
| }), |
| managed_allowed_domains_only: Some(true), |
| ..Default::default() |
| }), |
| &permission_profile, |
| )?; |
| let mut exec_policy = Policy::empty(); |
| exec_policy.add_network_rule( |
| "example.com", |
| NetworkRuleProtocol::Https, |
| Decision::Allow, |
| None, |
| )?; |
|
|
| let (started_proxy, _) = Session::start_managed_network_proxy( |
| &spec, |
| &exec_policy, |
| &permission_profile, |
| SandboxType::None, |
| None, |
| None, |
| false, |
| crate::config::NetworkProxyAuditMetadata::default(), |
| ) |
| .await?; |
|
|
| let current_cfg = started_proxy.proxy().current_cfg().await?; |
| assert_eq!( |
| current_cfg.allowed_domains(), |
| Some(vec!["managed.example.com".to_string()]) |
| ); |
| Ok(()) |
| } |
|
|
| #[tokio::test] |
| async fn managed_network_proxy_decider_survives_full_access_start() -> anyhow::Result<()> { |
| let full_access_permission_profile = PermissionProfile::Disabled; |
| let spec = crate::config::NetworkProxySpec::from_config_and_constraints( |
| NetworkProxyConfig::default(), |
| Some(NetworkConstraints { |
| enabled: Some(true), |
| ..Default::default() |
| }), |
| &full_access_permission_profile, |
| )?; |
| let exec_policy = Policy::empty(); |
| let decider_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); |
| let network_policy_decider: Arc<dyn codex_network_proxy::NetworkPolicyDecider> = Arc::new({ |
| let decider_calls = Arc::clone(&decider_calls); |
| move |_request| { |
| decider_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); |
| async { codex_network_proxy::NetworkDecision::ask("not_allowed") } |
| } |
| }); |
|
|
| let (started_proxy, _) = Session::start_managed_network_proxy( |
| &spec, |
| &exec_policy, |
| &full_access_permission_profile, |
| SandboxType::None, |
| Some(network_policy_decider), |
| None, |
| true, |
| crate::config::NetworkProxyAuditMetadata::default(), |
| ) |
| .await?; |
|
|
| let spec = spec.recompute_for_permission_profile(&PermissionProfile::workspace_write())?; |
| spec.apply_to_started_proxy(&started_proxy).await?; |
| let current_cfg = started_proxy.proxy().current_cfg().await?; |
| assert_eq!(current_cfg.allowed_domains(), None); |
|
|
| use tokio::io::AsyncReadExt as _; |
| use tokio::io::AsyncWriteExt as _; |
|
|
| let prepared = started_proxy |
| .proxy() |
| .prepare_for_remote_environment(std::collections::HashMap::new(), "test-bridge")?; |
| let proxy_addr = prepared.env["HTTP_PROXY"] |
| .strip_prefix("http://") |
| .expect("HTTP proxy URL") |
| .parse::<std::net::SocketAddr>()?; |
| let mut stream = tokio::net::TcpStream::connect(proxy_addr).await?; |
| stream |
| .write_all( |
| b"GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n", |
| ) |
| .await?; |
| let mut buffer = [0_u8; 4096]; |
| let bytes_read = tokio::time::timeout(StdDuration::from_secs(2), stream.read(&mut buffer)) |
| .await |
| .expect("timed out waiting for proxy response")?; |
| let response = String::from_utf8_lossy(&buffer[..bytes_read]); |
|
|
| assert!( |
| response.starts_with("HTTP/1.1 403 Forbidden"), |
| "unexpected proxy response: {response}" |
| ); |
| assert!( |
| response.contains("x-proxy-error: blocked-by-allowlist"), |
| "unexpected proxy response: {response}" |
| ); |
| assert_eq!( |
| decider_calls.load(std::sync::atomic::Ordering::SeqCst), |
| 1, |
| "unexpected proxy response: {response}" |
| ); |
| Ok(()) |
| } |
|
|
| #[tokio::test] |
| async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow::Result<()> { |
| let (session, _turn_context) = make_session_and_context().await; |
| let initial_permission_profile = PermissionProfile::workspace_write(); |
|
|
| let mut network_config = NetworkProxyConfig::default(); |
| network_config.set_allowed_domains(vec!["evil.com".to_string()]); |
| let requirements = NetworkConstraints { |
| enabled: Some(true), |
| domains: Some(NetworkDomainPermissionsToml { |
| entries: std::collections::BTreeMap::from([( |
| "*.example.com".to_string(), |
| NetworkDomainPermissionToml::Allow, |
| )]), |
| }), |
| ..Default::default() |
| }; |
| let spec = crate::config::NetworkProxySpec::from_config_and_constraints( |
| network_config, |
| Some(requirements), |
| &initial_permission_profile, |
| )?; |
| let network_policy_decider: Arc<dyn codex_network_proxy::NetworkPolicyDecider> = |
| Arc::new(|_request| async { codex_network_proxy::NetworkDecision::ask("not_allowed") }); |
| let (started_proxy, _) = Session::start_managed_network_proxy( |
| &spec, |
| &Policy::empty(), |
| &initial_permission_profile, |
| SandboxType::None, |
| Some(Arc::clone(&network_policy_decider)), |
| None, |
| false, |
| crate::config::NetworkProxyAuditMetadata::default(), |
| ) |
| .await?; |
| assert_eq!( |
| started_proxy.proxy().current_cfg().await?.allowed_domains(), |
| Some(vec!["*.example.com".to_string(), "evil.com".to_string()]) |
| ); |
|
|
| { |
| let mut state = session.state.lock().await; |
| let mut config = (*state.session_configuration.original_config_do_not_use).clone(); |
| config.permissions.network = Some(spec); |
| config |
| .permissions |
| .set_permission_profile(initial_permission_profile.clone()) |
| .expect("test setup should allow permission profile"); |
| state.session_configuration.original_config_do_not_use = Arc::new(config); |
| state |
| .session_configuration |
| .set_permission_profile_for_tests(initial_permission_profile) |
| .expect("test setup should allow permission profile"); |
| #[cfg(windows)] |
| { |
| state.session_configuration.windows_sandbox_type = SandboxType::WindowsMxc; |
| } |
| } |
| session |
| .services |
| .network_proxy |
| .store(Some(Arc::new(started_proxy))); |
|
|
| session |
| .new_turn_with_sub_id( |
| "sandbox-policy-change".to_string(), |
| SessionSettingsUpdate { |
| sandbox_policy: Some(SandboxPolicy::DangerFullAccess), |
| ..Default::default() |
| }, |
| Default::default(), |
| ) |
| .await?; |
|
|
| let started_proxy = session |
| .services |
| .network_proxy |
| .load_full() |
| .expect("managed network proxy should be present"); |
| assert_eq!( |
| started_proxy.proxy().current_cfg().await?.allowed_domains(), |
| Some(vec!["*.example.com".to_string()]) |
| ); |
| #[cfg(windows)] |
| { |
| assert_eq!( |
| started_proxy.proxy().managed_proxy_routing(), |
| ManagedProxyRouting::DedicatedListeners |
| ); |
| let rebuilt_policy_decider = started_proxy |
| .network_policy_decider() |
| .expect("rebuilt managed network proxy should retain its policy decider"); |
| assert!(Arc::ptr_eq( |
| &network_policy_decider, |
| &rebuilt_policy_decider |
| )); |
| } |
|
|
| Ok(()) |
| } |
|
|
| #[tokio::test] |
| async fn refresh_clears_disabled_managed_network_proxy() -> anyhow::Result<()> { |
| let permission_profile = PermissionProfile::workspace_write(); |
| let enabled_spec = crate::config::NetworkProxySpec::from_config_and_constraints( |
| NetworkProxyConfig::default(), |
| Some(NetworkConstraints { |
| enabled: Some(true), |
| ..Default::default() |
| }), |
| &permission_profile, |
| )?; |
| let disabled_spec = crate::config::NetworkProxySpec::from_config_and_constraints( |
| NetworkProxyConfig::default(), |
| Some(NetworkConstraints { |
| enabled: Some(false), |
| ..Default::default() |
| }), |
| &permission_profile, |
| )?; |
| let session = make_session_with_config(move |config| { |
| config |
| .permissions |
| .set_permission_profile(permission_profile) |
| .expect("test setup should allow permission profile"); |
| config.permissions.network = Some(enabled_spec); |
| }) |
| .await?; |
| assert!(session.services.network_proxy.load_full().is_some()); |
|
|
| { |
| let mut state = session.state.lock().await; |
| let mut config = (*state.session_configuration.original_config_do_not_use).clone(); |
| config.permissions.network = Some(disabled_spec); |
| state.session_configuration.original_config_do_not_use = Arc::new(config); |
| } |
|
|
| session |
| .refresh_managed_network_proxy_for_current_permission_profile() |
| .await; |
|
|
| assert!(session.services.network_proxy.load_full().is_none()); |
| assert!(session.new_default_turn().await.network.is_none()); |
| Ok(()) |
| } |
|
|
| #[tokio::test] |
| async fn danger_full_access_turns_do_not_expose_managed_network_proxy() -> anyhow::Result<()> { |
| let network_spec = crate::config::NetworkProxySpec::from_config_and_constraints( |
| NetworkProxyConfig::default(), |
| Some(NetworkConstraints { |
| enabled: Some(true), |
| ..Default::default() |
| }), |
| &PermissionProfile::Disabled, |
| )?; |
|
|
| let session = make_session_with_config(move |config| { |
| config |
| .permissions |
| .set_permission_profile(PermissionProfile::Disabled) |
| .expect("test setup should allow permission profile"); |
| config.permissions.network = Some(network_spec); |
| }) |
| .await?; |
|
|
| let turn_context = session.new_default_turn().await; |
| assert!(turn_context.network.is_none()); |
| Ok(()) |
| } |
|
|
| #[tokio::test] |
| async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> anyhow::Result<()> { |
| #[derive(Default)] |
| struct ProbeToolRuntime { |
| enforce_managed_network: Vec<bool>, |
| } |
|
|
| impl crate::tools::sandboxing::Approvable<TurnEnvironment> for ProbeToolRuntime { |
| fn approval_action( |
| &self, |
| _req: &TurnEnvironment, |
| call_id: &str, |
| ) -> std::io::Result<crate::tools::sandboxing::ApprovalAction> { |
| Ok(crate::tools::sandboxing::ApprovalAction::ExecCommand { |
| id: call_id.to_string(), |
| environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), |
| command: Vec::new(), |
| hook_command: String::new(), |
| cwd: PathUri::from_abs_path(&std::env::temp_dir().abs()), |
| sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, |
| additional_permissions: None, |
| justification: None, |
| tty: false, |
| proposed_execpolicy_amendment: None, |
| }) |
| } |
| } |
|
|
| impl crate::tools::sandboxing::Sandboxable for ProbeToolRuntime { |
| fn sandbox_preference(&self) -> codex_sandboxing::SandboxablePreference { |
| codex_sandboxing::SandboxablePreference::Auto |
| } |
| } |
|
|
| impl crate::tools::sandboxing::ToolRuntime<TurnEnvironment, ()> for ProbeToolRuntime { |
| fn turn_environment<'a>(&self, req: &'a TurnEnvironment) -> &'a TurnEnvironment { |
| req |
| } |
|
|
| async fn run( |
| &mut self, |
| _req: &TurnEnvironment, |
| attempt: &crate::tools::sandboxing::SandboxAttempt<'_>, |
| _ctx: &crate::tools::sandboxing::ToolCtx, |
| ) -> Result<(), crate::tools::sandboxing::ToolError> { |
| self.enforce_managed_network |
| .push(attempt.enforce_managed_network); |
| Ok(()) |
| } |
| } |
|
|
| let network_spec = crate::config::NetworkProxySpec::from_config_and_constraints( |
| NetworkProxyConfig::default(), |
| Some(NetworkConstraints { |
| enabled: Some(true), |
| ..Default::default() |
| }), |
| &PermissionProfile::Disabled, |
| )?; |
|
|
| let session = make_session_with_config(move |config| { |
| config |
| .permissions |
| .set_permission_profile(PermissionProfile::Disabled) |
| .expect("test setup should allow permission profile"); |
| config.permissions.network = Some(network_spec); |
|
|
| let layers = config |
| .config_layer_stack |
| .all_layers_low_to_high() |
| .cloned() |
| .collect(); |
| let mut requirements = config.config_layer_stack.requirements().clone(); |
| requirements.network = Some(Sourced::new( |
| NetworkConstraints { |
| enabled: Some(true), |
| ..Default::default() |
| }, |
| RequirementSource::LegacyManagedConfigTomlFromMdm, |
| )); |
| let mut requirements_toml = config.config_layer_stack.requirements_toml().clone(); |
| requirements_toml.network = Some(codex_config::NetworkRequirementsToml { |
| enabled: Some(true), |
| ..Default::default() |
| }); |
| config.config_layer_stack = ConfigLayerStack::new(layers, requirements, requirements_toml) |
| .expect("rebuild config layer stack with network requirements"); |
| }) |
| .await?; |
|
|
| let turn = session.new_default_turn().await; |
| assert!(turn.network.is_none()); |
|
|
| let mut orchestrator = crate::tools::orchestrator::ToolOrchestrator::new(); |
| let mut tool = ProbeToolRuntime::default(); |
| let tool_ctx = crate::tools::sandboxing::ToolCtx { |
| cancellation_token: CancellationToken::new(), |
| session: Arc::clone(&session), |
| step_context: StepContext::for_test(Arc::clone(&turn)), |
| call_id: "probe-call".to_string(), |
| tool_name: codex_tools::ToolName::plain("probe"), |
| }; |
|
|
| orchestrator |
| .run( |
| &mut tool, |
| turn.environments |
| .primary() |
| .expect("turn should have a primary environment"), |
| &tool_ctx, |
| ) |
| .await |
| .expect("probe runtime should succeed"); |
|
|
| assert_eq!(tool.enforce_managed_network, vec![false]); |
|
|
| Ok(()) |
| } |
|
|
| #[tokio::test] |
| async fn workspace_write_turns_continue_to_expose_managed_network_proxy() -> anyhow::Result<()> { |
| let permission_profile = PermissionProfile::workspace_write(); |
| let network_spec = crate::config::NetworkProxySpec::from_config_and_constraints( |
| NetworkProxyConfig::default(), |
| Some(NetworkConstraints { |
| enabled: Some(true), |
| ..Default::default() |
| }), |
| &permission_profile, |
| )?; |
|
|
| let session = make_session_with_config(move |config| { |
| config |
| .permissions |
| .set_permission_profile(permission_profile) |
| .expect("test setup should allow permission profile"); |
| config.permissions.network = Some(network_spec); |
| }) |
| .await?; |
|
|
| let turn_context = session.new_default_turn().await; |
| assert!(turn_context.network.is_some()); |
| Ok(()) |
| } |
|
|
| #[tokio::test] |
| async fn disabled_managed_network_does_not_start_or_expose_proxy() -> anyhow::Result<()> { |
| let permission_profile = PermissionProfile::workspace_write(); |
| let network_spec = crate::config::NetworkProxySpec::from_config_and_constraints( |
| NetworkProxyConfig::default(), |
| Some(NetworkConstraints { |
| enabled: Some(false), |
| ..Default::default() |
| }), |
| &permission_profile, |
| )?; |
|
|
| let (session, rx) = make_session_with_config_and_rx(move |config| { |
| config |
| .permissions |
| .set_permission_profile(permission_profile) |
| .expect("test setup should allow permission profile"); |
| config.permissions.network = Some(network_spec); |
| }) |
| .await?; |
|
|
| assert!(session.services.network_proxy.load_full().is_none()); |
| assert!(session.new_default_turn().await.network.is_none()); |
|
|
| loop { |
| let event = rx.recv().await.expect("channel open"); |
| if let EventMsg::SessionConfigured(event) = event.msg { |
| assert!(event.network_proxy.is_none()); |
| break; |
| } |
| } |
|
|
| Ok(()) |
| } |
|
|
| #[tokio::test] |
| async fn user_shell_commands_do_not_inherit_managed_network_proxy() -> anyhow::Result<()> { |
| let permission_profile = PermissionProfile::workspace_write(); |
| let network_spec = crate::config::NetworkProxySpec::from_config_and_constraints( |
| NetworkProxyConfig::default(), |
| Some(NetworkConstraints { |
| enabled: Some(true), |
| ..Default::default() |
| }), |
| &permission_profile, |
| )?; |
|
|
| let (session, rx) = make_session_with_config_and_rx(move |config| { |
| config |
| .permissions |
| .set_permission_profile(permission_profile) |
| .expect("test setup should allow permission profile"); |
| config.permissions.network = Some(network_spec); |
| }) |
| .await?; |
|
|
| let turn_context = session.new_default_turn().await; |
| assert!(turn_context.network.is_some()); |
|
|
| #[cfg(windows)] |
| let command = r#"$val = $env:HTTP_PROXY; if ([string]::IsNullOrEmpty($val)) { $val = 'not-set' } ; [System.Console]::Write($val)"#.to_string(); |
| #[cfg(not(windows))] |
| let command = r#"sh -c "printf '%s' \"${HTTP_PROXY:-not-set}\"""#.to_string(); |
|
|
| execute_user_shell_command( |
| Arc::clone(&session), |
| turn_context, |
| command, |
| None, |
| CancellationToken::new(), |
| UserShellCommandMode::StandaloneTurn, |
| ) |
| .await; |
|
|
| loop { |
| let event = rx.recv().await.expect("channel open"); |
| if let EventMsg::ExecCommandEnd(event) = event.msg { |
| assert_eq!(event.exit_code, 0); |
| assert_eq!(event.stdout.trim(), "not-set"); |
| break; |
| } |
| } |
|
|
| Ok(()) |
| } |
|
|
| #[tokio::test] |
| async fn user_shell_commands_remain_login_shells_when_model_login_shells_are_disabled() |
| -> anyhow::Result<()> { |
| let (session, rx) = make_session_with_config_and_rx(|config| { |
| config.permissions.allow_login_shell = false; |
| }) |
| .await?; |
| let turn_context = session.new_default_turn().await; |
| let command = "echo managed-login-shell".to_string(); |
| let expected_command = session |
| .user_shell() |
| .derive_exec_args(&command, true); |
|
|
| execute_user_shell_command( |
| Arc::clone(&session), |
| turn_context, |
| command, |
| None, |
| CancellationToken::new(), |
| UserShellCommandMode::StandaloneTurn, |
| ) |
| .await; |
|
|
| loop { |
| let event = rx.recv().await.expect("channel open"); |
| if let EventMsg::ExecCommandBegin(event) = event.msg { |
| assert_eq!(event.command, expected_command); |
| break; |
| } |
| } |
|
|
| Ok(()) |
| } |
|
|
| #[tokio::test] |
| async fn reload_user_config_layer_updates_effective_apps_config() { |
| let (session, _turn_context) = make_session_and_context().await; |
| let codex_home = session.codex_home().await; |
| std::fs::create_dir_all(&codex_home).expect("create codex home"); |
| let config_toml_path = codex_home.join(CONFIG_TOML_FILE); |
| std::fs::write( |
| &config_toml_path, |
| "[apps.calendar]\nenabled = false\ndestructive_enabled = false\n", |
| ) |
| .expect("write user config"); |
|
|
| session.reload_user_config_layer().await; |
|
|
| let config = session.get_config().await; |
| let apps_toml = config |
| .config_layer_stack |
| .effective_config() |
| .as_table() |
| .and_then(|table| table.get("apps")) |
| .cloned() |
| .expect("apps table"); |
| let apps = codex_config::types::AppsConfigToml::deserialize(apps_toml) |
| .expect("deserialize apps config"); |
| let app = apps |
| .apps |
| .get("calendar") |
| .expect("calendar app config exists"); |
|
|
| assert!(!app.enabled); |
| assert_eq!(app.destructive_enabled, Some(false)); |
| } |
|
|
| #[tokio::test] |
| async fn reload_user_config_layer_keeps_previous_config_for_malformed_shell_policy() { |
| let (session, _turn_context) = make_session_and_context().await; |
| let codex_home = session.codex_home().await; |
| std::fs::create_dir_all(&codex_home).expect("create codex home"); |
| let config_toml_path = codex_home.join(CONFIG_TOML_FILE); |
| std::fs::write(&config_toml_path, "[apps.calendar]\nenabled = false\n") |
| .expect("write valid user config"); |
| session.reload_user_config_layer().await; |
| let previous_config = session |
| .get_config() |
| .await |
| .config_layer_stack |
| .effective_user_config() |
| .expect("previous user config"); |
|
|
| std::fs::write( |
| &config_toml_path, |
| r#" |
| [apps.calendar] |
| enabled = true |
| |
| [shell_environment_policy] |
| exclude = ["SECRET_*", 17] |
| "#, |
| ) |
| .expect("write malformed user config"); |
|
|
| session.reload_user_config_layer().await; |
|
|
| let current_config = session |
| .get_config() |
| .await |
| .config_layer_stack |
| .effective_user_config() |
| .expect("current user config"); |
| assert_eq!(current_config, previous_config); |
| } |
|
|
| #[tokio::test] |
| async fn reload_user_config_layer_updates_base_and_selected_profile_layers() { |
| let (session, _turn_context) = make_session_and_context().await; |
| let codex_home = session.codex_home().await; |
| std::fs::create_dir_all(&codex_home).expect("create codex home"); |
| let base_config_path = codex_home.join(CONFIG_TOML_FILE); |
| let profile_config_path = codex_home.join("work.config.toml"); |
| std::fs::write( |
| &base_config_path, |
| "model = \"base\"\napproval_policy = \"on-request\"\n", |
| ) |
| .expect("write base user config"); |
| std::fs::write(&profile_config_path, "model = \"profile-old\"\n") |
| .expect("write profile user config"); |
| let config = ConfigBuilder::without_managed_config_for_tests() |
| .codex_home(codex_home.to_path_buf()) |
| .loader_overrides(LoaderOverrides { |
| user_config_path: Some(profile_config_path.abs()), |
| user_config_profile: Some("work".parse().expect("profile-v2 name")), |
| ..LoaderOverrides::without_managed_config_for_tests() |
| }) |
| .build() |
| .await |
| .expect("load profile config"); |
| { |
| let mut state = session.state.lock().await; |
| state.session_configuration.original_config_do_not_use = Arc::new(config); |
| } |
| std::fs::write( |
| &base_config_path, |
| "model = \"base\"\napproval_policy = \"never\"\n", |
| ) |
| .expect("update base user config"); |
| std::fs::write(&profile_config_path, "model = \"profile-new\"\n") |
| .expect("update profile user config"); |
|
|
| session.reload_user_config_layer().await; |
|
|
| let config = session.get_config().await; |
| assert_eq!( |
| config |
| .config_layer_stack |
| .get_user_config_file() |
| .map(codex_utils_absolute_path::AbsolutePathBuf::as_path), |
| Some(profile_config_path.as_path()) |
| ); |
| let effective_user_config = config |
| .config_layer_stack |
| .effective_user_config() |
| .expect("merged user config"); |
| assert_eq!( |
| effective_user_config |
| .get("model") |
| .and_then(toml::Value::as_str), |
| Some("profile-new") |
| ); |
| assert_eq!( |
| effective_user_config |
| .get("approval_policy") |
| .and_then(toml::Value::as_str), |
| Some("never") |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn reload_user_config_layer_refreshes_hooks() -> anyhow::Result<()> { |
| let session = make_session_with_config(|config| { |
| config |
| .features |
| .enable(Feature::CodexHooks) |
| .expect("enable Codex hooks"); |
| }) |
| .await?; |
| let codex_home = session.codex_home().await; |
| std::fs::create_dir_all(&codex_home)?; |
| let config_toml_path = codex_home.join(CONFIG_TOML_FILE); |
| let user_config: codex_config::TomlValue = serde_json::from_value(serde_json::json!({ |
| "hooks": { |
| "SessionStart": [{ |
| "hooks": [{ |
| "type": "command", |
| "command": "python3 /tmp/user.py", |
| }], |
| }], |
| }, |
| }))?; |
|
|
| let request = codex_hooks::SessionStartRequest { |
| session_id: session.thread_id, |
| cwd: session.get_config().await.cwd.clone(), |
| transcript_path: None, |
| model: "gpt-5.2".to_string(), |
| permission_mode: "default".to_string(), |
| target: codex_hooks::StartHookTarget::SessionStart { |
| source: codex_hooks::SessionStartSource::Startup, |
| }, |
| }; |
| assert!(session.hooks().preview_session_start(&request).is_empty()); |
|
|
| let config = session.get_config().await; |
| let hook_list = codex_hooks::list_hooks(codex_hooks::HooksConfig { |
| feature_enabled: true, |
| config_layer_stack: Some( |
| config |
| .config_layer_stack |
| .with_user_config(&config_toml_path, user_config.clone()) |
| .expect("hook user config should be valid"), |
| ), |
| ..codex_hooks::HooksConfig::default() |
| }); |
| assert_eq!(hook_list.hooks.len(), 1); |
| assert_eq!( |
| hook_list.hooks[0].trust_status, |
| codex_protocol::protocol::HookTrustStatus::Untrusted |
| ); |
|
|
| let trusted_user_config: codex_config::TomlValue = serde_json::from_value(serde_json::json!({ |
| "hooks": { |
| "SessionStart": [{ |
| "hooks": [{ |
| "type": "command", |
| "command": "python3 /tmp/user.py", |
| }], |
| }], |
| "state": { |
| hook_list.hooks[0].key.clone(): { |
| "trusted_hash": hook_list.hooks[0].current_hash.clone(), |
| }, |
| }, |
| }, |
| }))?; |
| std::fs::write(&config_toml_path, toml::to_string(&trusted_user_config)?)?; |
|
|
| session.reload_user_config_layer().await; |
|
|
| assert_eq!(session.hooks().preview_session_start(&request).len(), 1); |
| Ok(()) |
| } |
|
|
| #[tokio::test] |
| async fn refresh_runtime_config_refreshes_hooks() -> anyhow::Result<()> { |
| let (session, _turn_context) = make_session_and_context().await; |
| { |
| let mut state = session.state.lock().await; |
| let mut config = (*state.session_configuration.original_config_do_not_use).clone(); |
| config |
| .features |
| .enable(Feature::CodexHooks) |
| .expect("enable Codex hooks"); |
| state.session_configuration.original_config_do_not_use = Arc::new(config); |
| } |
| let codex_home = session.codex_home().await; |
| std::fs::create_dir_all(&codex_home)?; |
| let config_toml_path = codex_home.join(CONFIG_TOML_FILE); |
| #[derive(serde::Serialize)] |
| struct NormalizedHookIdentity { |
| event_name: &'static str, |
| #[serde(flatten)] |
| group: codex_config::MatcherGroup, |
| } |
| let trusted_hash = { |
| let identity = NormalizedHookIdentity { |
| event_name: "session_start", |
| group: codex_config::MatcherGroup { |
| matcher: None, |
| hooks: vec![codex_config::HookHandlerConfig::Command { |
| command: "python3 /tmp/user.py".to_string(), |
| command_windows: None, |
| timeout_sec: Some(600), |
| r#async: false, |
| status_message: None, |
| additional_context_limit: None, |
| }], |
| }, |
| }; |
| let identity = codex_config::TomlValue::try_from(identity)?; |
| codex_config::version_for_toml(&identity) |
| }; |
| let hook_key = format!("{}:session_start:0:0", config_toml_path.display()); |
| let trusted_user_config: codex_config::TomlValue = serde_json::from_value(serde_json::json!({ |
| "hooks": { |
| "SessionStart": [{ |
| "hooks": [{ |
| "type": "command", |
| "command": "python3 /tmp/user.py", |
| }], |
| }], |
| "state": { |
| hook_key: { |
| "trusted_hash": trusted_hash, |
| }, |
| }, |
| }, |
| }))?; |
| std::fs::write(&config_toml_path, toml::to_string(&trusted_user_config)?)?; |
|
|
| let request = codex_hooks::SessionStartRequest { |
| session_id: session.thread_id, |
| cwd: session.get_config().await.cwd.clone(), |
| transcript_path: None, |
| model: "gpt-5.2".to_string(), |
| permission_mode: "default".to_string(), |
| target: codex_hooks::StartHookTarget::SessionStart { |
| source: codex_hooks::SessionStartSource::Startup, |
| }, |
| }; |
| assert!(session.hooks().preview_session_start(&request).is_empty()); |
|
|
| let next_config = load_latest_config_for_session(&session).await; |
| session.refresh_runtime_config(next_config).await; |
|
|
| assert_eq!(session.hooks().preview_session_start(&request).len(), 1); |
| Ok(()) |
| } |
|
|
| #[tokio::test] |
| async fn reload_user_config_layer_updates_effective_tool_suggest_config() { |
| let (session, _turn_context) = make_session_and_context().await; |
| let codex_home = session.codex_home().await; |
| std::fs::create_dir_all(&codex_home).expect("create codex home"); |
| let config_toml_path = codex_home.join(CONFIG_TOML_FILE); |
| std::fs::write( |
| &config_toml_path, |
| r#"[tool_suggest] |
| disabled_tools = [ |
| { type = "connector", id = " calendar " }, |
| { type = "plugin", id = "slack@openai-curated" }, |
| ] |
| "#, |
| ) |
| .expect("write user config"); |
|
|
| session.reload_user_config_layer().await; |
|
|
| let config = session.get_config().await; |
| assert_eq!( |
| config.tool_suggest.disabled_tools, |
| vec![ |
| ToolSuggestDisabledTool::connector("calendar"), |
| ToolSuggestDisabledTool::plugin("slack@openai-curated"), |
| ] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn refresh_runtime_config_updates_runtime_refreshable_fields_and_keeps_session_static_settings() |
| { |
| let (session, _turn_context) = make_session_and_context().await; |
| let codex_home = session.codex_home().await; |
| std::fs::create_dir_all(&codex_home).expect("create codex home"); |
| std::fs::write( |
| codex_home.join(CONFIG_TOML_FILE), |
| r#"[apps.calendar] |
| enabled = false |
| destructive_enabled = false |
| |
| [tool_suggest] |
| disabled_tools = [ |
| { type = "connector", id = " calendar " }, |
| { type = "plugin", id = "slack@openai-curated" }, |
| ] |
| "#, |
| ) |
| .expect("write user config"); |
|
|
| let original = session.get_config().await; |
| let mut next_config = load_latest_config_for_session(&session).await; |
| next_config.model = Some("gpt-5.4".to_string()); |
| next_config.notify = Some(vec!["echo".to_string()]); |
|
|
| session.refresh_runtime_config(next_config).await; |
|
|
| let config = session.get_config().await; |
| let apps_toml = config |
| .config_layer_stack |
| .effective_config() |
| .as_table() |
| .and_then(|table| table.get("apps")) |
| .cloned() |
| .expect("apps table"); |
| let apps = codex_config::types::AppsConfigToml::deserialize(apps_toml) |
| .expect("deserialize apps config"); |
| let app = apps |
| .apps |
| .get("calendar") |
| .expect("calendar app config exists"); |
|
|
| assert!(!app.enabled); |
| assert_eq!(app.destructive_enabled, Some(false)); |
| assert_eq!(config.model, original.model); |
| assert_eq!(config.notify, original.notify); |
| assert_eq!( |
| config.tool_suggest.disabled_tools, |
| vec![ |
| ToolSuggestDisabledTool::connector("calendar"), |
| ToolSuggestDisabledTool::plugin("slack@openai-curated"), |
| ] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn refresh_mcp_config_replaces_managed_server_and_plugin_requirements() { |
| let (session, _turn_context) = make_session_and_context().await; |
| let server = serde_json::from_value::<McpServerConfig>(json!({ |
| "url": "https://example.com/mcp", |
| "enabled": true |
| })) |
| .expect("valid test MCP server"); |
| let requirement = serde_json::from_value::<codex_config::McpServerRequirement>(json!({ |
| "identity": { "url": "https://example.com/mcp" } |
| })) |
| .expect("valid managed MCP requirement"); |
| let plugin_requirements = std::collections::BTreeMap::from([( |
| "example-plugin".to_string(), |
| codex_config::PluginRequirementsToml { |
| mcp_servers: Some(std::collections::BTreeMap::from([( |
| "beta".to_string(), |
| requirement, |
| )])), |
| }, |
| )]); |
|
|
| let mut next_config = session.get_config().await.as_ref().clone(); |
| next_config.mcp_servers = codex_config::Constrained::normalized( |
| HashMap::from([("beta".to_string(), server.clone())]), |
| |mut servers: HashMap<String, McpServerConfig>| { |
| servers.retain(|name, _| name == "beta"); |
| servers |
| }, |
| ) |
| .expect("valid refreshed MCP constraints"); |
| let mut requirements = next_config.config_layer_stack.requirements().clone(); |
| requirements.plugins = Some(Sourced::new( |
| plugin_requirements.clone(), |
| RequirementSource::LegacyManagedConfigTomlFromMdm, |
| )); |
| let mut requirements_toml = next_config.config_layer_stack.requirements_toml().clone(); |
| requirements_toml.plugins = Some(plugin_requirements.clone()); |
| let layers = next_config |
| .config_layer_stack |
| .all_layers_low_to_high() |
| .cloned() |
| .collect(); |
| next_config.config_layer_stack = ConfigLayerStack::new(layers, requirements, requirements_toml) |
| .expect("managed MCP and plugin requirements"); |
|
|
| session.refresh_mcp_config(next_config).await; |
|
|
| let config = session.get_config().await; |
| let mut managed_servers = config.mcp_servers.clone(); |
| managed_servers |
| .set(HashMap::from([ |
| ("alpha".to_string(), server.clone()), |
| ("beta".to_string(), server.clone()), |
| ])) |
| .expect("apply refreshed managed MCP constraints"); |
| assert_eq!( |
| managed_servers.get(), |
| &HashMap::from([("beta".to_string(), server.clone())]) |
| ); |
| assert_eq!( |
| config |
| .config_layer_stack |
| .requirements() |
| .plugins |
| .as_ref() |
| .map(|requirements| &requirements.value), |
| Some(&plugin_requirements) |
| ); |
|
|
| let mut plugin_servers = HashMap::from([ |
| ("alpha".to_string(), server.clone()), |
| ("beta".to_string(), server), |
| ]); |
| config.apply_plugin_mcp_server_requirements("example-plugin", &mut plugin_servers); |
| assert!(!plugin_servers["alpha"].enabled); |
| assert!(plugin_servers["beta"].enabled); |
| } |
|
|
| #[test] |
| fn collect_explicit_app_ids_from_skill_items_includes_linked_mentions() { |
| let connectors = vec![make_connector("calendar", "Calendar")]; |
| let skill_items = vec\n</skill>", |
| )]; |
|
|
| let connector_ids = |
| collect_explicit_app_ids_from_skill_items(&skill_items, &connectors, &HashMap::new()); |
|
|
| assert_eq!(connector_ids, HashSet::from(["calendar".to_string()])); |
| } |
|
|
| #[test] |
| fn collect_explicit_app_ids_from_skill_items_resolves_unambiguous_plain_mentions() { |
| let connectors = vec![make_connector("calendar", "Calendar")]; |
| let skill_items = vec![skill_message( |
| "<skill>\n<name>demo</name>\n<path>/tmp/skills/demo/SKILL.md</path>\nuse $calendar\n</skill>", |
| )]; |
|
|
| let connector_ids = |
| collect_explicit_app_ids_from_skill_items(&skill_items, &connectors, &HashMap::new()); |
|
|
| assert_eq!(connector_ids, HashSet::from(["calendar".to_string()])); |
| } |
|
|
| #[test] |
| fn collect_explicit_app_ids_from_skill_items_skips_plain_mentions_with_skill_conflicts() { |
| let connectors = vec![make_connector("calendar", "Calendar")]; |
| let skill_items = vec![skill_message( |
| "<skill>\n<name>demo</name>\n<path>/tmp/skills/demo/SKILL.md</path>\nuse $calendar\n</skill>", |
| )]; |
| let skill_name_counts_lower = HashMap::from([("calendar".to_string(), 1)]); |
|
|
| let connector_ids = collect_explicit_app_ids_from_skill_items( |
| &skill_items, |
| &connectors, |
| &skill_name_counts_lower, |
| ); |
|
|
| assert_eq!(connector_ids, HashSet::<String>::new()); |
| } |
|
|
| #[tokio::test] |
| async fn reconstruct_history_matches_live_compactions() { |
| let (session, turn_context) = make_session_and_context().await; |
| let (rollout_items, expected) = sample_rollout(&session, &turn_context).await; |
|
|
| let reconstruction_turn = session.new_default_turn().await; |
| let reconstructed = session |
| .reconstruct_history_from_rollout(reconstruction_turn.as_ref(), &rollout_items) |
| .await; |
|
|
| assert_eq!(expected, raw_envelopes(&reconstructed.history)); |
| assert_eq!(2, reconstructed.window_number); |
| assert_eq!( |
| reconstructed |
| .window_id |
| .map(|window_id| window_id.get_version_num()), |
| Some(7) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn reconstruct_history_uses_replacement_history_verbatim() { |
| let (session, turn_context) = make_session_and_context().await; |
| let summary_item = ResponseItem::Message { |
| id: None, |
| role: "user".to_string(), |
| content: vec![ContentItem::InputText { |
| text: "summary".to_string(), |
| }], |
| phase: None, |
| internal_chat_message_metadata_passthrough: Some(InternalChatMessageMetadataPassthrough { |
| turn_id: Some("compact-turn".to_string()), |
| ..Default::default() |
| }), |
| }; |
| let replacement_history = vec![ |
| ResponseItemEnvelope { |
| item: summary_item.clone(), |
| metadata: Some(CodexHarnessMetadata::default()), |
| }, |
| ResponseItemEnvelope::new(ResponseItem::Message { |
| id: None, |
| role: "developer".to_string(), |
| content: vec![ContentItem::InputText { |
| text: "stale developer instructions".to_string(), |
| }], |
| phase: None, |
| internal_chat_message_metadata_passthrough: None, |
| }), |
| ]; |
| let first_window_id = Uuid::now_v7(); |
| let previous_window_id = Uuid::now_v7(); |
| let window_id = Uuid::now_v7(); |
| let rollout_items = vec![RolloutItem::Compacted(CompactedItem { |
| message: String::new(), |
| replacement_history: Some(replacement_history.clone()), |
| retained_context: None, |
| guardian_history: None, |
| mcp_resource_origins: None, |
| window_number: Some(42), |
| first_window_id: Some(first_window_id.to_string()), |
| previous_window_id: Some(previous_window_id.to_string()), |
| window_id: Some(window_id.to_string()), |
| compaction_response_id: None, |
| latest_token_usage_record: None, |
| })]; |
|
|
| let reconstructed = session |
| .reconstruct_history_from_rollout(&turn_context, &rollout_items) |
| .await; |
|
|
| assert_eq!(reconstructed.history, replacement_history); |
| assert_eq!(42, reconstructed.window_number); |
| assert_eq!(Some(first_window_id), reconstructed.first_window_id); |
| assert_eq!(Some(previous_window_id), reconstructed.previous_window_id); |
| assert_eq!(Some(window_id), reconstructed.window_id); |
| } |
|
|
| #[tokio::test] |
| async fn record_initial_history_reconstructs_resumed_transcript() { |
| let (session, turn_context) = make_session_and_context().await; |
| let (rollout_items, expected) = sample_rollout(&session, &turn_context).await; |
|
|
| session |
| .record_initial_history(InitialHistory::Resumed(ResumedHistory { |
| conversation_id: ThreadId::default(), |
| history: Arc::new(rollout_items), |
| rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), |
| })) |
| .await; |
|
|
| let history = session.state.lock().await.clone_history(); |
| assert_eq!(expected, raw_history_items(&history)); |
| } |
|
|
| #[tokio::test] |
| async fn record_conversation_items_stamps_missing_turn_id_and_preserves_existing_turn_id() { |
| let (session, turn_context) = make_session_and_context().await; |
| let mut fresh_item = user_message("fresh"); |
| fresh_item.set_id(Some(ResponseItemId::with_suffix("msg", "fresh"))); |
| let mut existing_item = assistant_message("existing"); |
| existing_item.set_id(Some(ResponseItemId::with_suffix("msg", "existing"))); |
| existing_item.set_turn_id_if_missing("older-turn"); |
|
|
| session |
| .record_conversation_items( |
| &turn_context, |
| turn_context.model_info(), |
| &[fresh_item.clone(), existing_item.clone()], |
| ) |
| .await; |
|
|
| let history = session.clone_history().await; |
| let recorded_items = raw_history_items(&history); |
| let fresh_create_time = recorded_items[0] |
| .executed_tool_call_metadata() |
| .and_then(|metadata| metadata.create_time.clone()) |
| .expect("harness-authored items should receive creation timestamps"); |
| assert!( |
| fresh_create_time |
| .as_f64() |
| .is_some_and(|seconds| seconds > 0.0) |
| ); |
|
|
| let mut expected_fresh_item = fresh_item; |
| expected_fresh_item.set_turn_id_if_missing(&turn_context.sub_id); |
| expected_fresh_item.set_create_time_if_missing(fresh_create_time); |
| let expected_items = vec![expected_fresh_item, existing_item]; |
| assert_eq!(recorded_items, expected_items); |
| } |
|
|
| #[tokio::test] |
| async fn record_response_item_and_emit_turn_item_emits_hook_prompt_lifecycle() { |
| let (session, turn_context, rx) = make_session_and_context_with_rx().await; |
| let response_item = build_hook_prompt_message(&[HookPromptFragment::from_single_hook( |
| "Retry with tests.", |
| "hook-run-1", |
| )]) |
| .expect("hook prompt message"); |
| let response_item_id = response_item.id().expect("hook prompt id").to_string(); |
|
|
| session |
| .record_response_item_and_emit_turn_item( |
| &turn_context, |
| turn_context.model_info(), |
| response_item, |
| ) |
| .await; |
|
|
| let raw_response = rx.recv().await.expect("raw response item event"); |
| assert!(matches!(raw_response.msg, EventMsg::RawResponseItem(_))); |
|
|
| let started = rx.recv().await.expect("started hook prompt event"); |
| assert!(matches!( |
| started.msg, |
| EventMsg::ItemStarted(ItemStartedEvent { |
| item: TurnItem::HookPrompt(item), |
| .. |
| }) if item.id == response_item_id |
| )); |
|
|
| let completed = rx.recv().await.expect("completed hook prompt event"); |
| assert!(matches!( |
| completed.msg, |
| EventMsg::ItemCompleted(ItemCompletedEvent { |
| item: TurnItem::HookPrompt(item), |
| .. |
| }) if item.id == response_item_id |
| )); |
|
|
| assert!(rx.try_recv().is_err(), "no extra events expected"); |
| } |
|
|
| #[tokio::test] |
| async fn item_completion_without_a_start_uses_completion_timestamp() { |
| let (session, turn_context, rx) = make_session_and_context_with_rx().await; |
| let item = TurnItem::UserMessage(UserMessageItem { |
| id: "missing-start".to_string(), |
| client_id: None, |
| content: Vec::new(), |
| }); |
|
|
| session.emit_turn_item_completed(&turn_context, item).await; |
|
|
| let completed = rx.recv().await.expect("completed item event"); |
| let EventMsg::ItemCompleted(event) = completed.msg else { |
| panic!("expected completed item event"); |
| }; |
| assert_eq!(event.started_at_ms, Some(event.completed_at_ms)); |
| } |
|
|
| #[tokio::test] |
| async fn subagent_activity_emits_matching_start_and_completion() { |
| let (session, turn_context, rx) = make_session_and_context_with_rx().await; |
| let item = codex_protocol::items::SubAgentActivityItem { |
| id: "activity-1".to_string(), |
| kind: codex_protocol::protocol::SubAgentActivityKind::Started, |
| agent_thread_id: ThreadId::new(), |
| agent_path: AgentPath::root(), |
| }; |
|
|
| crate::tools::handlers::multi_agents_v2::emit_sub_agent_activity(&session, &turn_context, item) |
| .await; |
|
|
| let EventMsg::ItemStarted(started) = rx.recv().await.expect("started item event").msg else { |
| panic!("expected started item event"); |
| }; |
| let EventMsg::ItemCompleted(completed) = rx.recv().await.expect("completed item event").msg |
| else { |
| panic!("expected completed item event"); |
| }; |
| assert_eq!(completed.started_at_ms, Some(started.started_at_ms)); |
| } |
|
|
| #[tokio::test] |
| async fn record_inter_agent_communication_sets_turn_id_in_rollout_and_resume() { |
| let (mut session, turn_context) = make_session_and_context().await; |
| let rollout_path = attach_thread_persistence(&mut session).await; |
| let communication = InterAgentCommunication::new( |
| AgentPath::root().join("worker").expect("worker path"), |
| AgentPath::root(), |
| Vec::new(), |
| "child done".to_string(), |
| false, |
| ); |
| let mut expected_item = communication.to_model_input_item(); |
| expected_item.set_turn_id_if_missing(&turn_context.sub_id); |
|
|
| session |
| .record_inter_agent_communication(&turn_context, turn_context.model_info(), communication) |
| .await; |
|
|
| let recorded_history = session.clone_history().await; |
| let recorded_items = raw_history_items(&recorded_history); |
| let create_time = recorded_items[0] |
| .executed_tool_call_metadata() |
| .and_then(|metadata| metadata.create_time.clone()) |
| .expect("locally authored agent message should receive a creation timestamp"); |
| assert!(create_time.as_f64().is_some_and(|seconds| seconds > 0.0)); |
| expected_item.set_create_time_if_missing(create_time); |
|
|
| assert_eq!( |
| strip_response_item_ids(&recorded_items), |
| strip_response_item_ids(std::slice::from_ref(&expected_item)) |
| ); |
|
|
| session.flush_rollout().await.expect("rollout should flush"); |
| let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path) |
| .await |
| .expect("read rollout history") |
| else { |
| panic!("expected resumed rollout history"); |
| }; |
| let persisted_items = resumed |
| .history |
| .iter() |
| .filter(|item| { |
| matches!( |
| item, |
| RolloutItem::ResponseItem(_) |
| | RolloutItem::InterAgentCommunication(_) |
| | RolloutItem::InterAgentCommunicationMetadata { .. } |
| ) |
| }) |
| .cloned() |
| .collect::<Vec<_>>(); |
| let expected_persisted_items = vec![ |
| RolloutItem::InterAgentCommunicationMetadata { |
| trigger_turn: false, |
| }, |
| RolloutItem::ResponseItem(expected_item.clone().into()), |
| ]; |
| assert_eq!( |
| strip_response_item_ids_from_json(serde_json::to_value(persisted_items).unwrap()), |
| strip_response_item_ids_from_json(serde_json::to_value(expected_persisted_items).unwrap()) |
| ); |
|
|
| let (resumed_session, _resumed_turn_context) = make_session_and_context().await; |
| resumed_session |
| .record_initial_history(InitialHistory::Resumed(resumed)) |
| .await; |
| assert_eq!( |
| strip_response_item_ids(&raw_history_items(&resumed_session.clone_history().await)), |
| strip_response_item_ids(std::slice::from_ref(&expected_item)) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn record_inter_agent_communication_preserves_item_id_in_rollout_and_resume() { |
| let (mut session, turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( |
| CodexAuth::from_api_key("Test API Key"), |
| Vec::new(), |
| |_| {}, |
| ) |
| .await; |
| let rollout_path = |
| attach_thread_persistence(Arc::get_mut(&mut session).expect("unique session")).await; |
| let communication = InterAgentCommunication::new( |
| AgentPath::root().join("worker").expect("worker path"), |
| AgentPath::root(), |
| Vec::new(), |
| "child done".to_string(), |
| false, |
| ); |
|
|
| session |
| .record_inter_agent_communication(&turn_context, turn_context.model_info(), communication) |
| .await; |
|
|
| let live_history = session.clone_history().await; |
| let live_items = raw_history_items(&live_history); |
| let [live_item] = live_items.as_slice() else { |
| panic!("expected exactly one live history item"); |
| }; |
| let live_item_id = live_item |
| .id() |
| .expect("live agent message should have an item id") |
| .to_string(); |
| assert!(live_item_id.starts_with("amsg_")); |
|
|
| session.flush_rollout().await.expect("rollout should flush"); |
| let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path) |
| .await |
| .expect("read rollout history") |
| else { |
| panic!("expected resumed rollout history"); |
| }; |
| let persisted_item_id = resumed.history.iter().find_map(|item| match item { |
| RolloutItem::ResponseItem(item) |
| if matches!(&item.item, ResponseItem::AgentMessage { .. }) => |
| { |
| item.id() |
| } |
| _ => None, |
| }); |
| assert_eq!( |
| persisted_item_id.map(ResponseItemId::as_str), |
| Some(live_item_id.as_str()) |
| ); |
|
|
| let (resumed_session, _resumed_turn_context, _rx) = |
| make_session_and_context_with_auth_and_config_and_rx( |
| CodexAuth::from_api_key("Test API Key"), |
| Vec::new(), |
| |_| {}, |
| ) |
| .await; |
| resumed_session |
| .record_initial_history(InitialHistory::Resumed(resumed)) |
| .await; |
| let resumed_history = resumed_session.clone_history().await; |
| let resumed_items = raw_history_items(&resumed_history); |
| let [resumed_item] = resumed_items.as_slice() else { |
| panic!("expected exactly one resumed history item"); |
| }; |
| assert_eq!( |
| resumed_item.id().map(ResponseItemId::as_str), |
| Some(live_item_id.as_str()) |
| ); |
| } |
|
|
| #[test_case::test_case(false, None, false, 5; "plain items")] |
| #[test_case::test_case(true, None, false, 5; "annotated items")] |
| #[test_case::test_case(true, Some(100), false, 100; "existing tool budget")] |
| #[test_case::test_case(false, None, true, 31; "byte policy includes allowance before conversion")] |
| #[tokio::test] |
| async fn annotated_history_uses_explicit_model_without_a_step( |
| retain_metadata: bool, |
| saved_budget: Option<usize>, |
| byte_policy: bool, |
| expected_budget: usize, |
| ) { |
| let (session, turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( |
| CodexAuth::from_api_key("Test API Key"), |
| Vec::new(), |
| |config| { |
| config |
| .features |
| .enable(Feature::RetainClientDeveloperMessages) |
| .unwrap(); |
| }, |
| ) |
| .await; |
| let mut model_info = turn_context.model_info().as_ref().clone(); |
| model_info.truncation_policy = if byte_policy { |
| codex_protocol::openai_models::TruncationPolicyConfig::bytes( 101) |
| } else { |
| codex_protocol::openai_models::TruncationPolicyConfig::tokens( 4) |
| }; |
| let text = "diagnostic line\n".repeat(50); |
| let mut item = ResponseItem::FunctionCallOutput { |
| id: Some(ResponseItemId::with_suffix("fco", "existing")), |
| call_id: Some("call-1".to_string()), |
| name: None, |
| namespace: None, |
| output: FunctionCallOutputPayload { |
| body: FunctionCallOutputBody::Text(text.clone()), |
| success: Some(true), |
| }, |
| internal_chat_message_metadata_passthrough: None, |
| }; |
| item.set_turn_id_if_missing("existing-turn"); |
| item.set_create_time_if_missing(123.into()); |
| let expected = ResponseItemEnvelope { |
| item, |
| metadata: retain_metadata.then_some(CodexHarnessMetadata { |
| client_authored: true, |
| history_truncation_token_limit: saved_budget, |
| ..Default::default() |
| }), |
| }; |
| let mut expected = vec![ |
| expected, |
| ResponseItemEnvelope { |
| item: ResponseItem::CustomToolCallOutput { |
| id: Some(ResponseItemId::with_suffix("ctco", "existing")), |
| call_id: "call-custom".to_string(), |
| name: None, |
| output: FunctionCallOutputPayload::from_text(text.clone()), |
| internal_chat_message_metadata_passthrough: None, |
| }, |
| metadata: retain_metadata.then_some(CodexHarnessMetadata { |
| client_authored: true, |
| history_truncation_token_limit: saved_budget, |
| ..Default::default() |
| }), |
| }, |
| ]; |
| expected[1].item.set_turn_id_if_missing("existing-turn"); |
| expected[1].item.set_create_time_if_missing(123.into()); |
| session |
| .record_annotated_conversation_items(&turn_context, &model_info, expected.clone()) |
| .await; |
| for envelope in &mut expected { |
| envelope |
| .metadata |
| .get_or_insert_default() |
| .history_truncation_token_limit = Some(expected_budget); |
| let (ResponseItem::FunctionCallOutput { output, .. } |
| | ResponseItem::CustomToolCallOutput { output, .. }) = &mut envelope.item |
| else { |
| unreachable!("fixture is a tool output"); |
| }; |
| output.body = FunctionCallOutputBody::Text(codex_utils_output_truncation::truncate_text( |
| &text, |
| codex_utils_output_truncation::TruncationPolicy::Tokens(expected_budget), |
| )); |
| } |
| assert_eq!(session.clone_history().await.annotated_items(), &expected); |
| } |
|
|
| #[tokio::test] |
| async fn prepares_image_failures_before_history_insertion() { |
| let (session, turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( |
| CodexAuth::from_api_key("Test API Key"), |
| Vec::new(), |
| |_| {}, |
| ) |
| .await; |
| let item = ResponseItem::FunctionCallOutput { |
| id: None, |
| call_id: Some("call-1".to_string()), |
| name: None, |
| namespace: None, |
| output: FunctionCallOutputPayload { |
| body: FunctionCallOutputBody::ContentItems(vec![ |
| FunctionCallOutputContentItem::InputText { |
| text: "before".to_string(), |
| }, |
| FunctionCallOutputContentItem::InputImage { |
| image: ImageReference::Inline { |
| image_url: "data:image/png;base64,%%%".to_string(), |
| }, |
| detail: Some(ImageDetail::High), |
| }, |
| FunctionCallOutputContentItem::InputImage { |
| image: ImageReference::Inline { |
| image_url: "https://example.com/image.png".to_string(), |
| }, |
| detail: Some(ImageDetail::High), |
| }, |
| ]), |
| success: Some(true), |
| }, |
| internal_chat_message_metadata_passthrough: None, |
| }; |
|
|
| session |
| .record_conversation_items( |
| turn_context.as_ref(), |
| turn_context.model_info(), |
| std::slice::from_ref(&item), |
| ) |
| .await; |
|
|
| let history = session.state.lock().await.clone_history(); |
| let id = history |
| .raw_items() |
| .next() |
| .expect("history should contain one item") |
| .id() |
| .expect("history item should have an ID"); |
| let uuid = id |
| .strip_prefix("fco_") |
| .expect("function call output ID should have the Responses API prefix"); |
| let parsed_id = Uuid::parse_str(uuid).expect("history item should have a UUID ID"); |
| assert_eq!(parsed_id.get_version(), Some(uuid::Version::SortRand)); |
| let expected = vec![ResponseItem::FunctionCallOutput { |
| id: Some(id.clone()), |
| call_id: Some("call-1".to_string()), |
| name: None, |
| namespace: None, |
| output: FunctionCallOutputPayload { |
| body: FunctionCallOutputBody::ContentItems(vec![ |
| FunctionCallOutputContentItem::InputText { |
| text: "before".to_string(), |
| }, |
| FunctionCallOutputContentItem::InputText { |
| text: "image content omitted because it could not be processed".to_string(), |
| }, |
| FunctionCallOutputContentItem::InputText { |
| text: "image content omitted because remote image URLs are not supported" |
| .to_string(), |
| }, |
| ]), |
| success: Some(true), |
| }, |
| internal_chat_message_metadata_passthrough: None, |
| }]; |
| assert_eq!( |
| strip_metadata_from_items(&raw_history_items(&history)), |
| expected |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn prepares_resumed_history_before_installing_it() { |
| let (session, _turn_context) = make_session_and_context().await; |
| let resumed_item = ResponseItem::Message { |
| id: None, |
| role: "user".to_string(), |
| content: vec![ |
| ContentItem::InputImage { |
| image: ImageReference::Inline { |
| image_url: "data:image/png;base64,%%%".to_string(), |
| }, |
| detail: Some(ImageDetail::High), |
| }, |
| ContentItem::InputImage { |
| image: ImageReference::Inline { |
| image_url: "https://example.com/image.png".to_string(), |
| }, |
| detail: Some(ImageDetail::High), |
| }, |
| ContentItem::InputText { |
| text: "keep me".to_string(), |
| }, |
| ], |
| phase: None, |
| internal_chat_message_metadata_passthrough: None, |
| }; |
|
|
| session |
| .record_initial_history(InitialHistory::Resumed(ResumedHistory { |
| conversation_id: ThreadId::default(), |
| history: Arc::new(vec![RolloutItem::ResponseItem(ResponseItemEnvelope { |
| item: resumed_item, |
| metadata: Some(CodexHarnessMetadata::default()), |
| })]), |
| rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), |
| })) |
| .await; |
|
|
| let history = session.state.lock().await.clone_history(); |
| assert_eq!( |
| raw_history_items(&history), |
| vec![ResponseItem::Message { |
| id: None, |
| role: "user".to_string(), |
| content: vec![ |
| ContentItem::InputText { |
| text: "image content omitted because it could not be processed".to_string(), |
| }, |
| ContentItem::InputText { |
| text: "image content omitted because remote image URLs are not supported" |
| .to_string(), |
| }, |
| ContentItem::InputText { |
| text: "keep me".to_string(), |
| }, |
| ], |
| phase: None, |
| internal_chat_message_metadata_passthrough: Some( |
| InternalChatMessageMetadataPassthrough { |
| content_item_kinds: Some(vec![ |
| ContentItemKind("images.preparation_error".to_string()), |
| ContentItemKind("images.preparation_error".to_string()), |
| ContentItemKind("unknown".to_string()), |
| ]), |
| ..Default::default() |
| }, |
| ), |
| }] |
| ); |
| assert_eq!( |
| history.annotated_items()[0].metadata, |
| Some(CodexHarnessMetadata::default()) |
| ); |
| } |
|
|
| #[test] |
| fn resolve_multi_agent_version_handles_unset_and_legacy_history() { |
| let thread_id = ThreadId::default(); |
|
|
| assert_eq!( |
| resolve_multi_agent_version( |
| &InitialHistory::New, |
| None |
| ), |
| None |
| ); |
| assert_eq!( |
| resolve_multi_agent_version( |
| &InitialHistory::Resumed(ResumedHistory { |
| conversation_id: thread_id, |
| history: Arc::new(Vec::new()), |
| rollout_path: None, |
| }), |
| None, |
| ), |
| Some(MultiAgentVersion::V1) |
| ); |
| assert_eq!( |
| resolve_multi_agent_version( |
| &InitialHistory::Resumed(ResumedHistory { |
| conversation_id: thread_id, |
| history: Arc::new(Vec::new()), |
| rollout_path: None, |
| }), |
| Some(MultiAgentVersion::V2), |
| ), |
| Some(MultiAgentVersion::V2) |
| ); |
| assert_eq!( |
| resolve_multi_agent_version( |
| &InitialHistory::Resumed(ResumedHistory { |
| conversation_id: thread_id, |
| history: Arc::new(vec![session_meta_item( |
| thread_id, |
| Some(MultiAgentVersion::Disabled) |
| )]), |
| rollout_path: None, |
| }), |
| Some(MultiAgentVersion::V2), |
| ), |
| Some(MultiAgentVersion::Disabled) |
| ); |
| assert_eq!( |
| resolve_multi_agent_version( |
| &InitialHistory::Forked(vec![session_meta_item( |
| thread_id, |
| Some(MultiAgentVersion::V2) |
| )]), |
| Some(MultiAgentVersion::Disabled), |
| ), |
| Some(MultiAgentVersion::Disabled) |
| ); |
| assert_eq!( |
| resolve_multi_agent_version( |
| &InitialHistory::Forked(Vec::new()), |
| None |
| ), |
| Some(MultiAgentVersion::V1) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn record_initial_history_new_defers_initial_context_until_first_turn() { |
| let (session, _turn_context) = make_session_and_context().await; |
|
|
| session.record_initial_history(InitialHistory::New).await; |
|
|
| let history = session.clone_history().await; |
| assert_eq!(raw_history_items(&history), Vec::<ResponseItem>::new()); |
| assert!(session.reference_context_item().await.is_none()); |
| assert_eq!(session.previous_turn_settings().await, None); |
| } |
|
|
| fn session_meta_item( |
| thread_id: ThreadId, |
| multi_agent_version: Option<MultiAgentVersion>, |
| ) -> RolloutItem { |
| RolloutItem::SessionMeta(SessionMetaLine { |
| meta: SessionMeta { |
| session_id: thread_id.into(), |
| id: thread_id, |
| multi_agent_version, |
| ..SessionMeta::default() |
| }, |
| git: None, |
| }) |
| } |
|
|
| #[tokio::test] |
| async fn resumed_history_injects_initial_context_on_first_context_update_only() { |
| let (session, turn_context) = make_session_and_context().await; |
| let turn_context = Arc::new(turn_context); |
| let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await; |
|
|
| session |
| .record_initial_history(InitialHistory::Resumed(ResumedHistory { |
| conversation_id: ThreadId::default(), |
| history: Arc::new(rollout_items), |
| rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), |
| })) |
| .await; |
|
|
| let history_before_seed = session.state.lock().await.clone_history(); |
| assert_eq!(expected, raw_history_items(&history_before_seed)); |
|
|
| let step_context = StepContext::for_test(Arc::clone(&turn_context)); |
| session |
| .record_context_updates_and_set_reference_context_item(&step_context) |
| .await |
| .expect("world state should build"); |
| let initial_context = build_initial_context(&session, &turn_context).await; |
| expected.extend(initial_context); |
| let history_after_seed = session.clone_history().await; |
| assert_eq!( |
| strip_response_item_ids(&strip_metadata_from_items(&expected)), |
| strip_response_item_ids(&strip_metadata_from_items(&raw_history_items( |
| &history_after_seed |
| ))) |
| ); |
|
|
| session |
| .record_context_updates_and_set_reference_context_item(&step_context) |
| .await |
| .expect("world state should build"); |
| let history_after_second_seed = session.clone_history().await; |
| assert_eq!( |
| raw_history_items(&history_after_seed), |
| raw_history_items(&history_after_second_seed) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn record_initial_history_seeds_token_info_from_rollout() { |
| let (session, turn_context) = make_session_and_context().await; |
| let (mut rollout_items, _expected) = sample_rollout(&session, &turn_context).await; |
|
|
| let info1 = TokenUsageInfo { |
| total_token_usage: TokenUsage { |
| input_tokens: 10, |
| cached_input_tokens: 0, |
| cache_write_input_tokens: 0, |
| output_tokens: 20, |
| reasoning_output_tokens: 0, |
| total_tokens: 30, |
| codex_rollout_budget_units: None, |
| }, |
| last_token_usage: TokenUsage { |
| input_tokens: 3, |
| cached_input_tokens: 0, |
| cache_write_input_tokens: 0, |
| output_tokens: 4, |
| reasoning_output_tokens: 0, |
| total_tokens: 7, |
| codex_rollout_budget_units: None, |
| }, |
| model_context_window: Some(1_000), |
| }; |
| let info2 = TokenUsageInfo { |
| total_token_usage: TokenUsage { |
| input_tokens: 100, |
| cached_input_tokens: 50, |
| cache_write_input_tokens: 0, |
| output_tokens: 200, |
| reasoning_output_tokens: 25, |
| total_tokens: 375, |
| codex_rollout_budget_units: None, |
| }, |
| last_token_usage: TokenUsage { |
| input_tokens: 10, |
| cached_input_tokens: 0, |
| cache_write_input_tokens: 0, |
| output_tokens: 20, |
| reasoning_output_tokens: 5, |
| total_tokens: 35, |
| codex_rollout_budget_units: None, |
| }, |
| model_context_window: Some(2_000), |
| }; |
|
|
| rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount( |
| TokenCountEvent { |
| info: Some(info1), |
| rate_limits: None, |
| }, |
| ))); |
| rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount( |
| TokenCountEvent { |
| info: None, |
| rate_limits: None, |
| }, |
| ))); |
| rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount( |
| TokenCountEvent { |
| info: Some(info2.clone()), |
| rate_limits: None, |
| }, |
| ))); |
| rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount( |
| TokenCountEvent { |
| info: None, |
| rate_limits: None, |
| }, |
| ))); |
|
|
| session |
| .record_initial_history(InitialHistory::Resumed(ResumedHistory { |
| conversation_id: ThreadId::default(), |
| history: Arc::new(rollout_items), |
| rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), |
| })) |
| .await; |
|
|
| let actual = session.state.lock().await.token_info(); |
| assert_eq!(actual, Some(info2)); |
| } |
|
|
| #[test] |
| fn latest_token_usage_record_stops_at_compaction_checkpoint() { |
| let thread_id = ThreadId::new(); |
| let checkpoint_record = TokenUsageRecord { |
| thread_id, |
| turn_id: "turn-1".to_string(), |
| session_id: SessionId::from(thread_id), |
| root_turn_id: "turn-1".to_string(), |
| response_id: "response-1".to_string(), |
| usage: TokenUsage::default(), |
| turn_token_usage: TokenUsage::default(), |
| thread_token_usage: TokenUsage::default(), |
| }; |
| let checkpoint = |latest_token_usage_record| { |
| RolloutItem::Compacted(CompactedItem { |
| message: String::new(), |
| replacement_history: None, |
| retained_context: None, |
| guardian_history: None, |
| mcp_resource_origins: None, |
| window_number: None, |
| first_window_id: None, |
| previous_window_id: None, |
| window_id: None, |
| compaction_response_id: None, |
| latest_token_usage_record, |
| }) |
| }; |
|
|
| assert_eq!( |
| Session::last_token_usage_record_from_rollout(&[ |
| RolloutItem::TokenUsageRecord(checkpoint_record.clone()), |
| checkpoint(Some(checkpoint_record.clone())), |
| ]), |
| Some(checkpoint_record.clone()) |
| ); |
| assert_eq!( |
| Session::last_token_usage_record_from_rollout(&[ |
| RolloutItem::TokenUsageRecord(checkpoint_record), |
| checkpoint(None), |
| ]), |
| None |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn recompute_token_usage_uses_session_base_instructions() { |
| let (session, turn_context) = make_session_and_context().await; |
|
|
| let override_instructions = "SESSION_OVERRIDE_INSTRUCTIONS_ONLY".repeat(120); |
| { |
| let mut state = session.state.lock().await; |
| state.session_configuration.base_instructions = override_instructions.clone(); |
| } |
|
|
| let item = user_message("hello"); |
| session |
| .record_conversation_items( |
| &turn_context, |
| turn_context.model_info(), |
| std::slice::from_ref(&item), |
| ) |
| .await; |
|
|
| let history = session.clone_history().await; |
| let session_base_instructions = BaseInstructions { |
| text: override_instructions, |
| provenance: None, |
| }; |
| let expected_tokens = history |
| .estimate_token_count_with_base_instructions(&session_base_instructions) |
| .expect("estimate with session base instructions"); |
| let model_estimated_tokens = history |
| .estimate_token_count(&turn_context) |
| .expect("estimate with model instructions"); |
| assert_ne!(expected_tokens, model_estimated_tokens); |
|
|
| session.recompute_token_usage(&turn_context).await; |
|
|
| let actual_tokens = session |
| .state |
| .lock() |
| .await |
| .token_info() |
| .expect("token info") |
| .last_token_usage |
| .total_tokens; |
| assert_eq!(actual_tokens, expected_tokens.max(0)); |
| } |
|
|
| #[tokio::test] |
| async fn recompute_token_usage_updates_model_context_window() { |
| let (session, mut turn_context) = make_session_and_context().await; |
|
|
| { |
| let mut state = session.state.lock().await; |
| state.set_token_info(Some(TokenUsageInfo { |
| total_token_usage: TokenUsage::default(), |
| last_token_usage: TokenUsage::default(), |
| model_context_window: Some(258_400), |
| })); |
| } |
|
|
| update_turn_settings_for_test(&mut turn_context, |settings| { |
| Arc::make_mut(&mut settings.model_info).context_window = Some(128_000); |
| Arc::make_mut(&mut settings.model_info).effective_context_window_percent = 100; |
| }); |
|
|
| session.recompute_token_usage(&turn_context).await; |
|
|
| let actual = session.state.lock().await.token_info().expect("token info"); |
| assert_eq!(actual.model_context_window, Some(128_000)); |
| } |
|
|
| #[tokio::test] |
| async fn record_token_usage_info_notifies_extension_contributors() { |
| struct SessionTokenUsageMarker; |
| struct ThreadTokenUsageMarker; |
|
|
| #[derive(Debug, PartialEq, Eq)] |
| struct RecordedTokenUsage { |
| session_level_id: String, |
| thread_level_id: String, |
| turn_level_id: String, |
| token_usage: TokenUsageInfo, |
| saw_session_store: bool, |
| saw_thread_store: bool, |
| } |
|
|
| struct TokenUsageRecorder { |
| records: Arc<std::sync::Mutex<Vec<RecordedTokenUsage>>>, |
| } |
|
|
| impl codex_extension_api::TokenUsageContributor for TokenUsageRecorder { |
| fn on_token_usage<'a>( |
| &'a self, |
| session_store: &'a codex_extension_api::ExtensionData, |
| thread_store: &'a codex_extension_api::ExtensionData, |
| turn_store: &'a codex_extension_api::ExtensionData, |
| token_usage: &'a TokenUsageInfo, |
| ) -> codex_extension_api::ExtensionFuture<'a, ()> { |
| Box::pin(async move { |
| self.records |
| .lock() |
| .expect("token usage records lock") |
| .push(RecordedTokenUsage { |
| session_level_id: session_store.level_id().to_string(), |
| thread_level_id: thread_store.level_id().to_string(), |
| turn_level_id: turn_store.level_id().to_string(), |
| token_usage: token_usage.clone(), |
| saw_session_store: session_store.get::<SessionTokenUsageMarker>().is_some(), |
| saw_thread_store: thread_store.get::<ThreadTokenUsageMarker>().is_some(), |
| }); |
| }) |
| } |
| } |
|
|
| let (mut session, turn_context) = make_session_and_context().await; |
| let records = Arc::new(std::sync::Mutex::new(Vec::new())); |
| let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new(); |
| builder.token_usage_contributor(Arc::new(TokenUsageRecorder { |
| records: Arc::clone(&records), |
| })); |
| session.services.extensions = Arc::new(builder.build()); |
| session |
| .services |
| .session_extension_data |
| .insert(SessionTokenUsageMarker); |
| session |
| .services |
| .thread_extension_data |
| .insert(ThreadTokenUsageMarker); |
|
|
| let first_usage = TokenUsage { |
| input_tokens: 10, |
| cached_input_tokens: 2, |
| cache_write_input_tokens: 0, |
| output_tokens: 20, |
| reasoning_output_tokens: 3, |
| total_tokens: 33, |
| codex_rollout_budget_units: None, |
| }; |
| let second_usage = TokenUsage { |
| input_tokens: 7, |
| cached_input_tokens: 1, |
| cache_write_input_tokens: 0, |
| output_tokens: 8, |
| reasoning_output_tokens: 5, |
| total_tokens: 20, |
| codex_rollout_budget_units: None, |
| }; |
|
|
| session |
| .record_token_usage_info( |
| &turn_context, |
| &turn_context.initial_settings, |
| Some(&first_usage), |
| ) |
| .await |
| .expect("first usage should be recorded"); |
| session |
| .record_token_usage_info( |
| &turn_context, |
| &turn_context.initial_settings, |
| Some(&second_usage), |
| ) |
| .await |
| .expect("second usage should be recorded"); |
|
|
| let mut expected_total_usage = first_usage.clone(); |
| expected_total_usage.add_assign(&second_usage); |
| let expected = vec![ |
| RecordedTokenUsage { |
| session_level_id: session.session_id().to_string(), |
| thread_level_id: session.thread_id.to_string(), |
| turn_level_id: turn_context.sub_id.clone(), |
| token_usage: TokenUsageInfo { |
| total_token_usage: first_usage.clone(), |
| last_token_usage: first_usage, |
| model_context_window: turn_context.model_context_window(), |
| }, |
| saw_session_store: true, |
| saw_thread_store: true, |
| }, |
| RecordedTokenUsage { |
| session_level_id: session.session_id().to_string(), |
| thread_level_id: session.thread_id.to_string(), |
| turn_level_id: turn_context.sub_id.clone(), |
| token_usage: TokenUsageInfo { |
| total_token_usage: expected_total_usage, |
| last_token_usage: second_usage, |
| model_context_window: turn_context.model_context_window(), |
| }, |
| saw_session_store: true, |
| saw_thread_store: true, |
| }, |
| ]; |
| let actual = records |
| .lock() |
| .expect("token usage records lock") |
| .drain(..) |
| .collect::<Vec<_>>(); |
| assert_eq!(expected, actual); |
| } |
|
|
| #[tokio::test] |
| async fn turn_start_lifecycle_exposes_turn_metadata_and_token_baseline() { |
| struct SessionTurnStartMarker; |
| struct ThreadTurnStartMarker; |
|
|
| #[derive(Debug, PartialEq, Eq)] |
| struct RecordedTurnStart { |
| session_level_id: String, |
| thread_level_id: String, |
| turn_level_id: String, |
| turn_id: String, |
| collaboration_mode: CollaborationMode, |
| token_usage_at_turn_start: TokenUsage, |
| saw_session_store: bool, |
| saw_thread_store: bool, |
| } |
|
|
| struct TurnStartRecorder { |
| records: Arc<std::sync::Mutex<Vec<RecordedTurnStart>>>, |
| } |
|
|
| impl codex_extension_api::TurnLifecycleContributor for TurnStartRecorder { |
| fn on_turn_start<'a>( |
| &'a self, |
| input: codex_extension_api::TurnStartInput<'a>, |
| ) -> codex_extension_api::ExtensionFuture<'a, ()> { |
| Box::pin(async move { |
| self.records |
| .lock() |
| .expect("turn start records lock") |
| .push(RecordedTurnStart { |
| session_level_id: input.session_store.level_id().to_string(), |
| thread_level_id: input.thread_store.level_id().to_string(), |
| turn_level_id: input.turn_store.level_id().to_string(), |
| turn_id: input.turn_id.to_string(), |
| collaboration_mode: input.collaboration_mode.clone(), |
| token_usage_at_turn_start: input.token_usage_at_turn_start.clone(), |
| saw_session_store: input |
| .session_store |
| .get::<SessionTurnStartMarker>() |
| .is_some(), |
| saw_thread_store: input |
| .thread_store |
| .get::<ThreadTurnStartMarker>() |
| .is_some(), |
| }); |
| }) |
| } |
| } |
|
|
| let (mut session, turn_context) = make_session_and_context().await; |
| let records = Arc::new(std::sync::Mutex::new(Vec::new())); |
| let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new(); |
| builder.turn_lifecycle_contributor(Arc::new(TurnStartRecorder { |
| records: Arc::clone(&records), |
| })); |
| session.services.extensions = Arc::new(builder.build()); |
| session |
| .services |
| .session_extension_data |
| .insert(SessionTurnStartMarker); |
| session |
| .services |
| .thread_extension_data |
| .insert(ThreadTurnStartMarker); |
|
|
| let token_usage_at_turn_start = TokenUsage { |
| input_tokens: 100, |
| cached_input_tokens: 40, |
| cache_write_input_tokens: 0, |
| output_tokens: 25, |
| reasoning_output_tokens: 5, |
| total_tokens: 130, |
| codex_rollout_budget_units: None, |
| }; |
| set_total_token_usage(&session, token_usage_at_turn_start.clone()).await; |
|
|
| let expected = RecordedTurnStart { |
| session_level_id: session.session_id().to_string(), |
| thread_level_id: session.thread_id.to_string(), |
| turn_level_id: turn_context.sub_id.clone(), |
| turn_id: turn_context.sub_id.clone(), |
| collaboration_mode: turn_context.collaboration_mode(), |
| token_usage_at_turn_start, |
| saw_session_store: true, |
| saw_thread_store: true, |
| }; |
|
|
| let sess = Arc::new(session); |
| sess.spawn_task( |
| Arc::new(turn_context), |
| Vec::new(), |
| NeverEndingTask { |
| kind: TaskKind::Regular, |
| listen_to_cancellation_token: true, |
| }, |
| ) |
| .await; |
| sess.abort_all_tasks(TurnAbortReason::Interrupted).await; |
|
|
| let actual = records |
| .lock() |
| .expect("turn start records lock") |
| .drain(..) |
| .collect::<Vec<_>>(); |
| assert_eq!(vec![expected], actual); |
| } |
|
|
| #[tokio::test] |
| async fn turn_error_lifecycle_exposes_error_and_stores() { |
| struct SessionTurnErrorMarker; |
| struct ThreadTurnErrorMarker; |
|
|
| #[derive(Debug, PartialEq, Eq)] |
| struct RecordedTurnError { |
| session_level_id: String, |
| thread_level_id: String, |
| turn_level_id: String, |
| turn_id: String, |
| error: CodexErrorInfo, |
| saw_session_store: bool, |
| saw_thread_store: bool, |
| } |
|
|
| struct TurnErrorRecorder { |
| records: Arc<std::sync::Mutex<Vec<RecordedTurnError>>>, |
| } |
|
|
| impl codex_extension_api::TurnLifecycleContributor for TurnErrorRecorder { |
| fn on_turn_error<'a>( |
| &'a self, |
| input: codex_extension_api::TurnErrorInput<'a>, |
| ) -> codex_extension_api::ExtensionFuture<'a, ()> { |
| Box::pin(async move { |
| self.records |
| .lock() |
| .expect("turn error records lock") |
| .push(RecordedTurnError { |
| session_level_id: input.session_store.level_id().to_string(), |
| thread_level_id: input.thread_store.level_id().to_string(), |
| turn_level_id: input.turn_store.level_id().to_string(), |
| turn_id: input.turn_id.to_string(), |
| error: input.error, |
| saw_session_store: input |
| .session_store |
| .get::<SessionTurnErrorMarker>() |
| .is_some(), |
| saw_thread_store: input |
| .thread_store |
| .get::<ThreadTurnErrorMarker>() |
| .is_some(), |
| }); |
| }) |
| } |
| } |
|
|
| let (mut session, turn_context) = make_session_and_context().await; |
| let records = Arc::new(std::sync::Mutex::new(Vec::new())); |
| let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new(); |
| builder.turn_lifecycle_contributor(Arc::new(TurnErrorRecorder { |
| records: Arc::clone(&records), |
| })); |
| session.services.extensions = Arc::new(builder.build()); |
| session |
| .services |
| .session_extension_data |
| .insert(SessionTurnErrorMarker); |
| session |
| .services |
| .thread_extension_data |
| .insert(ThreadTurnErrorMarker); |
|
|
| let expected = RecordedTurnError { |
| session_level_id: session.session_id().to_string(), |
| thread_level_id: session.thread_id.to_string(), |
| turn_level_id: turn_context.sub_id.clone(), |
| turn_id: turn_context.sub_id.clone(), |
| error: CodexErrorInfo::UsageLimitExceeded, |
| saw_session_store: true, |
| saw_thread_store: true, |
| }; |
|
|
| session |
| .emit_turn_error_lifecycle(&turn_context, CodexErrorInfo::UsageLimitExceeded) |
| .await; |
|
|
| let actual = records |
| .lock() |
| .expect("turn error records lock") |
| .drain(..) |
| .collect::<Vec<_>>(); |
| assert_eq!(vec![expected], actual); |
| } |
|
|
| #[tokio::test] |
| async fn config_change_contributor_observes_effective_config_changes() { |
| struct SessionConfigMarker; |
| struct ThreadConfigMarker; |
|
|
| #[derive(Debug, PartialEq)] |
| struct RecordedConfigChange { |
| previous_model: Option<String>, |
| new_model: Option<String>, |
| previous_disabled_tools: Vec<ToolSuggestDisabledTool>, |
| new_disabled_tools: Vec<ToolSuggestDisabledTool>, |
| saw_session_store: bool, |
| saw_thread_store: bool, |
| } |
|
|
| struct ConfigRecorder { |
| records: Arc<std::sync::Mutex<Vec<RecordedConfigChange>>>, |
| } |
|
|
| impl codex_extension_api::ConfigContributor<crate::config::Config> for ConfigRecorder { |
| fn on_config_changed( |
| &self, |
| session_store: &codex_extension_api::ExtensionData, |
| thread_store: &codex_extension_api::ExtensionData, |
| previous_config: &crate::config::Config, |
| new_config: &crate::config::Config, |
| ) { |
| self.records |
| .lock() |
| .expect("config change records lock") |
| .push(RecordedConfigChange { |
| previous_model: previous_config.model.clone(), |
| new_model: new_config.model.clone(), |
| previous_disabled_tools: previous_config.tool_suggest.disabled_tools.clone(), |
| new_disabled_tools: new_config.tool_suggest.disabled_tools.clone(), |
| saw_session_store: session_store.get::<SessionConfigMarker>().is_some(), |
| saw_thread_store: thread_store.get::<ThreadConfigMarker>().is_some(), |
| }); |
| } |
| } |
|
|
| let (mut session, _turn_context) = make_session_and_context().await; |
| let records = Arc::new(std::sync::Mutex::new(Vec::new())); |
| let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new(); |
| builder.config_contributor(Arc::new(ConfigRecorder { |
| records: Arc::clone(&records), |
| })); |
| session.services.extensions = Arc::new(builder.build()); |
| session |
| .services |
| .session_extension_data |
| .insert(SessionConfigMarker); |
| session |
| .services |
| .thread_extension_data |
| .insert(ThreadConfigMarker); |
|
|
| let original_model = session.collaboration_mode().await.model().to_string(); |
| let original_disabled_tools = session |
| .get_config() |
| .await |
| .tool_suggest |
| .disabled_tools |
| .clone(); |
| let next_model = if original_model == "gpt-5.4" { |
| "gpt-5.2" |
| } else { |
| "gpt-5.4" |
| }; |
| let collaboration_mode = session.collaboration_mode().await.with_updates( |
| Some(next_model.to_string()), |
| None, |
| None, |
| ); |
| session |
| .update_settings(SessionSettingsUpdate { |
| step_settings: StepSettingsUpdate { |
| collaboration_mode: Some(collaboration_mode), |
| ..Default::default() |
| }, |
| ..Default::default() |
| }) |
| .await |
| .expect("update settings"); |
|
|
| let codex_home = session.codex_home().await; |
| std::fs::create_dir_all(&codex_home).expect("create codex home"); |
| std::fs::write( |
| codex_home.join(CONFIG_TOML_FILE), |
| r#"[tool_suggest] |
| disabled_tools = [ |
| { type = "connector", id = " calendar " }, |
| { type = "plugin", id = "slack@openai-curated" }, |
| ] |
| "#, |
| ) |
| .expect("write user config"); |
| let next_config = load_latest_config_for_session(&session).await; |
| session.refresh_runtime_config(next_config).await; |
|
|
| let expected_disabled_tools = vec![ |
| ToolSuggestDisabledTool::connector("calendar"), |
| ToolSuggestDisabledTool::plugin("slack@openai-curated"), |
| ]; |
| let expected = vec![ |
| RecordedConfigChange { |
| previous_model: Some(original_model), |
| new_model: Some(next_model.to_string()), |
| previous_disabled_tools: original_disabled_tools.clone(), |
| new_disabled_tools: original_disabled_tools.clone(), |
| saw_session_store: true, |
| saw_thread_store: true, |
| }, |
| RecordedConfigChange { |
| previous_model: Some(next_model.to_string()), |
| new_model: Some(next_model.to_string()), |
| previous_disabled_tools: original_disabled_tools, |
| new_disabled_tools: expected_disabled_tools, |
| saw_session_store: true, |
| saw_thread_store: true, |
| }, |
| ]; |
| let actual = records |
| .lock() |
| .expect("config change records lock") |
| .drain(..) |
| .collect::<Vec<_>>(); |
| assert_eq!(expected, actual); |
| } |
|
|
| #[tokio::test] |
| async fn record_initial_history_reconstructs_forked_transcript() { |
| let (session, turn_context) = make_session_and_context().await; |
| let (rollout_items, expected) = sample_rollout(&session, &turn_context).await; |
|
|
| session |
| .record_initial_history(InitialHistory::Forked(rollout_items)) |
| .await; |
|
|
| let history = session.state.lock().await.clone_history(); |
| assert_eq!( |
| strip_response_item_ids(&expected), |
| strip_response_item_ids(&raw_history_items(&history)) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn start_new_context_window_persists_checkpoint_state() { |
| let (mut session, turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( |
| CodexAuth::from_api_key("Test API Key"), |
| Vec::new(), |
| |_| {}, |
| ) |
| .await; |
| let rollout_path = |
| attach_thread_persistence(Arc::get_mut(&mut session).expect("unique session")).await; |
| let thread_id = ThreadId::new(); |
| let token_usage_record = TokenUsageRecord { |
| thread_id, |
| turn_id: "turn-1".to_string(), |
| session_id: SessionId::from(thread_id), |
| root_turn_id: "turn-1".to_string(), |
| response_id: "response-1".to_string(), |
| usage: TokenUsage::default(), |
| turn_token_usage: TokenUsage::default(), |
| thread_token_usage: TokenUsage::default(), |
| }; |
| session.state.lock().await.latest_token_usage_record = Some(token_usage_record.clone()); |
| let step_context = session |
| .capture_step_context(Arc::clone(&turn_context), &CancellationToken::new()) |
| .await |
| .expect("a fresh cancellation token cannot be cancelled"); |
| let world_state = Arc::new( |
| session |
| .build_world_state_for_step(&step_context) |
| .await |
| .expect("world state should build"), |
| ); |
|
|
| session |
| .start_new_context_window(&step_context, world_state) |
| .await; |
|
|
| let live_history = session.clone_history().await; |
| assert!(live_history.raw_items().next().is_some()); |
| assert!(live_history.raw_items().all(|item| item.id().is_some())); |
|
|
| session.flush_rollout().await.expect("rollout should flush"); |
| let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path) |
| .await |
| .expect("read rollout history") |
| else { |
| panic!("expected resumed rollout history"); |
| }; |
| let persisted_compacted = resumed.history.iter().rev().find_map(|item| match item { |
| RolloutItem::Compacted(compacted) => Some(compacted), |
| RolloutItem::SessionMeta(_) |
| | RolloutItem::ResponseItem(_) |
| | RolloutItem::InterAgentCommunication(_) |
| | RolloutItem::InterAgentCommunicationMetadata { .. } |
| | RolloutItem::TurnContext(_) |
| | RolloutItem::WorldState(_) |
| | RolloutItem::RetainedContext(_) |
| | RolloutItem::SecurityRiskScore(_) |
| | RolloutItem::TokenUsageRecord(_) |
| | RolloutItem::RealtimeItem(_) |
| | RolloutItem::EventMsg(_) => None, |
| }); |
| assert_eq!( |
| persisted_compacted.and_then(|compacted| compacted.replacement_history.clone()), |
| Some(live_history.annotated_items().to_vec()) |
| ); |
| assert_eq!( |
| persisted_compacted.map(|compacted| { |
| ( |
| compacted.compaction_response_id.as_deref(), |
| compacted.latest_token_usage_record.as_ref(), |
| ) |
| }), |
| Some((None, Some(&token_usage_record))) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn record_initial_history_assigns_and_persists_id_for_forked_response_item() { |
| let (mut session, _turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( |
| CodexAuth::from_api_key("Test API Key"), |
| Vec::new(), |
| |_| {}, |
| ) |
| .await; |
| let rollout_path = |
| attach_thread_persistence(Arc::get_mut(&mut session).expect("unique session")).await; |
| let response_item = |
| ContextualUserFragment::into(DeveloperInstructions::new("Subagent guidance.")); |
| let mut expected_item = ResponseItem::Message { |
| id: None, |
| role: "developer".to_string(), |
| content: vec![ContentItem::InputText { |
| text: "Subagent guidance.".to_string(), |
| }], |
| phase: None, |
| internal_chat_message_metadata_passthrough: Some(InternalChatMessageMetadataPassthrough { |
| content_item_kinds: Some(vec![ContentItemKind( |
| "generic.developer_instructions".to_string(), |
| )]), |
| ..Default::default() |
| }), |
| }; |
| let response_item = ResponseItemEnvelope { |
| item: response_item, |
| metadata: Some(CodexHarnessMetadata::default()), |
| }; |
|
|
| session |
| .record_initial_history(InitialHistory::Forked(vec![RolloutItem::ResponseItem( |
| response_item, |
| )])) |
| .await; |
|
|
| let live_history = session.clone_history().await; |
| let live_items = raw_history_items(&live_history); |
| let [live_item] = live_items.as_slice() else { |
| panic!("expected one forked response item"); |
| }; |
| let live_item_id = live_item |
| .id() |
| .expect("forked response item should have an id") |
| .to_string(); |
| assert!(live_item_id.starts_with("msg_")); |
| expected_item.set_id(live_item.id().cloned()); |
| assert_eq!(raw_history_items(&live_history), vec![expected_item]); |
| assert_eq!( |
| live_history.annotated_items()[0].metadata, |
| Some(CodexHarnessMetadata::default()) |
| ); |
|
|
| session.flush_rollout().await.expect("rollout should flush"); |
| let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path) |
| .await |
| .expect("read rollout history") |
| else { |
| panic!("expected resumed rollout history"); |
| }; |
| let persisted_item = resumed.history.iter().find_map(|item| match item { |
| RolloutItem::ResponseItem(response_item) => Some(response_item), |
| RolloutItem::SessionMeta(_) |
| | RolloutItem::InterAgentCommunication(_) |
| | RolloutItem::InterAgentCommunicationMetadata { .. } |
| | RolloutItem::Compacted(_) |
| | RolloutItem::TurnContext(_) |
| | RolloutItem::WorldState(_) |
| | RolloutItem::RetainedContext(_) |
| | RolloutItem::SecurityRiskScore(_) |
| | RolloutItem::TokenUsageRecord(_) |
| | RolloutItem::RealtimeItem(_) |
| | RolloutItem::EventMsg(_) => None, |
| }); |
| let persisted_item = persisted_item.expect("forked response item should be persisted"); |
| assert_eq!( |
| persisted_item.id().map(ResponseItemId::as_str), |
| Some(live_item_id.as_str()) |
| ); |
| assert_eq!( |
| persisted_item.metadata, |
| Some(CodexHarnessMetadata::default()) |
| ); |
| } |
|
|
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| async fn session_configured_reports_permission_profile_for_external_sandbox() -> anyhow::Result<()> |
| { |
| let server = start_mock_server().await; |
| let sandbox_policy = SandboxPolicy::ExternalSandbox { |
| network_access: codex_protocol::protocol::NetworkAccess::Restricted, |
| }; |
| let permission_profile = PermissionProfile::External { |
| network: NetworkSandboxPolicy::Restricted, |
| }; |
| let expected_permission_profile = permission_profile.clone(); |
| let mut builder = test_codex().with_config(move |config| { |
| config |
| .permissions |
| .set_permission_profile(permission_profile.clone()) |
| .expect("set permission profile"); |
| config |
| .set_legacy_sandbox_policy(sandbox_policy) |
| .expect("set sandbox policy"); |
| }); |
|
|
| let test = builder.build(&server).await?; |
|
|
| assert_eq!( |
| test.session_configured.permission_profile, expected_permission_profile, |
| "ExternalSandbox is represented explicitly instead of as a lossy root-write profile" |
| ); |
| Ok(()) |
| } |
|
|
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<()> { |
| let server = start_mock_server().await; |
| mount_sse_once( |
| &server, |
| sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), |
| ) |
| .await; |
| let first_forked_request = mount_sse_once( |
| &server, |
| sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), |
| ) |
| .await; |
|
|
| let mut builder = test_codex().with_config(|config| { |
| config.update_plan_enabled = true; |
| config.permissions.approval_policy = |
| codex_config::Constrained::allow_any(AskForApproval::OnRequest); |
| }); |
| let initial = builder.build(&server).await?; |
| let rollout_path = initial |
| .session_configured |
| .rollout_path |
| .clone() |
| .expect("rollout path"); |
|
|
| initial |
| .codex |
| .start_or_steer_turn(ExternalTurnInputRequest::user_input(vec![ |
| UserInput::Text { |
| text: "fork seed".into(), |
| text_elements: Vec::new(), |
| }, |
| ])) |
| .await?; |
| wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; |
| |
| |
| initial.codex.ensure_rollout_materialized().await; |
| initial |
| .codex |
| .flush_rollout() |
| .await |
| .expect("source rollout should flush before fork"); |
|
|
| let mut fork_config = initial.config.clone(); |
| fork_config.permissions.approval_policy = |
| codex_config::Constrained::allow_any(AskForApproval::UnlessTrusted); |
| let forked = initial |
| .thread_manager |
| .fork_thread( |
| usize::MAX, |
| core_test_support::test_codex::StartThreadOptions::new(fork_config.clone()), |
| rollout_path, |
| ) |
| .await?; |
|
|
| let collaboration_mode = CollaborationMode { |
| mode: ModeKind::Plan, |
| settings: Settings { |
| model: forked.session_configured.model.clone(), |
| reasoning_effort: None, |
| developer_instructions: Some("Fork turn collaboration instructions.".to_string()), |
| }, |
| }; |
| forked |
| .thread |
| .start_or_steer_turn( |
| ExternalTurnInputRequest::user_input(vec![UserInput::Text { |
| text: "after fork".into(), |
| text_elements: Vec::new(), |
| }]) |
| .with_thread_settings(ThreadSettingsOverrides { |
| approval_policy: Some(AskForApproval::Never), |
| collaboration_mode: Some(collaboration_mode), |
| ..Default::default() |
| }), |
| ) |
| .await?; |
| wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; |
|
|
| let request = first_forked_request.single_request(); |
| let snapshot = context_snapshot::format_labeled_requests_snapshot( |
| "First request after fork when startup preserves the parent baseline, the fork changes approval policy, and the first forked turn enters plan mode.", |
| &[("First Forked Turn Request", &request)], |
| &ContextSnapshotOptions::default().rewrite_known_segments(), |
| ); |
|
|
| let mut settings = insta::Settings::clone_current(); |
| settings.set_snapshot_path("snapshots"); |
| settings.set_prepend_module_to_snapshot(false); |
| settings.bind(|| { |
| insta::assert_snapshot!( |
| "codex_core__codex_tests__fork_startup_context_then_first_turn_diff", |
| snapshot |
| ); |
| }); |
|
|
| Ok(()) |
| } |
|
|
| #[tokio::test] |
| async fn record_initial_history_forked_hydrates_previous_turn_settings() { |
| let (session, turn_context) = make_session_and_context().await; |
| let previous_model = "forked-rollout-model"; |
| let previous_context_item = TurnContextItem { |
| turn_id: Some(turn_context.sub_id.clone()), |
| root_turn_id: None, |
| disabled_plugin_ids: None, |
| #[allow(deprecated)] |
| cwd: turn_context.cwd.clone(), |
| workspace_roots: None, |
| current_date: turn_context.current_date.clone(), |
| timezone: turn_context.timezone.clone(), |
| approval_policy: turn_context.approval_policy(), |
| approvals_reviewer: None, |
| sandbox_policy: turn_context.sandbox_policy(), |
| permission_profile: None, |
| active_permission_profile: None, |
| network: None, |
| file_system_sandbox_policy: None, |
| model: previous_model.to_string(), |
| comp_hash: None, |
| personality: turn_context.personality(), |
| collaboration_mode: Some(turn_context.collaboration_mode()), |
| multi_agent_version: None, |
| multi_agent_mode: None, |
| realtime_active: Some(turn_context.realtime_active), |
| cyber_access_program: None, |
| effort: turn_context.reasoning_effort().cloned(), |
| summary: codex_protocol::config_types::ReasoningSummary::Auto, |
| }; |
| let turn_id = previous_context_item |
| .turn_id |
| .clone() |
| .expect("thread settings should have turn_id"); |
| let rollout_items = vec![ |
| RolloutItem::EventMsg(EventMsg::TurnStarted( |
| codex_protocol::protocol::TurnStartedEvent { |
| turn_id: turn_id.clone(), |
| root_turn_id: None, |
| trace_id: None, |
| started_at: None, |
| model_context_window: Some(128_000), |
| collaboration_mode_kind: ModeKind::Default, |
| }, |
| )), |
| RolloutItem::EventMsg(EventMsg::UserMessage( |
| codex_protocol::protocol::UserMessageEvent { |
| client_id: None, |
| message: "forked seed".to_string(), |
| images: None, |
| local_images: Vec::new(), |
| text_elements: Vec::new(), |
| ..Default::default() |
| }, |
| )), |
| RolloutItem::TurnContext(previous_context_item.clone()), |
| RolloutItem::EventMsg(EventMsg::TurnComplete( |
| codex_protocol::protocol::TurnCompleteEvent { |
| turn_id, |
| last_agent_message: None, |
| error: None, |
| started_at: None, |
| completed_at: None, |
| duration_ms: None, |
| time_to_first_token_ms: None, |
| }, |
| )), |
| ]; |
|
|
| session |
| .record_initial_history(InitialHistory::Forked(rollout_items)) |
| .await; |
|
|
| let history = session.clone_history().await; |
| assert_eq!( |
| session.previous_turn_settings().await, |
| Some(PreviousTurnSettings { |
| model: previous_model.to_string(), |
| comp_hash: None, |
| realtime_active: Some(turn_context.realtime_active), |
| }) |
| ); |
| assert_eq!(raw_history_items(&history), Vec::<ResponseItem>::new()); |
| assert_eq!( |
| serde_json::to_value(session.reference_context_item().await) |
| .expect("serialize fork reference context item"), |
| serde_json::to_value(Some(previous_context_item)) |
| .expect("serialize expected reference context item") |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn set_rate_limits_retains_previous_credits() { |
| let codex_home = tempfile::tempdir().expect("create temp dir"); |
| let config = build_test_config(codex_home.path()).await; |
| let config = Arc::new(config); |
| let model = get_model_offline_for_tests(config.model.as_deref()); |
| let model_info = |
| construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); |
| let reasoning_effort = config.model_reasoning_effort.clone(); |
| let collaboration_mode = CollaborationMode { |
| mode: ModeKind::Default, |
| settings: Settings { |
| model, |
| reasoning_effort, |
| developer_instructions: None, |
| }, |
| }; |
| let session_configuration = SessionConfiguration { |
| provider: create_model_provider(config.model_provider.clone(), None), |
| step_settings: Arc::new(StepSettings { |
| collaboration_mode, |
| reasoning_summary: config.model_reasoning_summary, |
| service_tier: None, |
| personality: config.personality, |
| approval_policy: config.permissions.approval_policy.clone(), |
| approvals_reviewer: config.approvals_reviewer, |
| }), |
| model_info_overrides: config.to_models_manager_config().into(), |
| developer_instructions: config.developer_instructions.clone(), |
| base_instructions: config |
| .base_instructions |
| .clone() |
| .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), |
| permission_profile_state: config.permissions.permission_profile_state().clone(), |
| allow_login_shell: config.permissions.allow_login_shell, |
| shell_environment_policy: config.permissions.shell_environment_policy.clone(), |
| windows_sandbox_level: WindowsSandboxLevel::from_config(&config), |
| windows_sandbox_type: config.permissions.windows_sandbox_type, |
| windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop, |
| use_legacy_landlock: config.features.use_legacy_landlock(), |
| legacy_fallback_cwd: config.cwd.clone(), |
| runtime_workspace_roots: config.workspace_roots.clone(), |
| codex_home: config.codex_home.clone(), |
| thread_name: None, |
| disabled_plugin_ids: Vec::new(), |
| original_config_do_not_use: Arc::clone(&config), |
| metrics_service_name: None, |
| app_server_client_name: None, |
| app_server_client_version: None, |
| trusted_guardian_reviewer: false, |
| session_source: SessionSource::Exec, |
| history_mode: Default::default(), |
| forked_from_thread_id: None, |
| parent_thread_id: None, |
| thread_source: None, |
| originator: "test_originator".to_string(), |
| dynamic_tools: Vec::new(), |
| user_shell_override: None, |
| }; |
|
|
| let mut state = SessionState::new(session_configuration); |
| let initial = RateLimitSnapshot { |
| limit_id: None, |
| limit_name: None, |
| normal_model_slug: None, |
| primary: Some(RateLimitWindow { |
| used_percent: 10.0, |
| window_minutes: Some(15), |
| resets_at: Some(1_700), |
| }), |
| secondary: None, |
| credits: Some(CreditsSnapshot { |
| has_credits: true, |
| unlimited: false, |
| balance: Some("10.00".to_string()), |
| }), |
| individual_limit: None, |
| spend_control_reached: None, |
| plan_type: Some(codex_protocol::account::PlanType::Plus), |
| rate_limit_reached_type: None, |
| }; |
| state.set_rate_limits(initial.clone()); |
|
|
| let update = RateLimitSnapshot { |
| limit_id: Some("codex_other".to_string()), |
| limit_name: Some("codex_other".to_string()), |
| normal_model_slug: None, |
| primary: Some(RateLimitWindow { |
| used_percent: 40.0, |
| window_minutes: Some(30), |
| resets_at: Some(1_800), |
| }), |
| secondary: Some(RateLimitWindow { |
| used_percent: 5.0, |
| window_minutes: Some(60), |
| resets_at: Some(1_900), |
| }), |
| credits: None, |
| individual_limit: None, |
| spend_control_reached: None, |
| plan_type: None, |
| rate_limit_reached_type: None, |
| }; |
| state.set_rate_limits(update.clone()); |
|
|
| assert_eq!( |
| state.latest_rate_limits, |
| Some(RateLimitSnapshot { |
| limit_id: Some("codex_other".to_string()), |
| limit_name: Some("codex_other".to_string()), |
| normal_model_slug: None, |
| primary: update.primary.clone(), |
| secondary: update.secondary, |
| credits: initial.credits, |
| individual_limit: initial.individual_limit, |
| spend_control_reached: initial.spend_control_reached, |
| plan_type: initial.plan_type, |
| rate_limit_reached_type: None, |
| }) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn set_rate_limits_updates_plan_type_when_present() { |
| let codex_home = tempfile::tempdir().expect("create temp dir"); |
| let config = build_test_config(codex_home.path()).await; |
| let config = Arc::new(config); |
| let model = get_model_offline_for_tests(config.model.as_deref()); |
| let model_info = |
| construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); |
| let reasoning_effort = config.model_reasoning_effort.clone(); |
| let collaboration_mode = CollaborationMode { |
| mode: ModeKind::Default, |
| settings: Settings { |
| model, |
| reasoning_effort, |
| developer_instructions: None, |
| }, |
| }; |
| let session_configuration = SessionConfiguration { |
| provider: create_model_provider(config.model_provider.clone(), None), |
| step_settings: Arc::new(StepSettings { |
| collaboration_mode, |
| reasoning_summary: config.model_reasoning_summary, |
| service_tier: None, |
| personality: config.personality, |
| approval_policy: config.permissions.approval_policy.clone(), |
| approvals_reviewer: config.approvals_reviewer, |
| }), |
| model_info_overrides: config.to_models_manager_config().into(), |
| developer_instructions: config.developer_instructions.clone(), |
| base_instructions: config |
| .base_instructions |
| .clone() |
| .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), |
| permission_profile_state: config.permissions.permission_profile_state().clone(), |
| allow_login_shell: config.permissions.allow_login_shell, |
| shell_environment_policy: config.permissions.shell_environment_policy.clone(), |
| windows_sandbox_level: WindowsSandboxLevel::from_config(&config), |
| windows_sandbox_type: config.permissions.windows_sandbox_type, |
| windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop, |
| use_legacy_landlock: config.features.use_legacy_landlock(), |
| legacy_fallback_cwd: config.cwd.clone(), |
| runtime_workspace_roots: config.workspace_roots.clone(), |
| codex_home: config.codex_home.clone(), |
| thread_name: None, |
| disabled_plugin_ids: Vec::new(), |
| original_config_do_not_use: Arc::clone(&config), |
| metrics_service_name: None, |
| app_server_client_name: None, |
| app_server_client_version: None, |
| trusted_guardian_reviewer: false, |
| session_source: SessionSource::Exec, |
| history_mode: Default::default(), |
| forked_from_thread_id: None, |
| parent_thread_id: None, |
| thread_source: None, |
| originator: "test_originator".to_string(), |
| dynamic_tools: Vec::new(), |
| user_shell_override: None, |
| }; |
|
|
| let mut state = SessionState::new(session_configuration); |
| let initial = RateLimitSnapshot { |
| limit_id: None, |
| limit_name: None, |
| normal_model_slug: None, |
| primary: Some(RateLimitWindow { |
| used_percent: 15.0, |
| window_minutes: Some(20), |
| resets_at: Some(1_600), |
| }), |
| secondary: Some(RateLimitWindow { |
| used_percent: 5.0, |
| window_minutes: Some(45), |
| resets_at: Some(1_650), |
| }), |
| credits: Some(CreditsSnapshot { |
| has_credits: true, |
| unlimited: false, |
| balance: Some("15.00".to_string()), |
| }), |
| individual_limit: None, |
| spend_control_reached: None, |
| plan_type: Some(codex_protocol::account::PlanType::Plus), |
| rate_limit_reached_type: None, |
| }; |
| state.set_rate_limits(initial.clone()); |
|
|
| let update = RateLimitSnapshot { |
| limit_id: None, |
| limit_name: None, |
| normal_model_slug: None, |
| primary: Some(RateLimitWindow { |
| used_percent: 35.0, |
| window_minutes: Some(25), |
| resets_at: Some(1_700), |
| }), |
| secondary: None, |
| credits: None, |
| individual_limit: None, |
| spend_control_reached: None, |
| plan_type: Some(codex_protocol::account::PlanType::Pro), |
| rate_limit_reached_type: None, |
| }; |
| state.set_rate_limits(update.clone()); |
|
|
| assert_eq!( |
| state.latest_rate_limits, |
| Some(RateLimitSnapshot { |
| limit_id: Some("codex".to_string()), |
| limit_name: None, |
| normal_model_slug: None, |
| primary: update.primary, |
| secondary: update.secondary, |
| credits: initial.credits, |
| individual_limit: initial.individual_limit, |
| spend_control_reached: initial.spend_control_reached, |
| plan_type: update.plan_type, |
| rate_limit_reached_type: None, |
| }) |
| ); |
| } |
|
|
| #[test] |
| fn prefers_structured_content_when_present() { |
| let ctr = McpCallToolResult { |
| |
| content: vec![text_block("ignored")], |
| is_error: None, |
| structured_content: Some(json!({ |
| "ok": true, |
| "value": 42 |
| })), |
| meta: None, |
| }; |
|
|
| let got = ctr.into_function_call_output_payload(); |
| let expected = FunctionCallOutputPayload { |
| body: FunctionCallOutputBody::Text( |
| serde_json::to_string(&json!({ |
| "ok": true, |
| "value": 42 |
| })) |
| .unwrap(), |
| ), |
| success: Some(true), |
| }; |
|
|
| assert_eq!(expected, got); |
| } |
|
|
| #[tokio::test] |
| async fn includes_timed_out_message() { |
| let exec = ExecToolCallOutput { |
| exit_code: 0, |
| stdout: StreamOutput::new(String::new()), |
| stderr: StreamOutput::new(String::new()), |
| aggregated_output: StreamOutput::new("Command output".to_string()), |
| duration: StdDuration::from_secs(1), |
| timed_out: true, |
| }; |
| let (_, turn_context) = make_session_and_context().await; |
|
|
| let out = format_exec_output_str(&exec, turn_context.model_info().truncation_policy.into()); |
|
|
| assert_eq!( |
| out, |
| "command timed out after 1000 milliseconds\nCommand output" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn turn_context_with_model_updates_model_fields() { |
| let (session, mut turn_context) = make_session_and_context().await; |
| let config = Arc::make_mut(&mut turn_context.config); |
| config.features.enable(Feature::FastMode).unwrap(); |
| config.model_reasoning_effort = Some(ReasoningEffortConfig::Minimal); |
| config.model_reasoning_summary = Some(ReasoningSummaryConfig::Detailed); |
| update_turn_settings_for_test(&mut turn_context, |settings| { |
| let mut selected = settings.selected().clone(); |
| selected.collaboration_mode.settings.reasoning_effort = |
| Some(ReasoningEffortConfig::Minimal); |
| selected.reasoning_summary = Some(ReasoningSummaryConfig::Detailed); |
| selected.service_tier = Some(ServiceTier::Fast.request_value().to_string()); |
| *settings = ResolvedStepSettings::new( |
| Arc::new(selected), |
| Arc::clone(&settings.model_info), |
| true, |
| ); |
| }); |
| Arc::make_mut(&mut turn_context.config).service_tier = |
| turn_context.initial_settings.service_tier.clone(); |
| let captured = turn_context.current_settings.load_full(); |
| let mut current_selection = captured.selected().clone(); |
| current_selection.reasoning_summary = Some(ReasoningSummaryConfig::None); |
| current_selection.service_tier = None; |
| current_selection |
| .collaboration_mode |
| .settings |
| .reasoning_effort = Some(ReasoningEffortConfig::High); |
| let current = Arc::new(ResolvedStepSettings::new( |
| Arc::new(current_selection), |
| Arc::clone(turn_context.model_info()), |
| true, |
| )); |
| turn_context.current_settings.store(Arc::clone(¤t)); |
| let updated = turn_context |
| .with_model("gpt-5.5".to_string(), &session.services.models_manager) |
| .await; |
| let expected_model_info = session |
| .services |
| .models_manager |
| .get_model_info( |
| "gpt-5.5", |
| &updated.config.as_ref().to_models_manager_config(), |
| ) |
| .await; |
|
|
| |
| |
| assert_eq!( |
| ( |
| updated.reasoning_summary(), |
| updated.initial_settings.service_tier.as_deref() |
| ), |
| ( |
| ReasoningSummaryConfig::Detailed, |
| Some(ServiceTier::Fast.request_value()) |
| ), |
| ); |
| assert_eq!( |
| ( |
| updated.config.model_reasoning_summary, |
| updated.config.service_tier.as_deref() |
| ), |
| ( |
| Some(ReasoningSummaryConfig::Detailed), |
| Some(ServiceTier::Fast.request_value()) |
| ), |
| ); |
| assert!(Arc::ptr_eq(&captured, &turn_context.initial_settings)); |
| assert!(Arc::ptr_eq( |
| ¤t, |
| &turn_context.current_settings.load_full() |
| )); |
| assert!(!Arc::ptr_eq(&captured, &updated.initial_settings)); |
| assert!(Arc::ptr_eq( |
| &updated.initial_settings, |
| &updated.current_settings.load_full() |
| )); |
| assert!(!Arc::ptr_eq( |
| &updated.current_settings.load_full(), |
| &turn_context.current_settings.load_full() |
| )); |
| assert_eq!(updated.config.model.as_deref(), Some("gpt-5.5")); |
| assert_eq!(updated.collaboration_mode().model(), "gpt-5.5"); |
| assert_eq!(updated.model_info().as_ref(), &expected_model_info); |
| assert_eq!( |
| updated.reasoning_effort(), |
| Some(&ReasoningEffortConfig::Medium) |
| ); |
| assert_eq!( |
| updated.collaboration_mode().reasoning_effort(), |
| Some(ReasoningEffortConfig::Medium) |
| ); |
| assert_eq!( |
| updated.config.model_reasoning_effort, |
| Some(ReasoningEffortConfig::Medium) |
| ); |
| } |
|
|
| #[test] |
| fn falls_back_to_content_when_structured_is_null() { |
| let ctr = McpCallToolResult { |
| content: vec![text_block("hello"), text_block("world")], |
| is_error: None, |
| structured_content: Some(serde_json::Value::Null), |
| meta: None, |
| }; |
|
|
| let got = ctr.into_function_call_output_payload(); |
| let expected = FunctionCallOutputPayload { |
| body: FunctionCallOutputBody::ContentItems(vec![ |
| FunctionCallOutputContentItem::InputText { |
| text: "hello".to_string(), |
| }, |
| FunctionCallOutputContentItem::InputText { |
| text: "world".to_string(), |
| }, |
| ]), |
| success: Some(true), |
| }; |
|
|
| assert_eq!(expected, got); |
| } |
|
|
| #[test] |
| fn success_flag_reflects_is_error_true() { |
| let ctr = McpCallToolResult { |
| content: vec![text_block("unused")], |
| is_error: Some(true), |
| structured_content: Some(json!({ "message": "bad" })), |
| meta: None, |
| }; |
|
|
| let got = ctr.into_function_call_output_payload(); |
| let expected = FunctionCallOutputPayload { |
| body: FunctionCallOutputBody::Text( |
| serde_json::to_string(&json!({ "message": "bad" })).unwrap(), |
| ), |
| success: Some(false), |
| }; |
|
|
| assert_eq!(expected, got); |
| } |
|
|
| #[test] |
| fn success_flag_true_with_no_error_and_content_used() { |
| let ctr = McpCallToolResult { |
| content: vec![text_block("alpha")], |
| is_error: Some(false), |
| structured_content: None, |
| meta: None, |
| }; |
|
|
| let got = ctr.into_function_call_output_payload(); |
| let expected = FunctionCallOutputPayload { |
| body: FunctionCallOutputBody::ContentItems(vec![ |
| FunctionCallOutputContentItem::InputText { |
| text: "alpha".to_string(), |
| }, |
| ]), |
| success: Some(true), |
| }; |
|
|
| assert_eq!(expected, got); |
| } |
|
|
| async fn open_thread_persistence(session: &mut Session) -> PathBuf { |
| let config = session.get_config().await; |
| let live_thread = LiveThread::create( |
| Arc::clone(&session.services.thread_store), |
| CreateThreadParams { |
| session_id: session.session_id(), |
| thread_id: session.thread_id, |
| extra_config: None, |
| forked_from_id: None, |
| parent_thread_id: None, |
| source: SessionSource::Exec, |
| thread_source: None, |
| originator: "test_originator".to_string(), |
| base_instructions: BaseInstructions::default(), |
| dynamic_tools: Vec::new(), |
| selected_capability_roots: Vec::new(), |
| multi_agent_version: None, |
| history_mode: Default::default(), |
| subagent_history_start_ordinal: None, |
| history_base: None, |
| initial_window_id: Uuid::now_v7().to_string(), |
| runtime_workspace_roots: None, |
| metadata: ThreadPersistenceMetadata { |
| cwd: Some(config.cwd.to_path_buf()), |
| model_provider: config.model_provider_id.clone(), |
| memory_mode: if config.memories.generate_memories { |
| ThreadMemoryMode::Enabled |
| } else { |
| ThreadMemoryMode::Disabled |
| }, |
| }, |
| }, |
| ) |
| .await |
| .expect("create thread persistence"); |
| session.services.live_thread = Some(live_thread); |
| session |
| .current_rollout_path() |
| .await |
| .expect("load rollout path") |
| .expect("thread should have rollout path") |
| } |
|
|
| async fn attach_thread_persistence(session: &mut Session) -> PathBuf { |
| let rollout_path = open_thread_persistence(session).await; |
| session |
| .ensure_rollout_materialized(PersistContext::Standard) |
| .await; |
| session |
| .flush_rollout() |
| .await |
| .expect("attached rollout should flush"); |
| rollout_path |
| } |
|
|
| fn text_block(s: &str) -> serde_json::Value { |
| json!({ |
| "type": "text", |
| "text": s, |
| }) |
| } |
|
|
| async fn build_test_config(codex_home: &Path) -> Config { |
| ConfigBuilder::without_managed_config_for_tests() |
| .codex_home(codex_home.to_path_buf()) |
| .harness_overrides(ConfigOverrides { |
| model: Some("gpt-5.5".to_string()), |
| ..Default::default() |
| }) |
| .build() |
| .await |
| .expect("load default test config") |
| } |
|
|
| fn session_telemetry( |
| conversation_id: ThreadId, |
| config: &Config, |
| model_info: &ModelInfo, |
| session_source: SessionSource, |
| ) -> SessionTelemetry { |
| SessionTelemetry::new( |
| conversation_id, |
| get_model_offline_for_tests(config.model.as_deref()).as_str(), |
| model_info.slug.as_str(), |
| None, |
| Some("test@test.com".to_string()), |
| Some(TelemetryAuthMode::Chatgpt), |
| "test_originator".to_string(), |
| false, |
| "test".to_string(), |
| session_source, |
| ) |
| } |
|
|
| fn model_with_default_service_tier(default_service_tier: Option<&str>) -> ModelInfo { |
| let mut model_info = model_info::model_info_from_slug("gpt-5.4"); |
| model_info.service_tiers = vec![ModelServiceTier { |
| id: ServiceTier::Fast.request_value().to_string(), |
| name: "Fast".to_string(), |
| description: "Priority processing.".to_string(), |
| }]; |
| model_info.default_service_tier = default_service_tier.map(str::to_string); |
| model_info |
| } |
|
|
| #[test] |
| fn get_service_tier_does_not_use_model_default_when_absent_and_fast_mode_enabled() { |
| let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value())); |
|
|
| assert_eq!( |
| get_service_tier( |
| None, |
| true, |
| &model_info, |
| ), |
| None |
| ); |
| } |
|
|
| #[test] |
| fn get_service_tier_does_not_use_model_default_when_fast_mode_disabled() { |
| let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value())); |
|
|
| assert_eq!( |
| get_service_tier( |
| None, |
| false, |
| &model_info, |
| ), |
| None |
| ); |
| } |
|
|
| #[test] |
| fn get_service_tier_keeps_supported_explicit_tier() { |
| let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value())); |
|
|
| assert_eq!( |
| get_service_tier( |
| Some(ServiceTier::Fast.request_value().to_string()), |
| true, |
| &model_info, |
| ), |
| Some(ServiceTier::Fast.request_value().to_string()) |
| ); |
| } |
|
|
| #[test] |
| fn get_service_tier_does_not_default_when_model_has_no_default() { |
| let model_info = model_with_default_service_tier( None); |
|
|
| assert_eq!( |
| get_service_tier( |
| None, |
| true, |
| &model_info, |
| ), |
| None |
| ); |
| } |
|
|
| #[test] |
| fn get_service_tier_drops_unsupported_configured_tier_when_fast_mode_enabled() { |
| let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value())); |
|
|
| assert_eq!( |
| get_service_tier( |
| Some("unsupported".to_string()), |
| true, |
| &model_info, |
| ), |
| None |
| ); |
| assert_eq!( |
| get_service_tier( |
| Some(ServiceTier::Flex.request_value().to_string()), |
| true, |
| &model_info, |
| ), |
| None |
| ); |
| assert_eq!( |
| get_service_tier( |
| Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()), |
| true, |
| &model_info, |
| ), |
| Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()) |
| ); |
| } |
|
|
| #[test] |
| fn get_service_tier_ignores_configured_tier_when_fast_mode_disabled() { |
| let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value())); |
|
|
| assert_eq!( |
| get_service_tier( |
| Some(ServiceTier::Fast.request_value().to_string()), |
| false, |
| &model_info, |
| ), |
| None |
| ); |
| assert_eq!( |
| get_service_tier( |
| Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()), |
| false, |
| &model_info, |
| ), |
| None |
| ); |
| assert_eq!( |
| get_service_tier( |
| Some("unsupported".to_string()), |
| false, |
| &model_info, |
| ), |
| None |
| ); |
| assert_eq!( |
| get_service_tier( |
| None, |
| false, |
| &model_info, |
| ), |
| None |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn session_settings_null_service_tier_update_uses_default_service_tier() { |
| let session_configuration = make_session_configuration_for_tests().await; |
|
|
| let updated = session_configuration |
| .apply( |
| &SessionSettingsUpdate { |
| step_settings: StepSettingsUpdate { |
| service_tier: Some(None), |
| ..Default::default() |
| }, |
| ..Default::default() |
| }, |
| &[], |
| ) |
| .expect("null service tier update should apply"); |
|
|
| assert_eq!( |
| updated.step_settings.service_tier, |
| Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn session_settings_legacy_fast_service_tier_update_uses_priority_request_value() { |
| let session_configuration = make_session_configuration_for_tests().await; |
|
|
| let updated = session_configuration |
| .apply( |
| &SessionSettingsUpdate { |
| step_settings: StepSettingsUpdate { |
| service_tier: Some(Some("fast".to_string())), |
| ..Default::default() |
| }, |
| ..Default::default() |
| }, |
| &[], |
| ) |
| .expect("legacy fast service tier update should apply"); |
|
|
| assert_eq!( |
| updated.step_settings.service_tier, |
| Some(ServiceTier::Fast.request_value().to_string()) |
| ); |
| } |
|
|
| pub(crate) async fn make_session_configuration_for_tests() -> SessionConfiguration { |
| let codex_home = tempfile::tempdir().expect("create temp dir"); |
| let config = build_test_config(codex_home.path()).await; |
| let config = Arc::new(config); |
| let model = get_model_offline_for_tests(config.model.as_deref()); |
| let model_info = |
| construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); |
| let reasoning_effort = config.model_reasoning_effort.clone(); |
| let collaboration_mode = CollaborationMode { |
| mode: ModeKind::Default, |
| settings: Settings { |
| model, |
| reasoning_effort, |
| developer_instructions: None, |
| }, |
| }; |
|
|
| SessionConfiguration { |
| provider: create_model_provider(config.model_provider.clone(), None), |
| step_settings: Arc::new(StepSettings { |
| collaboration_mode, |
| reasoning_summary: config.model_reasoning_summary, |
| service_tier: None, |
| personality: config.personality, |
| approval_policy: config.permissions.approval_policy.clone(), |
| approvals_reviewer: config.approvals_reviewer, |
| }), |
| model_info_overrides: config.to_models_manager_config().into(), |
| developer_instructions: config.developer_instructions.clone(), |
| base_instructions: config |
| .base_instructions |
| .clone() |
| .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), |
| permission_profile_state: config.permissions.permission_profile_state().clone(), |
| allow_login_shell: config.permissions.allow_login_shell, |
| shell_environment_policy: config.permissions.shell_environment_policy.clone(), |
| windows_sandbox_level: WindowsSandboxLevel::from_config(&config), |
| windows_sandbox_type: config.permissions.windows_sandbox_type, |
| windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop, |
| use_legacy_landlock: config.features.use_legacy_landlock(), |
| legacy_fallback_cwd: config.cwd.clone(), |
| runtime_workspace_roots: config.workspace_roots.clone(), |
| codex_home: config.codex_home.clone(), |
| thread_name: None, |
| disabled_plugin_ids: Vec::new(), |
| original_config_do_not_use: Arc::clone(&config), |
| metrics_service_name: None, |
| app_server_client_name: None, |
| app_server_client_version: None, |
| trusted_guardian_reviewer: false, |
| session_source: SessionSource::Exec, |
| history_mode: Default::default(), |
| forked_from_thread_id: None, |
| parent_thread_id: None, |
| thread_source: None, |
| originator: "test_originator".to_string(), |
| dynamic_tools: Vec::new(), |
| user_shell_override: None, |
| } |
| } |
|
|
| #[tokio::test] |
| async fn emit_subagent_session_started_includes_fork_lineage_and_originator() { |
| use codex_app_server_protocol::ServerNotification; |
| use codex_app_server_protocol::ThreadArchivedNotification; |
| use wiremock::Mock; |
| use wiremock::MockServer; |
| use wiremock::ResponseTemplate; |
| use wiremock::matchers::method; |
| use wiremock::matchers::path; |
|
|
| let server = MockServer::start().await; |
| Mock::given(method("POST")) |
| .and(path("/codex/analytics-events/events")) |
| .respond_with(ResponseTemplate::new(200)) |
| .mount(&server) |
| .await; |
|
|
| let auth_manager = |
| AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); |
| let analytics_events_client = AnalyticsEventsClient::new( |
| auth_manager, |
| server.uri(), |
| Some(true), |
| ); |
|
|
| let parent_thread_id = ThreadId::new(); |
| let forked_from_thread_id = ThreadId::new(); |
| let child_thread_id = ThreadId::new(); |
| let mut session_configuration = make_session_configuration_for_tests().await; |
| session_configuration.forked_from_thread_id = Some(forked_from_thread_id); |
| session_configuration.thread_source = Some(ThreadSource::GuardianReview); |
|
|
| emit_subagent_session_started( |
| &analytics_events_client, |
| AppServerClientMetadata { |
| client_name: Some("codex-tui".to_string()), |
| client_version: Some("1.0.0".to_string()), |
| }, |
| SessionId::from(child_thread_id), |
| child_thread_id, |
| Some(parent_thread_id), |
| session_configuration.thread_config_snapshot(Vec::new()), |
| SubAgentSource::Other(crate::guardian::GUARDIAN_REVIEWER_NAME.to_string()), |
| ); |
|
|
| let event = timeout(Duration::from_secs(1), async { |
| 'wait_for_event: loop { |
| if let Some(requests) = server.received_requests().await { |
| for request in requests { |
| let payload: serde_json::Value = |
| serde_json::from_slice(&request.body).expect("valid analytics payload"); |
| if let Some(event) = payload["events"].as_array().and_then(|events| { |
| events |
| .iter() |
| .find(|event| event["event_type"] == "codex_thread_initialized") |
| }) { |
| break 'wait_for_event event.clone(); |
| } |
| } |
| } |
| tokio::time::sleep(Duration::from_millis(10)).await; |
| } |
| }) |
| .await |
| .expect("subagent initialization analytics should be emitted"); |
|
|
| assert_eq!(event["event_params"]["thread_source"], "guardian_review"); |
| assert_eq!( |
| event["event_params"]["parent_thread_id"], |
| parent_thread_id.to_string() |
| ); |
| assert_eq!( |
| event["event_params"]["forked_from_thread_id"], |
| forked_from_thread_id.to_string() |
| ); |
| assert_eq!( |
| event["event_params"]["app_server_client"]["product_client_id"], |
| "test_originator" |
| ); |
|
|
| let prewarmed_thread_id = ThreadId::new(); |
| emit_subagent_session_started( |
| &analytics_events_client, |
| AppServerClientMetadata { |
| client_name: None, |
| client_version: None, |
| }, |
| SessionId::from(parent_thread_id), |
| prewarmed_thread_id, |
| Some(parent_thread_id), |
| session_configuration.thread_config_snapshot(Vec::new()), |
| SubAgentSource::Other(crate::guardian::GUARDIAN_REVIEWER_NAME.to_string()), |
| ); |
| |
| analytics_events_client.track_notification(&ServerNotification::ThreadArchived( |
| ThreadArchivedNotification { |
| thread_id: prewarmed_thread_id.to_string(), |
| }, |
| )); |
| analytics_events_client.flush().await; |
| let events = server |
| .received_requests() |
| .await |
| .expect("analytics requests") |
| .into_iter() |
| .flat_map(|request| { |
| let payload: serde_json::Value = |
| serde_json::from_slice(&request.body).expect("valid analytics payload"); |
| payload["events"] |
| .as_array() |
| .expect("analytics events") |
| .clone() |
| }) |
| .collect::<Vec<_>>(); |
| let [initialization, archive] = events.as_slice() else { |
| panic!("expected one complete initialization and one archive: {events:?}"); |
| }; |
| assert_eq!(initialization, &event); |
| assert_eq!( |
| json!([ |
| archive["event_type"], |
| archive["event_params"]["thread_id"], |
| archive["event_params"]["thread_source"], |
| archive["event_params"]["parent_thread_id"], |
| ]), |
| json!([ |
| "codex_thread_archive_event", |
| prewarmed_thread_id.to_string(), |
| "guardian_review", |
| parent_thread_id.to_string(), |
| ]) |
| ); |
| } |
|
|
| async fn resolved_environments_for_configuration( |
| session_configuration: &SessionConfiguration, |
| environment_selections: &[TurnEnvironmentSelection], |
| ) -> (Arc<EnvironmentManager>, TurnEnvironmentSnapshot) { |
| let environment_manager = Arc::new(EnvironmentManager::default_for_tests()); |
| let turn_environments = ThreadEnvironments::new( |
| Arc::clone(&environment_manager), |
| default_user_shell(), |
| session_configuration.inferred_environment_config(), |
| ShellSnapshot::disabled(), |
| TurnEnvironmentSnapshot::default(), |
| false, |
| ); |
| turn_environments.update_selections( |
| environment_selections, |
| &session_configuration.inferred_environment_config(), |
| ); |
| (environment_manager, turn_environments.snapshot().await) |
| } |
|
|
| #[tokio::test] |
| async fn session_configuration_apply_client_metadata_preserves_permissions() { |
| let mut configuration = make_session_configuration_for_tests().await; |
| let workspace = tempfile::tempdir().expect("create workspace"); |
| let cwd = workspace.path().abs(); |
| configuration.legacy_fallback_cwd = cwd.clone(); |
| let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( |
| SandboxEnforcement::Managed, |
| &FileSystemSandboxPolicy::restricted(vec![ |
| FileSystemSandboxEntry::new( |
| FileSystemPath::Path { |
| path: cwd.join("writable").into(), |
| }, |
| FileSystemAccessMode::Write, |
| ), |
| FileSystemSandboxEntry::new( |
| FileSystemPath::Path { |
| path: cwd.join("writable/private").into(), |
| }, |
| FileSystemAccessMode::Deny, |
| ), |
| ]), |
| NetworkSandboxPolicy::Restricted, |
| ); |
| configuration |
| .set_permission_profile_for_tests(permission_profile) |
| .expect("set custom permission profile"); |
| let expected = configuration.thread_settings_snapshot(&[]); |
| let updated = configuration |
| .apply( |
| &SessionSettingsUpdate { |
| app_server_client_name: Some("codex-tui".to_string()), |
| app_server_client_version: Some("1.0.0".to_string()), |
| ..Default::default() |
| }, |
| &[], |
| ) |
| .expect("update client metadata"); |
|
|
| assert_eq!(updated.thread_settings_snapshot(&[]), expected); |
| assert_eq!( |
| ( |
| updated.app_server_client_name, |
| updated.app_server_client_version |
| ), |
| (Some("codex-tui".to_string()), Some("1.0.0".to_string())), |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd_only_update() { |
| let mut session_configuration = make_session_configuration_for_tests().await; |
| let workspace = tempfile::tempdir().expect("create temp dir"); |
| let project_root = workspace.path().join("project"); |
| let original_cwd = project_root.join("subdir"); |
| let docs_dir = original_cwd.join("docs"); |
| std::fs::create_dir_all(&docs_dir).expect("create docs dir"); |
| let project_root = project_root.abs(); |
| let docs_dir = docs_dir.abs(); |
|
|
| session_configuration.legacy_fallback_cwd = original_cwd.abs(); |
| let sandbox_policy = SandboxPolicy::WorkspaceWrite { |
| writable_roots: Vec::new(), |
| network_access: false, |
| exclude_tmpdir_env_var: true, |
| exclude_slash_tmp: true, |
| }; |
| let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ |
| FileSystemSandboxEntry { |
| path: FileSystemPath::Special { |
| value: FileSystemSpecialPath::project_roots( None), |
| }, |
| access: FileSystemAccessMode::Write, |
| missing_path_behavior: None, |
| }, |
| FileSystemSandboxEntry { |
| path: docs_dir.into(), |
| access: FileSystemAccessMode::Read, |
| missing_path_behavior: None, |
| }, |
| ]); |
| let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); |
| session_configuration |
| .set_permission_profile_for_tests( |
| PermissionProfile::from_runtime_permissions_with_enforcement( |
| SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), |
| &file_system_sandbox_policy, |
| network_sandbox_policy, |
| ), |
| ) |
| .expect("set permission profile"); |
| let expected_file_system_sandbox_policy = |
| file_system_sandbox_policy.materialize_project_roots_with_workspace_roots(&[]); |
|
|
| let updated = session_configuration |
| .apply( |
| &SessionSettingsUpdate { |
| environments: Some(TurnEnvironmentSelections::new(project_root, Vec::new())), |
| ..Default::default() |
| }, |
| &[], |
| ) |
| .expect("cwd-only update should succeed"); |
|
|
| assert_eq!( |
| updated.file_system_sandbox_policy(&[]), |
| expected_file_system_sandbox_policy |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn session_configuration_apply_permission_profile_preserves_existing_deny_read_entries() { |
| let mut session_configuration = make_session_configuration_for_tests().await; |
| let cwd = tempfile::tempdir().expect("create temp dir"); |
| session_configuration.legacy_fallback_cwd = cwd.path().abs(); |
|
|
| let workspace_policy = SandboxPolicy::new_workspace_write_policy(); |
| let deny_entry = FileSystemSandboxEntry { |
| path: FileSystemPath::GlobPattern { |
| pattern: "**/*.env".to_string(), |
| }, |
| access: FileSystemAccessMode::Deny, |
| missing_path_behavior: None, |
| }; |
| let mut existing_file_system_policy = |
| FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( |
| &workspace_policy, |
| session_configuration.cwd().as_path(), |
| ); |
| existing_file_system_policy.glob_scan_max_depth = Some(2); |
| existing_file_system_policy.entries.push(deny_entry.clone()); |
| session_configuration |
| .set_permission_profile_for_tests( |
| PermissionProfile::from_runtime_permissions_with_enforcement( |
| SandboxEnforcement::from_legacy_sandbox_policy(&workspace_policy), |
| &existing_file_system_policy, |
| NetworkSandboxPolicy::Restricted, |
| ), |
| ) |
| .expect("set permission profile"); |
|
|
| let requested_file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( |
| &workspace_policy, |
| session_configuration.cwd().as_path(), |
| ); |
| let permission_profile = codex_protocol::models::PermissionProfile::from_runtime_permissions( |
| &requested_file_system_policy, |
| NetworkSandboxPolicy::Restricted, |
| ); |
| let updated = session_configuration |
| .apply( |
| &SessionSettingsUpdate { |
| permission_profile: Some(permission_profile), |
| ..Default::default() |
| }, |
| &[], |
| ) |
| .expect("permission profile update should succeed"); |
|
|
| let mut expected_file_system_policy = |
| requested_file_system_policy.materialize_project_roots_with_workspace_roots(&[]); |
| expected_file_system_policy.glob_scan_max_depth = Some(2); |
| expected_file_system_policy.entries.push(deny_entry); |
| assert_eq!( |
| updated.file_system_sandbox_policy(&[]), |
| expected_file_system_policy |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn session_configuration_apply_permission_profile_accepts_direct_write_roots() { |
| let mut session_configuration = make_session_configuration_for_tests().await; |
| let cwd = tempfile::tempdir().expect("create cwd"); |
| session_configuration.legacy_fallback_cwd = cwd.path().abs(); |
| let external_write_dir = tempfile::tempdir().expect("create external write root"); |
| let external_write_path = AbsolutePathBuf::from_absolute_path( |
| codex_utils_absolute_path::canonicalize_preserving_symlinks(external_write_dir.path()) |
| .expect("canonical temp dir"), |
| ) |
| .expect("canonical temp dir should be absolute"); |
| let file_system_sandbox_policy = |
| FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { |
| path: FileSystemPath::Path { |
| path: external_write_path.clone().into(), |
| }, |
| access: FileSystemAccessMode::Write, |
| missing_path_behavior: None, |
| }]); |
| let permission_profile = PermissionProfile::from_runtime_permissions( |
| &file_system_sandbox_policy, |
| NetworkSandboxPolicy::Restricted, |
| ); |
|
|
| let updated = session_configuration |
| .apply( |
| &SessionSettingsUpdate { |
| permission_profile: Some(permission_profile.clone()), |
| ..Default::default() |
| }, |
| &[], |
| ) |
| .expect("permission profile update should accept direct runtime permissions"); |
|
|
| assert_eq!(updated.permission_profile(), permission_profile); |
| assert_eq!( |
| updated.file_system_sandbox_policy(&[]), |
| file_system_sandbox_policy |
| ); |
| assert_eq!( |
| updated.sandbox_policy(&[]), |
| SandboxPolicy::WorkspaceWrite { |
| writable_roots: vec![external_write_path], |
| network_access: false, |
| exclude_tmpdir_env_var: true, |
| exclude_slash_tmp: true, |
| } |
| ); |
| } |
|
|
| #[test_case::test_case(false; "ordinary_proxy")] |
| #[test_case::test_case(true; "credential_broker")] |
| #[tokio::test] |
| async fn active_profile_update_rebuilds_network_proxy_config( |
| credential_broker: bool, |
| ) -> std::io::Result<()> { |
| let codex_home = tempfile::tempdir().expect("create codex home"); |
| let cwd = tempfile::tempdir().expect("create cwd"); |
| let permissions = PermissionsToml { |
| entries: std::collections::BTreeMap::from([ |
| ( |
| "locked-down".to_string(), |
| PermissionProfileToml { |
| description: None, |
| extends: None, |
| workspace_roots: None, |
| filesystem: Some(FilesystemPermissionsToml { |
| glob_scan_max_depth: None, |
| entries: std::collections::BTreeMap::from([( |
| ":minimal".to_string(), |
| FilesystemPermissionToml::Access(FileSystemAccessMode::Read), |
| )]), |
| }), |
| network: None, |
| }, |
| ), |
| ( |
| "web-enabled".to_string(), |
| PermissionProfileToml { |
| description: None, |
| extends: None, |
| workspace_roots: None, |
| filesystem: Some(FilesystemPermissionsToml { |
| glob_scan_max_depth: None, |
| entries: std::collections::BTreeMap::from([( |
| ":minimal".to_string(), |
| FilesystemPermissionToml::Access(FileSystemAccessMode::Read), |
| )]), |
| }), |
| network: Some(NetworkToml { |
| enabled: Some(true), |
| proxy_url: Some("http://127.0.0.1:43128".to_string()), |
| enable_socks5: Some(false), |
| ..Default::default() |
| }), |
| }, |
| ), |
| ]), |
| }; |
| let base_config = ConfigToml { |
| features: Some( |
| toml::from_str(if credential_broker { |
| "network_proxy = { enabled = true, credential_broker = true }" |
| } else { |
| "network_proxy = true" |
| }) |
| .expect("valid features"), |
| ), |
| default_permissions: Some("locked-down".to_string()), |
| permissions: Some(permissions), |
| ..Default::default() |
| }; |
| std::fs::write( |
| codex_home.path().join(codex_config::CONFIG_TOML_FILE), |
| toml::to_string(&base_config).expect("serialize config"), |
| )?; |
| let locked_config = Arc::new( |
| ConfigBuilder::default() |
| .codex_home(codex_home.path().to_path_buf()) |
| .harness_overrides(ConfigOverrides { |
| cwd: Some(cwd.path().to_path_buf()), |
| ..Default::default() |
| }) |
| .build() |
| .await?, |
| ); |
| assert_ne!( |
| locked_config |
| .permissions |
| .network |
| .as_ref() |
| .map(crate::config::NetworkProxySpec::proxy_host_and_port) |
| .as_deref(), |
| Some("127.0.0.1:43128") |
| ); |
| let selected_config = ConfigBuilder::default() |
| .codex_home(codex_home.path().to_path_buf()) |
| .harness_overrides(ConfigOverrides { |
| cwd: Some(cwd.path().to_path_buf()), |
| default_permissions: Some("web-enabled".to_string()), |
| ..Default::default() |
| }) |
| .build() |
| .await?; |
|
|
| let mut session_configuration = make_session_configuration_for_tests().await; |
| session_configuration.permission_profile_state = |
| locked_config.permissions.permission_profile_state().clone(); |
| session_configuration.original_config_do_not_use = Arc::clone(&locked_config); |
|
|
| let updated = session_configuration |
| .apply( |
| &SessionSettingsUpdate { |
| permission_profile: Some(selected_config.permissions.permission_profile().clone()), |
| active_permission_profile: selected_config.permissions.active_permission_profile(), |
| ..Default::default() |
| }, |
| &[], |
| ) |
| .expect("active profile update should apply"); |
|
|
| let network = updated |
| .original_config_do_not_use |
| .permissions |
| .network |
| .as_ref() |
| .expect("selected profile proxy should become the session proxy config"); |
| assert_eq!(network.proxy_host_and_port(), "127.0.0.1:43128"); |
| assert!(!network.socks_enabled()); |
| assert_eq!(network.credential_broker_enabled(), credential_broker); |
| Ok(()) |
| } |
|
|
| #[cfg_attr(windows, ignore)] |
| #[tokio::test] |
| async fn new_default_turn_uses_config_aware_skills_for_role_overrides() { |
| let (session, _turn_context) = make_session_and_context().await; |
| let parent_config = session.get_config().await; |
| let codex_home = parent_config.codex_home.clone(); |
| let skill_dir = codex_home.join("skills").join("demo"); |
| std::fs::create_dir_all(&skill_dir).expect("create skill dir"); |
| let skill_path = skill_dir.join("SKILL.md"); |
| std::fs::write( |
| &skill_path, |
| "---\nname: demo-skill\ndescription: demo description\n---\n\n# Body\n", |
| ) |
| .expect("write skill"); |
|
|
| let skill_fs = session |
| .services |
| .turn_environments |
| .environment_manager() |
| .default_environment() |
| .map(|environment| environment.get_filesystem()) |
| .unwrap_or_else(|| std::sync::Arc::clone(&codex_exec_server::LOCAL_FS)); |
| let parent_snapshot = session |
| .services |
| .skills_service |
| .for_request() |
| .snapshot_for_cwd( |
| &crate::skills_load_input_from_config(&parent_config, Vec::new()), |
| true, |
| Some(Arc::clone(&skill_fs)), |
| ) |
| .await; |
| let parent_outcome = parent_snapshot.outcome(); |
| let parent_skill = parent_outcome |
| .skills |
| .iter() |
| .find(|skill| skill.name == "demo-skill") |
| .expect("demo skill should be discovered"); |
| assert_eq!(parent_outcome.is_skill_enabled(parent_skill), true); |
|
|
| let role_path = codex_home.join("skills-role.toml"); |
| std::fs::write( |
| &role_path, |
| format!( |
| r#"developer_instructions = "Stay focused" |
| |
| [[skills.config]] |
| path = "{}" |
| enabled = false |
| "#, |
| skill_path.display() |
| ), |
| ) |
| .expect("write role config"); |
|
|
| let mut child_config = (*parent_config).clone(); |
| child_config.agent_roles.insert( |
| "custom".to_string(), |
| crate::config::AgentRoleConfig { |
| description: None, |
| config_file: Some(role_path.to_path_buf()), |
| nickname_candidates: None, |
| }, |
| ); |
| crate::agent::role::apply_role_to_config(&mut child_config, Some("custom")) |
| .await |
| .expect("custom role should apply"); |
|
|
| { |
| let mut state = session.state.lock().await; |
| state.session_configuration.original_config_do_not_use = Arc::new(child_config); |
| } |
|
|
| let child_turn = session |
| .new_turn_with_default_settings("role-skill-turn".to_string(), Default::default()) |
| .await; |
| let skills_snapshot = child_turn.skills_snapshot(); |
| let child_skill = skills_snapshot |
| .outcome() |
| .skills |
| .iter() |
| .find(|skill| skill.name == "demo-skill") |
| .expect("demo skill should be discovered"); |
| assert_eq!( |
| skills_snapshot.outcome().is_skill_enabled(child_skill), |
| false |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_update() { |
| let mut session_configuration = make_session_configuration_for_tests().await; |
| let workspace = tempfile::tempdir().expect("create temp dir"); |
| let original_cwd = workspace.path().join("repo-a"); |
| let next_cwd = workspace.path().join("repo-b"); |
| std::fs::create_dir_all(&original_cwd).expect("create original cwd"); |
| std::fs::create_dir_all(&next_cwd).expect("create next cwd"); |
| let original_cwd = original_cwd.abs(); |
| let next_cwd = next_cwd.abs(); |
|
|
| session_configuration.legacy_fallback_cwd = original_cwd.clone(); |
| let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ |
| FileSystemSandboxEntry { |
| path: FileSystemPath::Special { |
| value: FileSystemSpecialPath::Root, |
| }, |
| access: FileSystemAccessMode::Read, |
| missing_path_behavior: None, |
| }, |
| FileSystemSandboxEntry { |
| path: FileSystemPath::Path { |
| path: original_cwd.clone().into(), |
| }, |
| access: FileSystemAccessMode::Write, |
| missing_path_behavior: None, |
| }, |
| ]); |
| session_configuration |
| .set_permission_profile_for_tests( |
| PermissionProfile::from_runtime_permissions_with_enforcement( |
| SandboxEnforcement::Managed, |
| &file_system_sandbox_policy, |
| NetworkSandboxPolicy::Restricted, |
| ), |
| ) |
| .expect("set permission profile"); |
|
|
| let updated = session_configuration |
| .apply( |
| &SessionSettingsUpdate { |
| environments: Some(TurnEnvironmentSelections::new(next_cwd.clone(), Vec::new())), |
| ..Default::default() |
| }, |
| &[], |
| ) |
| .expect("cwd-only update should succeed"); |
|
|
| assert_eq!( |
| updated.file_system_sandbox_policy(&[]), |
| file_system_sandbox_policy |
| ); |
| assert!( |
| updated |
| .file_system_sandbox_policy(&[]) |
| .can_write_local_path_with_cwd(original_cwd.as_path(), updated.cwd().as_path()), |
| "absolute grant to the old cwd must remain writable" |
| ); |
| assert!( |
| !updated |
| .file_system_sandbox_policy(&[]) |
| .can_write_local_path_with_cwd(next_cwd.as_path(), updated.cwd().as_path()), |
| "cwd-only update must not reinterpret an absolute old-cwd grant as :workspace_roots" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn settings_checkpoint_waits_for_accepted_settings_persistence() { |
| let (mut session, _turn_context, rx) = make_session_and_context_with_auth_and_config_and_rx( |
| CodexAuth::from_api_key("Test API Key"), |
| Vec::new(), |
| |config| { |
| config |
| .permissions |
| .set_permission_profile(PermissionProfile::workspace_write()) |
| .expect("set initial permission profile"); |
| }, |
| ) |
| .await; |
| let rollout_path = |
| attach_thread_persistence(Arc::get_mut(&mut session).expect("unique session")).await; |
| let refresh_guard = session |
| .managed_network_proxy_refresh_lock |
| .acquire() |
| .await |
| .expect("network refresh lock"); |
| let mut update = Box::pin(tokio::task::unconstrained(thread_settings::apply_update( |
| &session, |
| "settings".to_string(), |
| SessionSettingsUpdate { |
| step_settings: StepSettingsUpdate { |
| service_tier: Some(Some(ServiceTier::Fast.request_value().to_string())), |
| ..Default::default() |
| }, |
| permission_profile: Some(PermissionProfile::read_only()), |
| ..Default::default() |
| }, |
| ))); |
| |
| assert!(futures::poll!(update.as_mut()).is_pending()); |
| let committed = session.thread_settings_snapshot().await; |
| let history_before = session.clone_history().await; |
| let (window_number, window_ids) = session.advance_auto_compact_window().await; |
| let mut checkpoint = Box::pin(tokio::task::unconstrained( |
| session.replace_compacted_history( |
| vec![ResponseItemEnvelope::new(user_message("compacted history"))], |
| None, |
| None, |
| CompactedHistoryMetadata { |
| message: "summary".to_string(), |
| window_number, |
| window_ids, |
| compaction_response_id: None, |
| compaction_model_hash: None, |
| reviewer_compaction_hash: None, |
| }, |
| ), |
| )); |
| let mut settings_checkpoint = Box::pin(tokio::task::unconstrained( |
| session.checkpoint_thread_settings(), |
| )); |
| assert!(futures::poll!(checkpoint.as_mut()).is_pending()); |
| assert!(futures::poll!(settings_checkpoint.as_mut()).is_pending()); |
| assert_eq!( |
| session.clone_history().await.annotated_items(), |
| history_before.annotated_items() |
| ); |
|
|
| |
| |
| let restored = session |
| .update_settings(SessionSettingsUpdate { |
| step_settings: StepSettingsUpdate { |
| service_tier: Some(None), |
| ..Default::default() |
| }, |
| ..Default::default() |
| }) |
| .await |
| .expect("restore current settings") |
| .snapshot; |
| assert_ne!(committed, restored); |
| drop(refresh_guard); |
| update.await.expect("accepted settings update"); |
| checkpoint.await; |
| settings_checkpoint |
| .await |
| .expect("checkpoint current settings"); |
|
|
| let live_snapshots = std::iter::from_fn(|| rx.try_recv().ok()) |
| .filter_map(|event| match event.msg { |
| EventMsg::ThreadSettingsApplied(event) => Some(event.thread_settings), |
| _ => None, |
| }) |
| .collect::<Vec<_>>(); |
| assert_eq!(live_snapshots, vec![committed.clone()]); |
|
|
| let (items, _, _) = RolloutRecorder::load_rollout_items(&rollout_path) |
| .await |
| .expect("read persisted settings"); |
| let snapshots = items |
| .into_iter() |
| .filter_map(|item| match item { |
| RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) => { |
| Some((event.thread_id, event.thread_settings)) |
| } |
| _ => None, |
| }) |
| .collect::<Vec<_>>(); |
| assert_eq!( |
| snapshots, |
| vec![ |
| (Some(session.thread_id), committed), |
| (Some(session.thread_id), restored.clone()), |
| (Some(session.thread_id), restored), |
| ] |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn session_settings_commit_keeps_snapshot_across_postcommit_wait() { |
| let (session, _turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( |
| CodexAuth::from_api_key("Test API Key"), |
| Vec::new(), |
| |config| { |
| config |
| .permissions |
| .set_permission_profile(PermissionProfile::workspace_write()) |
| .expect("set initial permission profile"); |
| }, |
| ) |
| .await; |
| let refresh_guard = session |
| .managed_network_proxy_refresh_lock |
| .acquire() |
| .await |
| .expect("network refresh lock"); |
| let mut first_update = Box::pin(tokio::task::unconstrained(session.update_settings( |
| SessionSettingsUpdate { |
| step_settings: StepSettingsUpdate { |
| service_tier: Some(Some(ServiceTier::Fast.request_value().to_string())), |
| ..Default::default() |
| }, |
| permission_profile: Some(PermissionProfile::read_only()), |
| ..Default::default() |
| }, |
| ))); |
|
|
| |
| { |
| let mut context = std::task::Context::from_waker(futures::task::noop_waker_ref()); |
| assert!(std::future::Future::poll(first_update.as_mut(), &mut context).is_pending()); |
| } |
| let expected = session.thread_settings_snapshot().await; |
| let later_commit = session |
| .update_settings(SessionSettingsUpdate { |
| step_settings: StepSettingsUpdate { |
| service_tier: Some(None), |
| ..Default::default() |
| }, |
| ..Default::default() |
| }) |
| .await |
| .expect("later settings update"); |
| drop(refresh_guard); |
|
|
| let commit = first_update.await.expect("first settings update"); |
| let configuration_snapshot = commit |
| .configuration |
| .thread_settings_snapshot(&session.services.turn_environments.selections()); |
| assert_eq!(commit.snapshot, expected); |
| assert_eq!(configuration_snapshot, expected); |
| assert_ne!(later_commit.snapshot, expected); |
| } |
|
|
| #[tokio::test] |
| async fn session_update_settings_does_not_rewrite_sticky_environment_cwds() { |
| let (session, turn_context) = make_session_and_context().await; |
| #[allow(deprecated)] |
| let updated_cwd = turn_context.cwd.join("project"); |
| let current_environments = session.services.turn_environments.selections(); |
| let expected_environments = current_environments.clone(); |
| std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir"); |
|
|
| session |
| .update_settings(SessionSettingsUpdate { |
| environments: Some(TurnEnvironmentSelections::new( |
| updated_cwd.clone(), |
| current_environments, |
| )), |
| ..Default::default() |
| }) |
| .await |
| .expect("cwd update should succeed"); |
|
|
| let session_cwd = { |
| let state = session.state.lock().await; |
| state.session_configuration.cwd().clone() |
| }; |
| let stored_environments = session.services.turn_environments.selections(); |
| let config = session.get_config().await; |
| let next_turn = session.new_default_turn().await; |
|
|
| assert_eq!(session_cwd, updated_cwd); |
| assert_eq!(stored_environments, expected_environments); |
| #[allow(deprecated)] |
| let turn_cwd = turn_context.cwd.clone(); |
| #[allow(deprecated)] |
| let next_turn_cwd = next_turn.cwd.clone(); |
| assert_eq!(config.cwd, turn_cwd); |
| assert_eq!(next_turn_cwd, turn_cwd); |
| assert_eq!(next_turn.config.cwd, turn_cwd); |
| } |
|
|
| #[tokio::test] |
| async fn permission_profile_updates_apply_to_next_turn_environment() { |
| for apply_on_turn_start in [false, true] { |
| let (session, active_turn) = make_session_and_context().await; |
| let active_environment_config = active_turn |
| .environments |
| .primary() |
| .expect("active turn environment") |
| .config() |
| .clone(); |
| let profile_root = active_turn.config.cwd.join("profile-root"); |
| let active_profile = ActivePermissionProfile::read_only(); |
| let updates = SessionSettingsUpdate { |
| permission_profile: Some(PermissionProfile::read_only()), |
| active_permission_profile: Some(active_profile.clone()), |
| profile_workspace_roots: Some(vec![profile_root.clone().into()]), |
| ..Default::default() |
| }; |
|
|
| let next_turn = if apply_on_turn_start { |
| let (next_turn, _) = session |
| .new_turn_with_sub_id( |
| "permission-profile-update".to_string(), |
| updates, |
| Default::default(), |
| ) |
| .await |
| .expect("turn permission profile update should succeed"); |
| next_turn |
| } else { |
| session |
| .update_settings(updates) |
| .await |
| .expect("permission profile update should succeed"); |
| session.new_default_turn().await |
| }; |
| let next_environment = next_turn |
| .environments |
| .primary() |
| .expect("next turn environment"); |
| let mut expected_environment_config = active_environment_config.clone(); |
| expected_environment_config.permission_profile = |
| PermissionProfileSnapshot::active_with_profile_workspace_roots( |
| PermissionProfile::read_only(), |
| active_profile, |
| vec![profile_root.into()], |
| ); |
|
|
| assert_eq!(next_environment.config(), &expected_environment_config); |
| assert_eq!( |
| active_turn |
| .environments |
| .primary() |
| .expect("active turn environment") |
| .config(), |
| &active_environment_config |
| ); |
| } |
| } |
|
|
| #[tokio::test] |
| async fn relative_cwd_update_without_environments_resolves_under_session_cwd() { |
| let (session, _turn_context) = make_session_and_context().await; |
| let original_cwd = session |
| .state |
| .lock() |
| .await |
| .session_configuration |
| .cwd() |
| .clone(); |
| let updated_cwd = original_cwd.join("project"); |
| std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir"); |
|
|
| session |
| .update_settings(SessionSettingsUpdate { |
| environments: Some(TurnEnvironmentSelections::new( |
| updated_cwd.clone(), |
| Vec::new(), |
| )), |
| ..Default::default() |
| }) |
| .await |
| .expect("cwd update should succeed"); |
|
|
| let state = session.state.lock().await; |
| assert_eq!(state.session_configuration.cwd(), &updated_cwd); |
| assert!(session.services.turn_environments.selections().is_empty()); |
| } |
|
|
| #[tokio::test] |
| async fn environment_settings_preserve_explicit_primary_cwd() { |
| let (session, _turn_context) = make_session_and_context().await; |
| let (original_cwd, environment_cwd, environments) = { |
| let state = session.state.lock().await; |
| let original_cwd = state.session_configuration.cwd().clone(); |
| let environment_cwd = original_cwd.join("environment"); |
| let environments = vec![local(environment_cwd.clone())]; |
| (original_cwd, environment_cwd, environments) |
| }; |
| let updated_cwd = original_cwd.join("project"); |
| std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir"); |
|
|
| session |
| .update_settings(SessionSettingsUpdate { |
| environments: Some(TurnEnvironmentSelections::new( |
| updated_cwd.clone(), |
| environments, |
| )), |
| ..Default::default() |
| }) |
| .await |
| .expect("cwd update should succeed"); |
|
|
| let state = session.state.lock().await; |
| assert_eq!(state.session_configuration.cwd(), &updated_cwd); |
| assert_eq!( |
| session.services.turn_environments.selections()[0].cwd, |
| PathUri::from_abs_path(&environment_cwd) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn absolute_cwd_update_with_turn_environment_is_allowed() { |
| let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; |
| let absolute_cwd = { |
| let state = session.state.lock().await; |
| state.session_configuration.cwd().join("absolute-turn") |
| }; |
| std::fs::create_dir_all(absolute_cwd.as_path()).expect("create absolute turn dir"); |
|
|
| let (turn_context, _) = session |
| .new_turn_with_sub_id( |
| "sub-1".to_string(), |
| SessionSettingsUpdate { |
| environments: Some(TurnEnvironmentSelections::new( |
| absolute_cwd.clone(), |
| vec![local(absolute_cwd.clone())], |
| )), |
| ..Default::default() |
| }, |
| Default::default(), |
| ) |
| .await |
| .expect("absolute cwd with explicit environments should succeed"); |
|
|
| #[allow(deprecated)] |
| let turn_cwd = turn_context.cwd.clone(); |
| assert_eq!(turn_cwd, absolute_cwd); |
| assert_eq!(turn_context.config.cwd, absolute_cwd); |
| assert_eq!(turn_context.environments.turn_environments().count(), 1); |
| } |
|
|
| #[tokio::test] |
| async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { |
| let codex_home = tempfile::tempdir().expect("create temp dir"); |
| let mut config = build_test_config(codex_home.path()).await; |
| config |
| .features |
| .enable(Feature::ShellZshFork) |
| .expect("test config should allow shell_zsh_fork"); |
| config.zsh_path = None; |
| let config = Arc::new(config); |
|
|
| let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); |
| let models_manager = models_manager_with_provider( |
| config.codex_home.to_path_buf(), |
| auth_manager.clone(), |
| config.model_provider.clone(), |
| ); |
| let model = get_model_offline_for_tests(config.model.as_deref()); |
| let model_info = |
| construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); |
| let collaboration_mode = CollaborationMode { |
| mode: ModeKind::Default, |
| settings: Settings { |
| model, |
| reasoning_effort: config.model_reasoning_effort.clone(), |
| developer_instructions: None, |
| }, |
| }; |
| let session_configuration = SessionConfiguration { |
| provider: create_model_provider( |
| config.model_provider.clone(), |
| Some(Arc::clone(&auth_manager)), |
| ), |
| step_settings: Arc::new(StepSettings { |
| collaboration_mode, |
| reasoning_summary: config.model_reasoning_summary, |
| service_tier: None, |
| personality: config.personality, |
| approval_policy: config.permissions.approval_policy.clone(), |
| approvals_reviewer: config.approvals_reviewer, |
| }), |
| model_info_overrides: config.to_models_manager_config().into(), |
| developer_instructions: config.developer_instructions.clone(), |
| base_instructions: config |
| .base_instructions |
| .clone() |
| .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), |
| permission_profile_state: config.permissions.permission_profile_state().clone(), |
| allow_login_shell: config.permissions.allow_login_shell, |
| shell_environment_policy: config.permissions.shell_environment_policy.clone(), |
| windows_sandbox_level: WindowsSandboxLevel::from_config(&config), |
| windows_sandbox_type: config.permissions.windows_sandbox_type, |
| windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop, |
| use_legacy_landlock: config.features.use_legacy_landlock(), |
| legacy_fallback_cwd: config.cwd.clone(), |
| runtime_workspace_roots: config.workspace_roots.clone(), |
| codex_home: config.codex_home.clone(), |
| thread_name: None, |
| disabled_plugin_ids: Vec::new(), |
| original_config_do_not_use: Arc::clone(&config), |
| metrics_service_name: None, |
| app_server_client_name: None, |
| app_server_client_version: None, |
| trusted_guardian_reviewer: false, |
| session_source: SessionSource::Exec, |
| history_mode: Default::default(), |
| forked_from_thread_id: None, |
| parent_thread_id: None, |
| thread_source: None, |
| originator: "test_originator".to_string(), |
| dynamic_tools: Vec::new(), |
| user_shell_override: None, |
| }; |
|
|
| let (tx_event, _rx_event) = async_channel::unbounded(); |
| let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); |
| let plugins_manager = Arc::new(plugins_manager_for_config( |
| &config, |
| Arc::clone(&auth_manager), |
| )); |
| let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); |
| let skills_service = Arc::new(HostSkillsService::new( |
| config.codex_home.clone(), |
| true, |
| )); |
| let environment_manager = Arc::new(EnvironmentManager::default_for_tests()); |
| let result = Session::new( |
| None, |
| session_configuration, |
| &[], |
| Arc::clone(&config), |
| SessionInstructions::default(), |
| "11111111-1111-4111-8111-111111111111".to_string(), |
| auth_manager, |
| models_manager, |
| Arc::default(), |
| model_info, |
| Arc::new(ExecPolicyManager::default()), |
| tx_event, |
| agent_status_tx, |
| InitialHistory::New, |
| ForkPersistence::Copied, |
| SessionSource::Exec, |
| skills_service, |
| plugins_manager, |
| mcp_manager, |
| Arc::new(codex_code_mode::DisabledCodeModeSessionProvider), |
| Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), |
| codex_extension_api::ExtensionDataInit::default(), |
| ClientMcpExtensions::default(), |
| AgentControl::default(), |
| None, |
| environment_manager, |
| None, |
| None, |
| crate::passthrough_image_store(), |
| Arc::new(codex_thread_store::LocalThreadStore::new( |
| codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), |
| None, |
| )), |
| codex_rollout_trace::ThreadTraceContext::disabled(), |
| None, |
| None, |
| Some(config.multi_agent_version_from_features()), |
| GitEnrichmentPolicy::Fresh, |
| codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile, |
| ) |
| .await; |
|
|
| let err = match result { |
| Ok(_) => panic!("expected startup to fail"), |
| Err(err) => err, |
| }; |
| let msg = format!("{err:#}"); |
| assert!(msg.contains("zsh fork feature enabled, but no packaged zsh fork is available")); |
| } |
|
|
| async fn build_initial_context( |
| session: &Session, |
| turn_context: &Arc<TurnContext>, |
| ) -> Vec<ResponseItem> { |
| let step_context = StepContext::for_test(Arc::clone(turn_context)); |
| let world_state = session |
| .build_world_state_for_step(&step_context) |
| .await |
| .expect("world state should build"); |
| session |
| .build_initial_context_with_world_state(&step_context, &world_state) |
| .await |
| } |
|
|
| pub(crate) async fn build_world_state_from_turn_context( |
| session: &Session, |
| turn_context: &Arc<TurnContext>, |
| ) -> WorldState { |
| let step_context = StepContext::for_test(Arc::clone(turn_context)); |
| session |
| .build_world_state_for_step(&step_context) |
| .await |
| .expect("world state should build") |
| } |
|
|
| #[tokio::test] |
| async fn responses_metadata_uses_selected_harness_analytics_client() { |
| for enabled in [true, false] { |
| let (mut session, mut turn_context) = make_session_and_context().await; |
| session.services.analytics_events_client = AnalyticsEventsClient::new( |
| Arc::clone(&session.services.auth_manager), |
| turn_context.config.chatgpt_base_url.clone(), |
| Some(enabled), |
| ); |
| Arc::make_mut(&mut turn_context.config).analytics_enabled = Some(!enabled); |
| let step_context = StepContext::for_test(Arc::new(turn_context)); |
| let metadata = session |
| .responses_metadata(&step_context, CodexResponsesRequestKind::Turn) |
| .await; |
| assert_eq!(metadata.analytics_enabled, Some(enabled)); |
| } |
| } |
|
|
| |
| pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { |
| let (tx_event, _rx_event) = async_channel::unbounded(); |
| let codex_home = tempfile::tempdir().expect("create temp dir"); |
| let config = build_test_config(codex_home.path()).await; |
| let config = Arc::new(config); |
| let thread_id = ThreadId::default(); |
| let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); |
| let models_manager = models_manager_with_provider( |
| config.codex_home.to_path_buf(), |
| auth_manager.clone(), |
| config.model_provider.clone(), |
| ); |
| let agent_control = AgentControl::default(); |
| let exec_policy = Arc::new(ExecPolicyManager::default()); |
| let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); |
| let model = get_model_offline_for_tests(config.model.as_deref()); |
| let model_info = |
| construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); |
| let reasoning_effort = config.model_reasoning_effort.clone(); |
| let collaboration_mode = CollaborationMode { |
| mode: ModeKind::Default, |
| settings: Settings { |
| model, |
| reasoning_effort, |
| developer_instructions: None, |
| }, |
| }; |
| let default_environments = vec![local(config.cwd.clone())]; |
| let session_configuration = SessionConfiguration { |
| provider: create_model_provider( |
| config.model_provider.clone(), |
| Some(Arc::clone(&auth_manager)), |
| ), |
| step_settings: Arc::new(StepSettings { |
| collaboration_mode, |
| reasoning_summary: config.model_reasoning_summary, |
| service_tier: None, |
| personality: config.personality, |
| approval_policy: config.permissions.approval_policy.clone(), |
| approvals_reviewer: config.approvals_reviewer, |
| }), |
| model_info_overrides: config.to_models_manager_config().into(), |
| developer_instructions: config.developer_instructions.clone(), |
| base_instructions: config |
| .base_instructions |
| .clone() |
| .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), |
| permission_profile_state: config.permissions.permission_profile_state().clone(), |
| allow_login_shell: config.permissions.allow_login_shell, |
| shell_environment_policy: config.permissions.shell_environment_policy.clone(), |
| windows_sandbox_level: WindowsSandboxLevel::from_config(&config), |
| windows_sandbox_type: config.permissions.windows_sandbox_type, |
| windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop, |
| use_legacy_landlock: config.features.use_legacy_landlock(), |
| legacy_fallback_cwd: config.cwd.clone(), |
| runtime_workspace_roots: config.workspace_roots.clone(), |
| codex_home: config.codex_home.clone(), |
| thread_name: None, |
| disabled_plugin_ids: Vec::new(), |
| original_config_do_not_use: Arc::clone(&config), |
| metrics_service_name: None, |
| app_server_client_name: None, |
| app_server_client_version: None, |
| trusted_guardian_reviewer: false, |
| session_source: SessionSource::Exec, |
| history_mode: Default::default(), |
| forked_from_thread_id: None, |
| parent_thread_id: None, |
| thread_source: None, |
| originator: "test_originator".to_string(), |
| dynamic_tools: Vec::new(), |
| user_shell_override: None, |
| }; |
| let session_telemetry = session_telemetry( |
| thread_id, |
| config.as_ref(), |
| &model_info, |
| session_configuration.session_source.clone(), |
| ); |
|
|
| let state = SessionState::new(session_configuration.clone()); |
| let (environment_manager, resolved_environments) = |
| resolved_environments_for_configuration(&session_configuration, &default_environments) |
| .await; |
| let resolved_turn_environments = resolved_environments.clone(); |
| let turn_environments = Arc::new(ThreadEnvironments::new( |
| environment_manager, |
| default_user_shell(), |
| session_configuration.inferred_environment_config(), |
| ShellSnapshot::disabled(), |
| resolved_environments, |
| false, |
| )); |
| let environment = Arc::clone( |
| &resolved_turn_environments |
| .primary() |
| .expect("primary environment") |
| .environment, |
| ); |
| let plugins_manager = Arc::new(plugins_manager_for_config( |
| &config, |
| Arc::clone(&auth_manager), |
| )); |
| let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); |
| let skills_service = Arc::new(HostSkillsService::new( |
| config.codex_home.clone(), |
| true, |
| )); |
| let network_approval = Arc::new(NetworkApprovalService::default()); |
| let mcp_runtime = Arc::new(codex_mcp::McpRuntime::empty(config.prefix_mcp_tool_names())); |
| let executed_tool_calls = |
| crate::state::ExecutedToolCalls::new(&config.features, &InitialHistory::Forked(Vec::new())); |
| let (hooks, async_hook_results) = Hooks::new( |
| HooksConfig { |
| legacy_notify_argv: config.notify.clone(), |
| ..HooksConfig::default() |
| }, |
| thread_id, |
| Arc::new(CoreHookMcpExecutor { |
| runtime: Arc::clone(&mcp_runtime), |
| thread_id, |
| }), |
| ) |
| .expect("initialize test hooks"); |
| let services = SessionServices { |
| mcp_runtime, |
| mcp_handler_cache: Default::default(), |
| unified_exec_manager: UnifiedExecProcessManager::new( |
| config.background_terminal_max_timeout, |
| ), |
| elicitations: crate::elicitation::ElicitationService::new(), |
| shell_zsh_path: None, |
| main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), |
| analytics_events_client: AnalyticsEventsClient::new( |
| Arc::clone(&auth_manager), |
| config.chatgpt_base_url.trim_end_matches('/').to_string(), |
| config.analytics_enabled, |
| ), |
| hooks: arc_swap::ArcSwap::from_pointee(hooks), |
| rollout_thread_trace: codex_rollout_trace::ThreadTraceContext::disabled(), |
| user_shell: Arc::new(default_user_shell()), |
| show_raw_agent_reasoning: config.show_raw_agent_reasoning, |
| exec_policy, |
| auth_manager: auth_manager.clone(), |
| openai_file_upload_client_pool: RouteAwareClientPool::new_without_request_logging( |
| config.http_client_factory(), |
| ClientRouteClass::Api, |
| ) |
| .with_legacy_custom_ca_fallback(), |
| session_telemetry: session_telemetry.clone(), |
| models_manager: Arc::clone(&models_manager), |
| git_root_discovery: Arc::default(), |
| tool_approvals: Mutex::new(ApprovalStore::default()), |
| runtime_handle: tokio::runtime::Handle::current(), |
| skills_service, |
| agents_md_manager: Arc::new(AgentsMdManager::new(SessionInstructions::default())), |
| plugins_manager, |
| mcp_manager, |
| extensions: Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), |
| session_extension_data: codex_extension_api::ExtensionData::new( |
| agent_control.session_id().to_string(), |
| ), |
| thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()), |
| selected_capability_roots: Vec::new(), |
| mcp_thread_init: codex_extension_api::ExtensionDataInit::default(), |
| client_mcp_extensions: ClientMcpExtensions::default(), |
| agent_control, |
| network_proxy: arc_swap::ArcSwapOption::from(None), |
| network_proxy_audit_metadata: crate::config::NetworkProxyAuditMetadata::default(), |
| managed_network_requirements_configured: false, |
| network_approval: Arc::clone(&network_approval), |
| state_db: None, |
| live_thread: None, |
| image_store: crate::passthrough_image_store(), |
| thread_store: Arc::new(codex_thread_store::LocalThreadStore::new( |
| codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), |
| None, |
| )), |
| attestation_provider: None, |
| time_provider: Arc::new(crate::current_time::SystemTimeProvider), |
| model_client: ModelClient::new( |
| Some(auth_manager.clone()), |
| AgentIdentityAuthPolicy::JwtOnly, |
| thread_id, |
| session_configuration.provider.info().clone(), |
| session_configuration.session_source.clone(), |
| session_configuration.originator.clone(), |
| config.model_verbosity, |
| config.features.enabled(Feature::ContentItemKinds), |
| config.features.enabled(Feature::EnableRequestCompression), |
| config.features.enabled(Feature::RuntimeMetrics), |
| Session::build_model_client_beta_features_header(config.as_ref()), |
| |
| config |
| .features |
| .enabled(Feature::ConcurrentReasoningSummaries), |
| None, |
| config.http_client_factory(), |
| config.workspace_routing_context(), |
| ), |
| executed_tool_calls: executed_tool_calls.clone(), |
| code_mode_service: crate::tools::code_mode::CodeModeService::new( |
| thread_id, |
| Arc::new(codex_code_mode::DisabledCodeModeSessionProvider), |
| &config.code_mode, |
| executed_tool_calls, |
| ), |
| tool_search_handler_cache: Default::default(), |
| turn_environments: Arc::clone(&turn_environments), |
| }; |
|
|
| let session = Session { |
| thread_id, |
| installation_id: "11111111-1111-4111-8111-111111111111".to_string(), |
| tx_event, |
| agent_status: agent_status_tx, |
| state: Mutex::new(state), |
| thread_settings_persistence: Semaphore::new( 1), |
| managed_network_proxy_refresh_lock: Semaphore::new( 1), |
| features: config.features.clone(), |
| guardian_context_mode: GuardianContextMode::from_features(&config.features), |
| isolation: codex_extension_api::SessionIsolation::Inherit, |
| allowed_tools: None, |
| windows_sandbox_proxy_settings_mode: |
| codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile, |
| multi_agent_version: OnceLock::from(config.multi_agent_version_from_features()), |
| mcp_refresh: McpRefresh::new(), |
| mcp_tool_approval_metadata: Default::default(), |
| mcp_elicitation_reviewer_handle: OnceLock::new(), |
| mcp_elicitation_lifecycle_handle: OnceLock::new(), |
| mcp_prewarm_tx: async_channel::bounded(1).0, |
| mcp_prewarm_shutdown: CancellationToken::new(), |
| mcp_prewarm_task: std::sync::Mutex::new(None), |
| conversation: Arc::new(RealtimeConversationManager::new()), |
| realtime_history: None, |
| active_turn: Mutex::new(None), |
| async_hook_results, |
| input_queue: super::input_queue::InputQueue::new(), |
| services, |
| git_enrichment_policy: GitEnrichmentPolicy::Fresh, |
| fork_persistence: ForkPersistence::Copied, |
| forked_from_ordinal_exclusive: None, |
| next_internal_sub_id: AtomicU64::new(0), |
| }; |
| let per_turn_config = |
| session.build_per_turn_config(&session_configuration, session_configuration.cwd().clone()); |
| let plugins_input = per_turn_config.plugins_config_input(); |
| let plugin_outcome = session |
| .services |
| .plugins_manager |
| .plugins_for_config(&plugins_input) |
| .await; |
| let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); |
| let plugin_skill_snapshots = session |
| .services |
| .plugins_manager |
| .plugin_skill_snapshots_for_config(&plugins_input); |
| let skills_input = |
| crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots) |
| .with_plugin_skill_snapshots(plugin_skill_snapshots); |
| let skill_fs = environment.get_filesystem(); |
| let skills_snapshot = session |
| .services |
| .skills_service |
| .snapshot_for_config(&skills_input, Some(Arc::clone(&skill_fs))) |
| .await; |
| let turn_context = Session::make_turn_context( |
| thread_id, |
| SessionId::from(thread_id), |
| Some(Arc::clone(&auth_manager)), |
| &session_telemetry, |
| session_configuration.provider.clone(), |
| &session_configuration, |
| config.multi_agent_version_from_features(), |
| session.services.user_shell.as_ref(), |
| session.services.shell_zsh_path.as_ref(), |
| session.services.main_execve_wrapper_exe.as_ref(), |
| per_turn_config, |
| Arc::new(super::step_settings::ResolvedStepSettings::new( |
| Arc::clone(&session_configuration.step_settings), |
| Arc::new(model_info), |
| config.features.enabled(Feature::FastMode), |
| )), |
| &models_manager, |
| None, |
| resolved_turn_environments, |
| session_configuration.cwd().clone(), |
| "turn_id".to_string(), |
| skills_snapshot, |
| ); |
| session.mark_mcp_runtime_dirty(); |
| crate::guardian::test_host::install(&session, &turn_context.config); |
| (session, turn_context) |
| } |
|
|
| async fn make_session_with_config( |
| mutator: impl FnOnce(&mut Config), |
| ) -> anyhow::Result<Arc<Session>> { |
| let (session, _rx_event) = make_session_with_config_and_rx(mutator).await?; |
| Ok(session) |
| } |
|
|
| async fn load_latest_config_for_session(session: &Session) -> Config { |
| let config = session.get_config().await; |
| ConfigBuilder::default() |
| .codex_home(config.codex_home.to_path_buf()) |
| .fallback_cwd(Some(config.cwd.to_path_buf())) |
| .build() |
| .await |
| .expect("load latest config for session") |
| } |
|
|
| async fn make_session_with_config_and_rx( |
| mutator: impl FnOnce(&mut Config), |
| ) -> anyhow::Result<(Arc<Session>, async_channel::Receiver<Event>)> { |
| let codex_home = tempfile::tempdir().expect("create temp dir"); |
| let mut config = build_test_config(codex_home.path()).await; |
| mutator(&mut config); |
| let config = Arc::new(config); |
| let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); |
| let models_manager = models_manager_with_provider( |
| config.codex_home.to_path_buf(), |
| auth_manager.clone(), |
| config.model_provider.clone(), |
| ); |
| let model = get_model_offline_for_tests(config.model.as_deref()); |
| let model_info = |
| construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); |
| let collaboration_mode = CollaborationMode { |
| mode: ModeKind::Default, |
| settings: Settings { |
| model, |
| reasoning_effort: config.model_reasoning_effort.clone(), |
| developer_instructions: None, |
| }, |
| }; |
| let default_environments = vec![local(config.cwd.clone())]; |
| let session_configuration = SessionConfiguration { |
| provider: create_model_provider( |
| config.model_provider.clone(), |
| Some(Arc::clone(&auth_manager)), |
| ), |
| step_settings: Arc::new(StepSettings { |
| collaboration_mode, |
| reasoning_summary: config.model_reasoning_summary, |
| service_tier: None, |
| personality: config.personality, |
| approval_policy: config.permissions.approval_policy.clone(), |
| approvals_reviewer: config.approvals_reviewer, |
| }), |
| model_info_overrides: config.to_models_manager_config().into(), |
| developer_instructions: config.developer_instructions.clone(), |
| base_instructions: config |
| .base_instructions |
| .clone() |
| .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), |
| permission_profile_state: config.permissions.permission_profile_state().clone(), |
| allow_login_shell: config.permissions.allow_login_shell, |
| shell_environment_policy: config.permissions.shell_environment_policy.clone(), |
| windows_sandbox_level: WindowsSandboxLevel::from_config(&config), |
| windows_sandbox_type: config.permissions.windows_sandbox_type, |
| windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop, |
| use_legacy_landlock: config.features.use_legacy_landlock(), |
| legacy_fallback_cwd: config.cwd.clone(), |
| runtime_workspace_roots: config.workspace_roots.clone(), |
| codex_home: config.codex_home.clone(), |
| thread_name: None, |
| disabled_plugin_ids: Vec::new(), |
| original_config_do_not_use: Arc::clone(&config), |
| metrics_service_name: None, |
| app_server_client_name: None, |
| app_server_client_version: None, |
| trusted_guardian_reviewer: false, |
| session_source: SessionSource::Exec, |
| history_mode: Default::default(), |
| forked_from_thread_id: None, |
| parent_thread_id: None, |
| thread_source: None, |
| originator: "test_originator".to_string(), |
| dynamic_tools: Vec::new(), |
| user_shell_override: None, |
| }; |
|
|
| let (tx_event, rx_event) = async_channel::unbounded(); |
| let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); |
| let plugins_manager = Arc::new(plugins_manager_for_config( |
| &config, |
| Arc::clone(&auth_manager), |
| )); |
| let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); |
| let skills_service = Arc::new(HostSkillsService::new( |
| config.codex_home.clone(), |
| true, |
| )); |
| let environment_manager = Arc::new(EnvironmentManager::default_for_tests()); |
|
|
| let session = Session::new( |
| None, |
| session_configuration, |
| &default_environments, |
| Arc::clone(&config), |
| SessionInstructions::default(), |
| "11111111-1111-4111-8111-111111111111".to_string(), |
| auth_manager, |
| models_manager, |
| Arc::default(), |
| model_info, |
| Arc::new(ExecPolicyManager::default()), |
| tx_event, |
| agent_status_tx, |
| InitialHistory::New, |
| ForkPersistence::Copied, |
| SessionSource::Exec, |
| skills_service, |
| plugins_manager, |
| mcp_manager, |
| Arc::new(codex_code_mode::DisabledCodeModeSessionProvider), |
| Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), |
| codex_extension_api::ExtensionDataInit::default(), |
| ClientMcpExtensions::default(), |
| AgentControl::default(), |
| None, |
| environment_manager, |
| None, |
| None, |
| crate::passthrough_image_store(), |
| Arc::new(codex_thread_store::LocalThreadStore::new( |
| codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), |
| None, |
| )), |
| codex_rollout_trace::ThreadTraceContext::disabled(), |
| None, |
| None, |
| Some(config.multi_agent_version_from_features()), |
| GitEnrichmentPolicy::Fresh, |
| codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile, |
| ) |
| .await?; |
|
|
| Ok((session, rx_event)) |
| } |
|
|
| async fn make_session_with_history_source_and_agent_control_and_rx( |
| initial_history: InitialHistory, |
| session_source: SessionSource, |
| agent_control: AgentControl, |
| ) -> anyhow::Result<(Arc<Session>, async_channel::Receiver<Event>)> { |
| let codex_home = tempfile::tempdir().expect("create temp dir"); |
| let mut config = build_test_config(codex_home.path()).await; |
| config.ephemeral = true; |
| let config = Arc::new(config); |
| let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); |
| let models_manager = models_manager_with_provider( |
| config.codex_home.to_path_buf(), |
| auth_manager.clone(), |
| config.model_provider.clone(), |
| ); |
| let model = get_model_offline_for_tests(config.model.as_deref()); |
| let model_info = |
| construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); |
| let collaboration_mode = CollaborationMode { |
| mode: ModeKind::Default, |
| settings: Settings { |
| model, |
| reasoning_effort: config.model_reasoning_effort.clone(), |
| developer_instructions: None, |
| }, |
| }; |
| let default_environments = vec![local(config.cwd.clone())]; |
| let session_configuration = SessionConfiguration { |
| provider: create_model_provider( |
| config.model_provider.clone(), |
| Some(Arc::clone(&auth_manager)), |
| ), |
| step_settings: Arc::new(StepSettings { |
| collaboration_mode, |
| reasoning_summary: config.model_reasoning_summary, |
| service_tier: None, |
| personality: config.personality, |
| approval_policy: config.permissions.approval_policy.clone(), |
| approvals_reviewer: config.approvals_reviewer, |
| }), |
| model_info_overrides: config.to_models_manager_config().into(), |
| developer_instructions: config.developer_instructions.clone(), |
| base_instructions: config |
| .base_instructions |
| .clone() |
| .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), |
| permission_profile_state: config.permissions.permission_profile_state().clone(), |
| allow_login_shell: config.permissions.allow_login_shell, |
| shell_environment_policy: config.permissions.shell_environment_policy.clone(), |
| windows_sandbox_level: WindowsSandboxLevel::from_config(&config), |
| windows_sandbox_type: config.permissions.windows_sandbox_type, |
| windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop, |
| use_legacy_landlock: config.features.use_legacy_landlock(), |
| legacy_fallback_cwd: config.cwd.clone(), |
| runtime_workspace_roots: config.workspace_roots.clone(), |
| codex_home: config.codex_home.clone(), |
| thread_name: None, |
| disabled_plugin_ids: Vec::new(), |
| original_config_do_not_use: Arc::clone(&config), |
| metrics_service_name: None, |
| app_server_client_name: None, |
| app_server_client_version: None, |
| trusted_guardian_reviewer: false, |
| session_source: session_source.clone(), |
| history_mode: Default::default(), |
| forked_from_thread_id: None, |
| parent_thread_id: None, |
| thread_source: None, |
| originator: "test_originator".to_string(), |
| dynamic_tools: Vec::new(), |
| user_shell_override: None, |
| }; |
|
|
| let (tx_event, rx_event) = async_channel::unbounded(); |
| let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); |
| let plugins_manager = Arc::new(plugins_manager_for_config( |
| &config, |
| Arc::clone(&auth_manager), |
| )); |
| let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); |
| let skills_service = Arc::new(HostSkillsService::new( |
| config.codex_home.clone(), |
| true, |
| )); |
| let environment_manager = Arc::new(EnvironmentManager::default_for_tests()); |
|
|
| let session = Session::new( |
| None, |
| session_configuration, |
| &default_environments, |
| Arc::clone(&config), |
| SessionInstructions::default(), |
| "11111111-1111-4111-8111-111111111111".to_string(), |
| auth_manager, |
| models_manager, |
| Arc::default(), |
| model_info, |
| Arc::new(ExecPolicyManager::default()), |
| tx_event, |
| agent_status_tx, |
| initial_history, |
| ForkPersistence::Copied, |
| session_source, |
| skills_service, |
| plugins_manager, |
| mcp_manager, |
| Arc::new(codex_code_mode::DisabledCodeModeSessionProvider), |
| Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), |
| codex_extension_api::ExtensionDataInit::default(), |
| ClientMcpExtensions::default(), |
| agent_control, |
| None, |
| environment_manager, |
| None, |
| None, |
| crate::passthrough_image_store(), |
| Arc::new(codex_thread_store::LocalThreadStore::new( |
| codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), |
| Some( |
| codex_state::StateRuntime::init( |
| config.sqlite.clone(), |
| config.model_provider_id.clone(), |
| ) |
| .await |
| .expect("state db should initialize"), |
| ), |
| )), |
| codex_rollout_trace::ThreadTraceContext::disabled(), |
| None, |
| None, |
| Some(config.multi_agent_version_from_features()), |
| GitEnrichmentPolicy::Fresh, |
| codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile, |
| ) |
| .await?; |
|
|
| Ok((session, rx_event)) |
| } |
|
|
| #[tokio::test] |
| async fn resumed_root_session_uses_thread_id_as_session_id() { |
| let thread_id = ThreadId::new(); |
| let (session, rx_event) = make_session_with_history_source_and_agent_control_and_rx( |
| InitialHistory::Resumed(ResumedHistory { |
| conversation_id: thread_id, |
| history: Arc::new(Vec::new()), |
| rollout_path: None, |
| }), |
| SessionSource::Exec, |
| AgentControl::default(), |
| ) |
| .await |
| .expect("resume should succeed"); |
|
|
| assert_eq!(session.thread_id(), thread_id); |
| assert_eq!(session.session_id(), SessionId::from(thread_id)); |
|
|
| let event = rx_event.recv().await.expect("session configured event"); |
| let EventMsg::SessionConfigured(event) = event.msg else { |
| panic!("expected session configured event"); |
| }; |
| assert_eq!(event.session_id, SessionId::from(thread_id)); |
| assert_eq!(event.thread_id, thread_id); |
| } |
|
|
| #[tokio::test] |
| async fn resumed_subagent_session_restores_persisted_session_id() { |
| let parent_thread_id = ThreadId::new(); |
| let parent_session_id = SessionId::from(parent_thread_id); |
| let thread_id = ThreadId::new(); |
| let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: None, |
| }); |
| let (session, rx_event) = make_session_with_history_source_and_agent_control_and_rx( |
| InitialHistory::Resumed(ResumedHistory { |
| conversation_id: thread_id, |
| history: Arc::new(vec![RolloutItem::SessionMeta(SessionMetaLine { |
| meta: SessionMeta { |
| session_id: parent_session_id, |
| id: thread_id, |
| source: session_source.clone(), |
| ..SessionMeta::default() |
| }, |
| git: None, |
| })]), |
| rollout_path: None, |
| }), |
| session_source, |
| AgentControl::default(), |
| ) |
| .await |
| .expect("resume should succeed"); |
|
|
| assert_eq!(session.thread_id(), thread_id); |
| assert_eq!(session.session_id(), parent_session_id); |
|
|
| let event = rx_event.recv().await.expect("session configured event"); |
| let EventMsg::SessionConfigured(event) = event.msg else { |
| panic!("expected session configured event"); |
| }; |
| assert_eq!(event.session_id, parent_session_id); |
| assert_eq!(event.thread_id, thread_id); |
| } |
|
|
| #[tokio::test] |
| async fn resumed_copied_fork_ignores_source_history_base() { |
| let ancestor_thread_id = ThreadId::new(); |
| let parent_thread_id = ThreadId::new(); |
| let thread_id = ThreadId::new(); |
| let history = vec![ |
| RolloutItem::SessionMeta(SessionMetaLine { |
| meta: SessionMeta { |
| id: thread_id, |
| session_id: SessionId::from(thread_id), |
| forked_from_id: Some(parent_thread_id), |
| ..SessionMeta::default() |
| }, |
| git: None, |
| }), |
| RolloutItem::SessionMeta(SessionMetaLine { |
| meta: SessionMeta { |
| id: parent_thread_id, |
| session_id: SessionId::from(parent_thread_id), |
| forked_from_id: Some(ancestor_thread_id), |
| history_base: Some(HistoryPosition { |
| thread_id: ancestor_thread_id, |
| end_ordinal_exclusive: 42, |
| end_byte_offset: 100, |
| }), |
| ..SessionMeta::default() |
| }, |
| git: None, |
| }), |
| ]; |
| let (session, _rx_event) = make_session_with_history_source_and_agent_control_and_rx( |
| InitialHistory::Resumed(ResumedHistory { |
| conversation_id: thread_id, |
| history: Arc::new(history), |
| rollout_path: None, |
| }), |
| SessionSource::Exec, |
| AgentControl::default(), |
| ) |
| .await |
| .expect("resume should succeed"); |
|
|
| assert_eq!(session.thread_id(), thread_id); |
| assert_eq!(session.forked_from_ordinal_exclusive, None); |
| } |
|
|
| #[tokio::test] |
| async fn notify_request_permissions_response_ignores_unmatched_call_id() { |
| let (session, _turn_context) = make_session_and_context().await; |
| *session.active_turn.lock().await = Some(ActiveTurn::default()); |
|
|
| session |
| .notify_request_permissions_response( |
| "missing", |
| codex_protocol::request_permissions::RequestPermissionsResponse { |
| permissions: RequestPermissionProfile { |
| network: Some(codex_protocol::models::NetworkPermissions { |
| enabled: Some(true), |
| }), |
| ..RequestPermissionProfile::default() |
| }, |
| scope: PermissionGrantScope::Turn, |
| strict_auto_review: false, |
| }, |
| ) |
| .await; |
|
|
| assert_eq!( |
| session |
| .granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID) |
| .await, |
| None |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn record_granted_request_permissions_for_turn_uses_originating_turn() { |
| let (session, _turn_context) = make_session_and_context().await; |
| let originating_active_turn = ActiveTurn::default(); |
| let originating_turn_state = Arc::clone(&originating_active_turn.turn_state); |
| *session.active_turn.lock().await = Some(originating_active_turn); |
|
|
| let current_active_turn = ActiveTurn::default(); |
| let current_turn_state = Arc::clone(¤t_active_turn.turn_state); |
| *session.active_turn.lock().await = Some(current_active_turn); |
|
|
| let requested_permissions = RequestPermissionProfile { |
| network: Some(codex_protocol::models::NetworkPermissions { |
| enabled: Some(true), |
| }), |
| ..RequestPermissionProfile::default() |
| }; |
| session |
| .record_granted_request_permissions_for_turn( |
| &codex_protocol::request_permissions::RequestPermissionsResponse { |
| permissions: requested_permissions.clone(), |
| scope: PermissionGrantScope::Turn, |
| strict_auto_review: false, |
| }, |
| codex_exec_server::LOCAL_ENVIRONMENT_ID, |
| Some(&originating_turn_state), |
| ) |
| .await; |
|
|
| assert_eq!( |
| originating_turn_state |
| .lock() |
| .await |
| .granted_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID), |
| Some(requested_permissions.into()) |
| ); |
| assert_eq!( |
| current_turn_state |
| .lock() |
| .await |
| .granted_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID), |
| None |
| ); |
| assert_eq!( |
| session |
| .granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID) |
| .await, |
| None |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn request_permission_grants_are_environment_keyed() { |
| let (session, _turn_context) = make_session_and_context().await; |
| let originating_active_turn = ActiveTurn::default(); |
| let originating_turn_state = Arc::clone(&originating_active_turn.turn_state); |
| *session.active_turn.lock().await = Some(originating_active_turn); |
|
|
| let requested_permissions = RequestPermissionProfile { |
| network: Some(codex_protocol::models::NetworkPermissions { |
| enabled: Some(true), |
| }), |
| ..RequestPermissionProfile::default() |
| }; |
| session |
| .record_granted_request_permissions_for_turn( |
| &codex_protocol::request_permissions::RequestPermissionsResponse { |
| permissions: requested_permissions.clone(), |
| scope: PermissionGrantScope::Turn, |
| strict_auto_review: false, |
| }, |
| "remote", |
| Some(&originating_turn_state), |
| ) |
| .await; |
|
|
| { |
| let turn_state = originating_turn_state.lock().await; |
| assert_eq!( |
| turn_state.granted_permissions("remote"), |
| Some(requested_permissions.clone().into()) |
| ); |
| assert_eq!(turn_state.granted_permissions("local"), None); |
| } |
|
|
| session |
| .record_granted_request_permissions_for_turn( |
| &codex_protocol::request_permissions::RequestPermissionsResponse { |
| permissions: requested_permissions.clone(), |
| scope: PermissionGrantScope::Session, |
| strict_auto_review: false, |
| }, |
| "remote", |
| None, |
| ) |
| .await; |
|
|
| assert_eq!( |
| session.granted_session_permissions("remote").await, |
| Some(requested_permissions.into()) |
| ); |
| assert_eq!(session.granted_session_permissions("local").await, None); |
| } |
|
|
| #[tokio::test] |
| async fn enable_strict_auto_review_for_turn_uses_originating_turn() { |
| let (session, _turn_context) = make_session_and_context().await; |
| let originating_active_turn = ActiveTurn::default(); |
| let originating_turn_state = Arc::clone(&originating_active_turn.turn_state); |
| *session.active_turn.lock().await = Some(originating_active_turn); |
|
|
| let requested_permissions = RequestPermissionProfile { |
| network: Some(codex_protocol::models::NetworkPermissions { |
| enabled: Some(true), |
| }), |
| ..RequestPermissionProfile::default() |
| }; |
| session |
| .record_granted_request_permissions_for_turn( |
| &codex_protocol::request_permissions::RequestPermissionsResponse { |
| permissions: requested_permissions.clone(), |
| scope: PermissionGrantScope::Turn, |
| strict_auto_review: true, |
| }, |
| codex_exec_server::LOCAL_ENVIRONMENT_ID, |
| Some(&originating_turn_state), |
| ) |
| .await; |
|
|
| assert!( |
| originating_turn_state |
| .lock() |
| .await |
| .strict_auto_review_enabled() |
| ); |
| } |
|
|
| #[test] |
| fn strict_auto_review_session_scope_grants_no_permissions() { |
| let requested_permissions = RequestPermissionProfile { |
| network: Some(codex_protocol::models::NetworkPermissions { |
| enabled: Some(true), |
| }), |
| ..RequestPermissionProfile::default() |
| }; |
|
|
| let cwd = PathUri::parse("file:///tmp").expect("test cwd should be valid"); |
| let context = FileSystemSandboxPolicyContext { |
| cwd: &cwd, |
| workspace_roots: &[], |
| user_home_dir: None, |
| temporary_directories: None, |
| }; |
| let response = Session::normalize_request_permissions_response( |
| requested_permissions.clone(), |
| codex_protocol::request_permissions::RequestPermissionsResponse { |
| permissions: requested_permissions, |
| scope: PermissionGrantScope::Session, |
| strict_auto_review: true, |
| }, |
| &context, |
| ); |
|
|
| assert_eq!( |
| response, |
| codex_protocol::request_permissions::RequestPermissionsResponse { |
| permissions: RequestPermissionProfile::default(), |
| scope: PermissionGrantScope::Turn, |
| strict_auto_review: false, |
| } |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn request_permissions_emits_event_when_granular_policy_allows_requests() { |
| let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; |
| *session.active_turn.lock().await = Some(ActiveTurn::default()); |
| let turn_context_mut = Arc::get_mut(&mut turn_context).expect("single thread settings ref"); |
| Arc::make_mut(&mut turn_context_mut.config) |
| .permissions |
| .approval_policy |
| .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); |
| let turn_context = Arc::new(turn_context); |
| let call_id = "call-1".to_string(); |
| let expected_response = codex_protocol::request_permissions::RequestPermissionsResponse { |
| permissions: RequestPermissionProfile { |
| network: Some(codex_protocol::models::NetworkPermissions { |
| enabled: Some(true), |
| }), |
| ..RequestPermissionProfile::default() |
| }, |
| scope: PermissionGrantScope::Turn, |
| strict_auto_review: false, |
| }; |
|
|
| let handle = tokio::spawn({ |
| let session = Arc::clone(&session); |
| let turn_context = Arc::clone(&turn_context); |
| let call_id = call_id.clone(); |
| async move { |
| let environment = turn_context |
| .environments |
| .primary() |
| .expect("primary environment") |
| .selection(); |
| session |
| .request_permissions_for_environment( |
| &StepContext::for_test(Arc::clone(turn_context.as_ref())), |
| call_id, |
| codex_protocol::request_permissions::RequestPermissionsArgs { |
| environment_id: None, |
| reason: Some("need network".to_string()), |
| permissions: RequestPermissionProfile { |
| network: Some(codex_protocol::models::NetworkPermissions { |
| enabled: Some(true), |
| }), |
| ..RequestPermissionProfile::default() |
| }, |
| }, |
| environment, |
| CancellationToken::new(), |
| ) |
| .await |
| } |
| }); |
|
|
| let request_event = tokio::time::timeout(StdDuration::from_secs(1), rx.recv()) |
| .await |
| .expect("request_permissions event timed out") |
| .expect("request_permissions event missing"); |
| let EventMsg::RequestPermissions(request) = request_event.msg else { |
| panic!("expected request_permissions event"); |
| }; |
| assert_eq!(request.call_id, call_id); |
| assert_eq!( |
| request.environment_id.as_deref(), |
| Some(codex_exec_server::LOCAL_ENVIRONMENT_ID) |
| ); |
| #[allow(deprecated)] |
| let turn_cwd = turn_context.cwd.clone(); |
| assert_eq!(request.cwd, Some(turn_cwd.into())); |
|
|
| session |
| .notify_request_permissions_response(&request.call_id, expected_response.clone()) |
| .await; |
|
|
| let response = tokio::time::timeout(StdDuration::from_secs(1), handle) |
| .await |
| .expect("request_permissions future timed out") |
| .expect("request_permissions join error"); |
|
|
| assert_eq!(response, Some(expected_response)); |
| } |
|
|
| #[tokio::test] |
| async fn request_permissions_tool_resolves_legacy_paths_against_selected_environment() { |
| let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; |
| *session.active_turn.lock().await = Some(ActiveTurn::default()); |
| let environment_cwd = { |
| #[allow(deprecated)] |
| let legacy_cwd = turn_context.cwd.clone(); |
| legacy_cwd.join("request-permissions-environment") |
| }; |
| let environment_home = environment_cwd.join("home"); |
| std::fs::create_dir_all(environment_cwd.as_path()).expect("create environment cwd"); |
| let turn_context_mut = Arc::get_mut(&mut turn_context).expect("single thread settings ref"); |
| Arc::make_mut(&mut turn_context_mut.config) |
| .permissions |
| .approval_policy |
| .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 current_environment = turn_context_mut |
| .environments |
| .primary() |
| .expect("primary environment") |
| .clone(); |
| let current_environment_config = current_environment.config().clone(); |
| let environment = TurnEnvironment { |
| user_home_dir: Some(PathUri::from_abs_path(&environment_home)), |
| ..TurnEnvironment::new( |
| TurnEnvironmentSelection { |
| environment_id: "remote".to_string(), |
| cwd: PathUri::from_abs_path(&environment_cwd), |
| workspace_roots: Vec::new(), |
| config: EnvironmentConfigState::Ready(current_environment_config), |
| }, |
| current_environment.config_origin, |
| current_environment.environment, |
| current_environment.shell, |
| ) |
| }; |
| turn_context_mut.environments.environments[0] = TurnEnvironmentState::Ready(environment); |
|
|
| let call_id = "call-1".to_string(); |
| let handler = RequestPermissionsHandler; |
| let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); |
| let step_context = StepContext::for_test(Arc::clone(&turn_context)); |
| let handle = tokio::spawn({ |
| let session = Arc::clone(&session); |
| let turn_context = Arc::clone(&turn_context); |
| let step_context = Arc::clone(&step_context); |
| let tracker = Arc::clone(&tracker); |
| let call_id = call_id.clone(); |
| async move { |
| handler |
| .handle(ToolInvocation { |
| session, |
| step_context, |
| turn: turn_context, |
| cancellation_token: CancellationToken::new(), |
| tracker, |
| call_id, |
| tool_name: codex_tools::ToolName::plain("request_permissions"), |
| source: ToolCallSource::Direct, |
| payload: ToolPayload::Function { |
| arguments: json!({ |
| "environment_id": "remote", |
| "reason": "need write", |
| "permissions": { |
| "file_system": { |
| "read": null, |
| "write": ["relative.txt", "~/home-relative.txt"], |
| }, |
| }, |
| }) |
| .to_string(), |
| }, |
| }) |
| .await |
| } |
| }); |
|
|
| let request_event = tokio::time::timeout(StdDuration::from_secs(1), rx.recv()) |
| .await |
| .expect("request_permissions event timed out") |
| .expect("request_permissions event missing"); |
| let EventMsg::RequestPermissions(request) = request_event.msg else { |
| panic!("expected request_permissions event"); |
| }; |
| let expected_permissions = RequestPermissionProfile { |
| file_system: Some(FileSystemPermissions::from_read_write_roots( |
| None, |
| Some(vec![ |
| environment_cwd.join("relative.txt"), |
| environment_home.join("home-relative.txt"), |
| ]), |
| )), |
| ..Default::default() |
| }; |
| assert_eq!(request.environment_id.as_deref(), Some("remote")); |
| assert_eq!(request.permissions, expected_permissions); |
|
|
| session |
| .notify_request_permissions_response( |
| &request.call_id, |
| codex_protocol::request_permissions::RequestPermissionsResponse { |
| permissions: request.permissions, |
| scope: PermissionGrantScope::Turn, |
| strict_auto_review: false, |
| }, |
| ) |
| .await; |
| tokio::time::timeout(StdDuration::from_secs(1), handle) |
| .await |
| .expect("request_permissions handler timed out") |
| .expect("request_permissions handler join error") |
| .expect("request_permissions handler should succeed"); |
| } |
|
|
| #[test_case("missing", "unknown turn environment id `missing`"; "unknown environment")] |
| #[test_case("local", "permission path cannot be represented losslessly"; "lossy path")] |
| #[tokio::test] |
| async fn request_permissions_tool_rejects_invalid_requests( |
| environment_id: &str, |
| expected_error: &str, |
| ) { |
| let (session, mut turn_context) = make_session_and_context().await; |
| Arc::make_mut(&mut turn_context.config) |
| .permissions |
| .approval_policy = codex_config::Constrained::allow_any(AskForApproval::Never); |
| let TurnEnvironmentState::Ready(environment) = &mut turn_context.environments.environments[0] |
| else { |
| panic!("turn environment should be ready"); |
| }; |
| environment.selection.cwd = PathUri::parse("file:///workspace/%FF").expect("non-UTF8 cwd"); |
| let turn_context = Arc::new(turn_context); |
| let step_context = StepContext::for_test(Arc::clone(&turn_context)); |
| let result = RequestPermissionsHandler |
| .handle(ToolInvocation { |
| session: Arc::new(session), |
| step_context, |
| turn: turn_context, |
| cancellation_token: CancellationToken::new(), |
| tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), |
| call_id: "call-1".to_string(), |
| tool_name: codex_tools::ToolName::plain("request_permissions"), |
| source: ToolCallSource::Direct, |
| payload: ToolPayload::Function { |
| arguments: json!({ |
| "environment_id": environment_id, |
| "permissions": { |
| "file_system": { |
| "write": ["relative"], |
| }, |
| }, |
| }) |
| .to_string(), |
| }, |
| }) |
| .await; |
|
|
| let Err(FunctionCallError::RespondToModel(output)) = result else { |
| panic!("expected invalid request to be rejected"); |
| }; |
| assert_eq!(output, expected_error); |
| } |
|
|
| #[tokio::test] |
| async fn request_permissions_response_materializes_session_cwd_grants_before_recording() { |
| let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; |
| *session.active_turn.lock().await = Some(ActiveTurn::default()); |
| let turn_context_mut = Arc::get_mut(&mut turn_context).expect("single thread settings ref"); |
| Arc::make_mut(&mut turn_context_mut.config) |
| .permissions |
| .approval_policy |
| .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); |
| let turn_context = Arc::new(turn_context); |
| let call_id = "call-1".to_string(); |
| let requested_permissions = RequestPermissionProfile { |
| file_system: Some(FileSystemPermissions { |
| entries: vec![FileSystemSandboxEntry { |
| path: FileSystemPath::Special { |
| value: FileSystemSpecialPath::project_roots( None), |
| }, |
| access: FileSystemAccessMode::Write, |
| missing_path_behavior: None, |
| }], |
| glob_scan_max_depth: None, |
| }), |
| ..Default::default() |
| }; |
|
|
| let handle = tokio::spawn({ |
| let session = Arc::clone(&session); |
| let turn_context = Arc::clone(&turn_context); |
| let call_id = call_id.clone(); |
| let requested_permissions = requested_permissions.clone(); |
| async move { |
| let environment = turn_context |
| .environments |
| .primary() |
| .expect("primary environment") |
| .selection(); |
| session |
| .request_permissions_for_environment( |
| &StepContext::for_test(Arc::clone(turn_context.as_ref())), |
| call_id, |
| codex_protocol::request_permissions::RequestPermissionsArgs { |
| environment_id: None, |
| reason: Some("need cwd write".to_string()), |
| permissions: requested_permissions, |
| }, |
| environment, |
| CancellationToken::new(), |
| ) |
| .await |
| } |
| }); |
|
|
| let request_event = tokio::time::timeout(StdDuration::from_secs(1), rx.recv()) |
| .await |
| .expect("request_permissions event timed out") |
| .expect("request_permissions event missing"); |
| let EventMsg::RequestPermissions(request) = request_event.msg else { |
| panic!("expected request_permissions event"); |
| }; |
| assert_eq!( |
| request.environment_id.as_deref(), |
| Some(codex_exec_server::LOCAL_ENVIRONMENT_ID) |
| ); |
| let request_cwd = |
| PathUri::try_from(request.cwd.clone().expect("request cwd")).expect("request cwd URI"); |
|
|
| session |
| .notify_request_permissions_response( |
| &request.call_id, |
| codex_protocol::request_permissions::RequestPermissionsResponse { |
| permissions: request.permissions, |
| scope: PermissionGrantScope::Session, |
| strict_auto_review: false, |
| }, |
| ) |
| .await; |
|
|
| let expected_permissions = RequestPermissionProfile { |
| file_system: Some(FileSystemPermissions::from_read_write_path_uris( |
| None, |
| Some(vec![request_cwd]), |
| )), |
| ..Default::default() |
| }; |
| let expected_response = codex_protocol::request_permissions::RequestPermissionsResponse { |
| permissions: expected_permissions.clone(), |
| scope: PermissionGrantScope::Session, |
| strict_auto_review: false, |
| }; |
|
|
| let response = tokio::time::timeout(StdDuration::from_secs(1), handle) |
| .await |
| .expect("request_permissions future timed out") |
| .expect("request_permissions join error"); |
|
|
| assert_eq!(response, Some(expected_response)); |
| assert_eq!( |
| session |
| .granted_session_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID) |
| .await, |
| Some(expected_permissions.into()) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_requests() { |
| let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; |
| *session.active_turn.lock().await = Some(ActiveTurn::default()); |
| let turn_context_mut = Arc::get_mut(&mut turn_context).expect("single thread settings ref"); |
| Arc::make_mut(&mut turn_context_mut.config) |
| .permissions |
| .approval_policy |
| .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); |
| let turn_context = Arc::new(turn_context); |
| let call_id = "call-1".to_string(); |
| let environment = turn_context |
| .environments |
| .primary() |
| .expect("primary environment") |
| .selection(); |
| let response = session |
| .request_permissions_for_environment( |
| &StepContext::for_test(Arc::clone(turn_context.as_ref())), |
| call_id, |
| codex_protocol::request_permissions::RequestPermissionsArgs { |
| environment_id: None, |
| reason: Some("need network".to_string()), |
| permissions: RequestPermissionProfile { |
| network: Some(codex_protocol::models::NetworkPermissions { |
| enabled: Some(true), |
| }), |
| ..RequestPermissionProfile::default() |
| }, |
| }, |
| environment, |
| CancellationToken::new(), |
| ) |
| .await; |
|
|
| assert_eq!( |
| response, |
| Some( |
| codex_protocol::request_permissions::RequestPermissionsResponse { |
| permissions: RequestPermissionProfile::default(), |
| scope: PermissionGrantScope::Turn, |
| strict_auto_review: false, |
| } |
| ) |
| ); |
| assert!( |
| tokio::time::timeout(StdDuration::from_millis(100), rx.recv()) |
| .await |
| .is_err(), |
| "request_permissions should not emit an event when granular.request_permissions is false" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn submit_with_trace_captures_current_span_trace_context() { |
| let (_session, _turn_context) = make_session_and_context().await; |
| let (tx_sub, rx_sub) = async_channel::bounded(1); |
| let (_tx_event, rx_event) = async_channel::unbounded(); |
| let io = SessionIo { |
| tx_sub, |
| rx_event, |
| agent_status: watch::channel(AgentStatus::PendingInit).1, |
| session_loop_termination: completed_session_loop_termination(), |
| }; |
|
|
| let _trace_test_context = install_test_tracing("codex-core-tests"); |
|
|
| let request_parent = W3cTraceContext { |
| traceparent: Some("00-00000000000000000000000000000011-0000000000000022-01".into()), |
| tracestate: Some("vendor=value".into()), |
| }; |
| let request_span = info_span!("app_server.request"); |
| assert!(set_parent_from_w3c_trace_context( |
| &request_span, |
| &request_parent |
| )); |
|
|
| let expected_trace = async { |
| let expected_trace = |
| current_span_w3c_trace_context().expect("current span should have trace context"); |
| io.submit_with_trace( |
| Op::Interrupt, |
| None, |
| None, |
| None, |
| ) |
| .await |
| .expect("submit should succeed"); |
| expected_trace |
| } |
| .instrument(request_span) |
| .await; |
|
|
| let submitted = rx_sub.recv().await.expect("submission"); |
| assert_eq!(submitted.trace, Some(expected_trace)); |
| } |
|
|
| #[tokio::test] |
| async fn new_default_turn_captures_current_span_trace_id() { |
| let (session, _turn_context) = make_session_and_context().await; |
|
|
| let _trace_test_context = install_test_tracing("codex-core-tests"); |
|
|
| let request_parent = W3cTraceContext { |
| traceparent: Some("00-00000000000000000000000000000011-0000000000000022-01".into()), |
| tracestate: Some("vendor=value".into()), |
| }; |
| let request_span = info_span!("app_server.request"); |
| assert!(set_parent_from_w3c_trace_context( |
| &request_span, |
| &request_parent |
| )); |
|
|
| let turn_trace_id = async { |
| let expected_trace_id = Span::current() |
| .context() |
| .span() |
| .span_context() |
| .trace_id() |
| .to_string(); |
| let turn_context = session.new_default_turn().await; |
| assert_eq!(turn_context.trace_id, Some(expected_trace_id)); |
| turn_context.trace_id.clone() |
| } |
| .instrument(request_span) |
| .await; |
|
|
| assert_eq!( |
| turn_trace_id.as_deref(), |
| Some("00000000000000000000000000000011") |
| ); |
| } |
|
|
| #[test] |
| fn submission_dispatch_span_prefers_submission_trace_context() { |
| let _trace_test_context = install_test_tracing("codex-core-tests"); |
|
|
| let ambient_parent = W3cTraceContext { |
| traceparent: Some("00-00000000000000000000000000000033-0000000000000044-01".into()), |
| tracestate: None, |
| }; |
| let ambient_span = info_span!("ambient"); |
| assert!(set_parent_from_w3c_trace_context( |
| &ambient_span, |
| &ambient_parent |
| )); |
|
|
| let submission_trace = W3cTraceContext { |
| traceparent: Some("00-00000000000000000000000000000055-0000000000000066-01".into()), |
| tracestate: Some("vendor=value".into()), |
| }; |
| let dispatch_span = ambient_span.in_scope(|| { |
| submission_dispatch_span(&Submission { |
| id: "sub-1".into(), |
| op: Op::Interrupt, |
| parent_turn_id: None, |
| root_turn_id: None, |
| trace: Some(submission_trace), |
| }) |
| }); |
|
|
| let trace_id = dispatch_span.context().span().span_context().trace_id(); |
| assert_eq!( |
| trace_id, |
| TraceId::from_hex("00000000000000000000000000000055").expect("trace id") |
| ); |
| } |
|
|
| #[test] |
| fn submission_dispatch_span_uses_debug_for_realtime_audio() { |
| let _trace_test_context = install_test_tracing("codex-core-tests"); |
|
|
| let dispatch_span = submission_dispatch_span(&Submission { |
| id: "sub-1".into(), |
| op: Op::RealtimeConversationAudio(ConversationAudioParams { |
| frame: RealtimeAudioFrame { |
| data: "ZmFrZQ==".into(), |
| sample_rate: 16_000, |
| num_channels: 1, |
| samples_per_channel: Some(160), |
| item_id: None, |
| }, |
| }), |
| parent_turn_id: None, |
| root_turn_id: None, |
| trace: None, |
| }); |
|
|
| assert_eq!( |
| dispatch_span.metadata().expect("span metadata").level(), |
| &tracing::Level::DEBUG |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn turn_environments_set_primary_environment() { |
| let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; |
| let selected_cwd = |
| AbsolutePathBuf::try_from(session.get_config().await.cwd.as_path().join("selected")) |
| .expect("absolute path"); |
|
|
| let (turn_context, _) = session |
| .new_turn_with_sub_id( |
| "sub-1".to_string(), |
| SessionSettingsUpdate { |
| environments: Some(TurnEnvironmentSelections::new( |
| selected_cwd.clone(), |
| vec![local(selected_cwd.clone())], |
| )), |
| ..Default::default() |
| }, |
| Default::default(), |
| ) |
| .await |
| .expect("turn should start"); |
|
|
| let turn_environments = &turn_context.environments; |
| assert_eq!(turn_environments.turn_environments().count(), 1); |
| let turn_environment = turn_context |
| .environments |
| .primary() |
| .expect("primary environment should be set"); |
| assert!(std::sync::Arc::ptr_eq( |
| &turn_environment.environment, |
| &turn_environments |
| .primary() |
| .expect("primary environment") |
| .environment |
| )); |
| assert!( |
| turn_context |
| .environments |
| .turn_environments() |
| .next() |
| .is_some() |
| ); |
| #[allow(deprecated)] |
| let turn_cwd = turn_context.cwd.clone(); |
| assert_eq!(turn_cwd.as_path(), selected_cwd.as_path()); |
| assert_eq!(turn_context.config.cwd.as_path(), selected_cwd.as_path()); |
|
|
| let stored_environment = { |
| session |
| .services |
| .turn_environments |
| .snapshot() |
| .await |
| .primary_environment() |
| .expect("stored primary environment") |
| }; |
| assert!(Arc::ptr_eq( |
| &stored_environment, |
| &turn_environment.environment |
| )); |
|
|
| let default_turn = session.new_default_turn().await; |
| assert!(Arc::ptr_eq( |
| &stored_environment, |
| &default_turn |
| .environments |
| .primary() |
| .expect("default turn primary environment") |
| .environment |
| )); |
| } |
|
|
| #[tokio::test] |
| async fn default_turn_does_not_overlay_legacy_fallback_cwd_onto_stored_thread_environments() { |
| let (session, _initial_turn, _rx) = make_session_and_context_with_rx().await; |
| let session_cwd = session.get_config().await.cwd.clone(); |
| let selected_cwd = |
| AbsolutePathBuf::try_from(session_cwd.as_path().join("selected")).expect("absolute path"); |
| session |
| .update_settings(SessionSettingsUpdate { |
| environments: Some(TurnEnvironmentSelections::new( |
| session_cwd.clone(), |
| vec![local(selected_cwd.clone())], |
| )), |
| ..Default::default() |
| }) |
| .await |
| .expect("environment selection update should succeed"); |
| let turn_context = session.new_default_turn().await; |
|
|
| let turn_environments = &turn_context.environments; |
| assert_eq!(turn_environments.turn_environments().count(), 1); |
| let turn_environment = turn_context |
| .environments |
| .primary() |
| .expect("primary environment should be set"); |
| assert!(std::sync::Arc::ptr_eq( |
| &turn_environment.environment, |
| &turn_environments |
| .primary() |
| .expect("primary environment") |
| .environment |
| )); |
| #[allow(deprecated)] |
| let turn_cwd = turn_context.cwd.clone(); |
| assert_eq!(turn_cwd, selected_cwd); |
| assert_eq!(turn_context.config.cwd, selected_cwd); |
| } |
|
|
| #[tokio::test] |
| async fn default_turn_honors_empty_stored_thread_environments() { |
| let (session, _initial_turn, _rx) = make_session_and_context_with_rx().await; |
| let session_cwd = session.get_config().await.cwd.clone(); |
|
|
| session |
| .update_settings(SessionSettingsUpdate { |
| environments: Some(TurnEnvironmentSelections::new( |
| session_cwd.clone(), |
| Vec::new(), |
| )), |
| ..Default::default() |
| }) |
| .await |
| .expect("environment selection update should succeed"); |
| let turn_context = session.new_default_turn().await; |
|
|
| assert!(turn_context.environments.primary().is_none()); |
| assert!( |
| turn_context |
| .environments |
| .turn_environments() |
| .next() |
| .is_none() |
| ); |
| #[allow(deprecated)] |
| let turn_cwd = turn_context.cwd.clone(); |
| assert_eq!(turn_cwd, session_cwd); |
| assert_eq!(turn_context.config.cwd, session_cwd); |
| assert_eq!(turn_context.environments.turn_environments().count(), 0); |
| } |
|
|
| #[tokio::test] |
| async fn primary_environment_uses_first_turn_environment() { |
| let (_session, mut turn_context) = make_session_and_context().await; |
| let first_environment = turn_context |
| .environments |
| .primary() |
| .expect("primary environment") |
| .clone(); |
| #[allow(deprecated)] |
| let second_cwd = turn_context.cwd.join("second"); |
| let second_cwd_uri = codex_utils_path_uri::PathUri::from_abs_path(&second_cwd); |
| let first_environment_config = first_environment.config().clone(); |
| turn_context |
| .environments |
| .environments |
| .push(TurnEnvironmentState::Ready(TurnEnvironment::new( |
| TurnEnvironmentSelection { |
| environment_id: "second".to_string(), |
| cwd: second_cwd_uri.clone(), |
| workspace_roots: Vec::new(), |
| config: EnvironmentConfigState::Ready(first_environment_config), |
| }, |
| first_environment.config_origin, |
| Arc::clone(&first_environment.environment), |
| None, |
| ))); |
|
|
| assert_eq!( |
| turn_context |
| .environments |
| .primary() |
| .expect("primary environment") |
| .selection |
| .environment_id, |
| first_environment.selection.environment_id |
| ); |
| assert_eq!( |
| turn_context |
| .environments |
| .turn_environments() |
| .find(|environment| environment.selection.environment_id == "second") |
| .expect("second environment") |
| .cwd(), |
| &second_cwd_uri |
| ); |
| assert_eq!(turn_context.environments.turn_environments().count(), 2); |
| assert_eq!( |
| turn_context |
| .environments |
| .turn_environments() |
| .nth(1) |
| .expect("second environment") |
| .cwd(), |
| &second_cwd_uri |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn empty_turn_environments_clear_primary_environment() { |
| let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; |
|
|
| let (turn_context, _) = session |
| .new_turn_with_sub_id( |
| "sub-1".to_string(), |
| SessionSettingsUpdate { |
| environments: Some(TurnEnvironmentSelections::new( |
| session.get_config().await.cwd.clone(), |
| vec![], |
| )), |
| ..Default::default() |
| }, |
| Default::default(), |
| ) |
| .await |
| .expect("turn should start"); |
|
|
| assert!(turn_context.environments.primary().is_none()); |
| assert!( |
| turn_context |
| .environments |
| .turn_environments() |
| .next() |
| .is_none() |
| ); |
| #[allow(deprecated)] |
| let turn_cwd = turn_context.cwd.clone(); |
| assert_eq!(turn_cwd, session.get_config().await.cwd); |
| assert_eq!(turn_context.config.cwd, session.get_config().await.cwd); |
| } |
|
|
| #[tokio::test] |
| async fn spawn_task_turn_span_inherits_dispatch_trace_context() { |
| struct TraceCaptureTask { |
| captured_trace: Arc<std::sync::Mutex<Option<W3cTraceContext>>>, |
| } |
|
|
| impl SessionTask for TraceCaptureTask { |
| fn kind(&self) -> TaskKind { |
| TaskKind::Regular |
| } |
|
|
| fn span_name(&self) -> &'static str { |
| "session_task.trace_capture" |
| } |
|
|
| async fn run( |
| self: Arc<Self>, |
| _session: Arc<Session>, |
| _ctx: Arc<TurnContext>, |
| _input: Vec<TurnInput>, |
| _cancellation_token: CancellationToken, |
| ) -> SessionTaskResult { |
| let mut trace = self |
| .captured_trace |
| .lock() |
| .unwrap_or_else(std::sync::PoisonError::into_inner); |
| *trace = current_span_w3c_trace_context(); |
| Ok(None) |
| } |
| } |
|
|
| let _trace_test_context = install_test_tracing("codex-core-tests"); |
|
|
| let request_parent = W3cTraceContext { |
| traceparent: Some("00-00000000000000000000000000000011-0000000000000022-01".into()), |
| tracestate: Some("vendor=value".into()), |
| }; |
| let request_span = tracing::info_span!("app_server.request"); |
| assert!(set_parent_from_w3c_trace_context( |
| &request_span, |
| &request_parent |
| )); |
|
|
| let submission_trace = |
| async { current_span_w3c_trace_context().expect("request span should have trace context") } |
| .instrument(request_span) |
| .await; |
|
|
| let dispatch_span = submission_dispatch_span(&Submission { |
| id: "sub-1".into(), |
| op: Op::Interrupt, |
| parent_turn_id: None, |
| root_turn_id: None, |
| trace: Some(submission_trace.clone()), |
| }); |
| let dispatch_span_id = dispatch_span.context().span().span_context().span_id(); |
|
|
| let (sess, tc, rx) = make_session_and_context_with_rx().await; |
| let captured_trace = Arc::new(std::sync::Mutex::new(None)); |
|
|
| async { |
| sess.spawn_task( |
| Arc::clone(&tc), |
| vec![TurnInput::UserInput { |
| acceptance_order: None, |
| content: vec![UserInput::Text { |
| text: "hello".to_string(), |
| text_elements: Vec::new(), |
| }], |
| client_id: None, |
| }], |
| TraceCaptureTask { |
| captured_trace: Arc::clone(&captured_trace), |
| }, |
| ) |
| .await; |
| } |
| .instrument(dispatch_span) |
| .await; |
|
|
| let evt = tokio::time::timeout(StdDuration::from_secs(2), rx.recv()) |
| .await |
| .expect("timeout waiting for turn completion") |
| .expect("event"); |
| assert!(matches!(evt.msg, EventMsg::TurnComplete(_))); |
|
|
| let task_trace = captured_trace |
| .lock() |
| .unwrap_or_else(std::sync::PoisonError::into_inner) |
| .clone() |
| .expect("turn task should capture the current span trace context"); |
| let submission_context = |
| codex_otel::context_from_w3c_trace_context(&submission_trace).expect("submission"); |
| let task_context = codex_otel::context_from_w3c_trace_context(&task_trace).expect("task trace"); |
|
|
| assert_eq!( |
| task_context.span().span_context().trace_id(), |
| submission_context.span().span_context().trace_id() |
| ); |
| assert_ne!( |
| task_context.span().span_context().span_id(), |
| dispatch_span_id |
| ); |
| } |
|
|
| #[cfg(debug_assertions)] |
| #[tokio::test] |
| async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() { |
| let (mut session, _turn_context) = make_session_and_context().await; |
| let store = Arc::new(codex_thread_store::InMemoryThreadStore::default()); |
| let thread_store: Arc<dyn codex_thread_store::ThreadStore> = store.clone(); |
| let config = session.get_config().await; |
| let live_thread = LiveThread::create( |
| Arc::clone(&thread_store), |
| CreateThreadParams { |
| session_id: session.session_id(), |
| thread_id: session.thread_id, |
| extra_config: None, |
| forked_from_id: None, |
| parent_thread_id: None, |
| source: SessionSource::Exec, |
| thread_source: None, |
| originator: "test_originator".to_string(), |
| base_instructions: BaseInstructions::default(), |
| dynamic_tools: Vec::new(), |
| selected_capability_roots: Vec::new(), |
| multi_agent_version: None, |
| history_mode: Default::default(), |
| subagent_history_start_ordinal: None, |
| history_base: None, |
| initial_window_id: Uuid::now_v7().to_string(), |
| runtime_workspace_roots: None, |
| metadata: ThreadPersistenceMetadata { |
| cwd: Some(config.cwd.to_path_buf()), |
| model_provider: config.model_provider_id.clone(), |
| memory_mode: if config.memories.generate_memories { |
| ThreadMemoryMode::Enabled |
| } else { |
| ThreadMemoryMode::Disabled |
| }, |
| }, |
| }, |
| ) |
| .await |
| .expect("create thread persistence"); |
| session.services.thread_store = thread_store; |
| session.services.live_thread = Some(live_thread); |
| let (result_sender, result_receiver) = async_channel::unbounded(); |
| result_sender |
| .try_send( |
| serde_json::from_value::<codex_protocol::protocol::HookCompletedEvent>(json!({ |
| "turn_id": "turn-1", |
| "run": { |
| "id": "user_prompt_submit:0:hooks.json", |
| "event_name": "user_prompt_submit", |
| "handler_type": "command", |
| "execution_mode": "async", |
| "scope": "turn", |
| "source_path": config.cwd.join("hooks.json"), |
| "source": "user", |
| "display_order": 0, |
| "status": "completed", |
| "status_message": null, |
| "started_at": 0, |
| "completed_at": 1, |
| "duration_ms": 1, |
| "entries": [{ |
| "kind": "context", |
| "text": "must not be persisted during shutdown" |
| }] |
| } |
| })) |
| .expect("valid buffered async hook result"), |
| ) |
| .expect("buffer an async hook result before shutdown"); |
| session.async_hook_results = result_receiver; |
| let session = Arc::new(session); |
|
|
| assert!(handlers::shutdown(&session, "sub-1".to_string()).await); |
| assert!(session.async_hook_results.is_closed()); |
| assert!(session.async_hook_results.is_empty()); |
| assert!(result_sender.is_closed()); |
|
|
| assert_eq!( |
| codex_thread_store::InMemoryThreadStoreCalls { |
| create_thread: 1, |
| shutdown_thread: 1, |
| ..Default::default() |
| }, |
| store.calls().await |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn submission_loop_channel_close_runs_full_thread_teardown() { |
| struct SessionStopMarker; |
| struct ThreadStopMarker; |
|
|
| struct ThreadStopRecorder { |
| calls: Arc<std::sync::atomic::AtomicUsize>, |
| expected_thread_id: ThreadId, |
| } |
|
|
| impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for ThreadStopRecorder { |
| fn on_thread_stop<'a>( |
| &'a self, |
| input: codex_extension_api::ThreadStopInput<'a>, |
| ) -> codex_extension_api::ExtensionFuture<'a, ()> { |
| Box::pin(async move { |
| assert_eq!( |
| self.expected_thread_id.to_string(), |
| input.thread_store.level_id() |
| ); |
| assert!(input.session_store.get::<SessionStopMarker>().is_some()); |
| assert!(input.thread_store.get::<ThreadStopMarker>().is_some()); |
| self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); |
| }) |
| } |
| } |
|
|
| let (mut session, turn_context) = make_session_and_context().await; |
| let store = Arc::new(codex_thread_store::InMemoryThreadStore::default()); |
| let thread_store: Arc<dyn codex_thread_store::ThreadStore> = store.clone(); |
| let config = session.get_config().await; |
| let live_thread = LiveThread::create( |
| Arc::clone(&thread_store), |
| CreateThreadParams { |
| session_id: session.session_id(), |
| thread_id: session.thread_id, |
| extra_config: None, |
| forked_from_id: None, |
| parent_thread_id: None, |
| source: SessionSource::Exec, |
| thread_source: None, |
| originator: "test_originator".to_string(), |
| base_instructions: BaseInstructions::default(), |
| dynamic_tools: Vec::new(), |
| selected_capability_roots: Vec::new(), |
| multi_agent_version: None, |
| history_mode: Default::default(), |
| subagent_history_start_ordinal: None, |
| history_base: None, |
| initial_window_id: Uuid::now_v7().to_string(), |
| runtime_workspace_roots: None, |
| metadata: ThreadPersistenceMetadata { |
| cwd: Some(config.cwd.to_path_buf()), |
| model_provider: config.model_provider_id.clone(), |
| memory_mode: if config.memories.generate_memories { |
| ThreadMemoryMode::Enabled |
| } else { |
| ThreadMemoryMode::Disabled |
| }, |
| }, |
| }, |
| ) |
| .await |
| .expect("create thread persistence"); |
| session.services.thread_store = thread_store; |
| session.services.live_thread = Some(live_thread); |
| let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); |
| let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new(); |
| builder.thread_lifecycle_contributor(Arc::new(ThreadStopRecorder { |
| calls: Arc::clone(&calls), |
| expected_thread_id: session.thread_id, |
| })); |
| session.services.extensions = Arc::new(builder.build()); |
| session |
| .services |
| .session_extension_data |
| .insert(SessionStopMarker); |
| session |
| .services |
| .thread_extension_data |
| .insert(ThreadStopMarker); |
|
|
| let (tx_sub, rx_sub) = async_channel::bounded(1); |
| drop(tx_sub); |
| let session = Arc::new(session); |
| submission_loop(session, Arc::clone(&turn_context.config), rx_sub).await; |
|
|
| assert_eq!(1, calls.load(std::sync::atomic::Ordering::SeqCst)); |
| assert_eq!( |
| codex_thread_store::InMemoryThreadStoreCalls { |
| create_thread: 1, |
| shutdown_thread: 1, |
| ..Default::default() |
| }, |
| store.calls().await |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn submission_loop_channel_close_aborts_active_turn_before_thread_stop_lifecycle() { |
| struct LifecycleRecorder { |
| calls: Arc<std::sync::Mutex<Vec<&'static str>>>, |
| expected_thread_id: ThreadId, |
| expected_turn_id: String, |
| } |
|
|
| impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for LifecycleRecorder { |
| fn on_thread_stop<'a>( |
| &'a self, |
| input: codex_extension_api::ThreadStopInput<'a>, |
| ) -> codex_extension_api::ExtensionFuture<'a, ()> { |
| Box::pin(async move { |
| assert_eq!( |
| self.expected_thread_id.to_string(), |
| input.thread_store.level_id() |
| ); |
| self.calls |
| .lock() |
| .unwrap_or_else(std::sync::PoisonError::into_inner) |
| .push("thread_stop"); |
| }) |
| } |
| } |
|
|
| impl codex_extension_api::TurnLifecycleContributor for LifecycleRecorder { |
| fn on_turn_abort<'a>( |
| &'a self, |
| input: codex_extension_api::TurnAbortInput<'a>, |
| ) -> codex_extension_api::ExtensionFuture<'a, ()> { |
| Box::pin(async move { |
| assert_eq!( |
| self.expected_thread_id.to_string(), |
| input.thread_store.level_id() |
| ); |
| assert_eq!(self.expected_turn_id, input.turn_store.level_id()); |
| assert_eq!(TurnAbortReason::Interrupted, input.reason); |
| self.calls |
| .lock() |
| .unwrap_or_else(std::sync::PoisonError::into_inner) |
| .push("turn_abort"); |
| }) |
| } |
| } |
|
|
| let (mut session, turn_context) = make_session_and_context().await; |
| let calls = Arc::new(std::sync::Mutex::new(Vec::new())); |
| let recorder = Arc::new(LifecycleRecorder { |
| calls: Arc::clone(&calls), |
| expected_thread_id: session.thread_id, |
| expected_turn_id: turn_context.sub_id.clone(), |
| }); |
| let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new(); |
| builder.thread_lifecycle_contributor(recorder.clone()); |
| builder.turn_lifecycle_contributor(recorder); |
| session.services.extensions = Arc::new(builder.build()); |
|
|
| let session = Arc::new(session); |
| session |
| .spawn_task( |
| Arc::new(turn_context), |
| Vec::new(), |
| NeverEndingTask { |
| kind: TaskKind::Regular, |
| listen_to_cancellation_token: true, |
| }, |
| ) |
| .await; |
|
|
| let (tx_sub, rx_sub) = async_channel::bounded(1); |
| drop(tx_sub); |
| submission_loop(Arc::clone(&session), session.get_config().await, rx_sub).await; |
|
|
| assert_eq!( |
| vec!["turn_abort", "thread_stop"], |
| *calls |
| .lock() |
| .unwrap_or_else(std::sync::PoisonError::into_inner) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn shutdown_and_wait_allows_multiple_waiters() { |
| let (_session, _turn_context) = make_session_and_context().await; |
| let (tx_sub, rx_sub) = async_channel::bounded::<Submission>(4); |
| let (_tx_event, rx_event) = async_channel::unbounded(); |
| let session_loop_handle = tokio::spawn(async move { |
| let shutdown = rx_sub.recv().await.expect("shutdown submission"); |
| assert!(matches!(shutdown.op, Op::Shutdown)); |
| tokio::time::sleep(StdDuration::from_millis(50)).await; |
| }); |
| let io = Arc::new(SessionIo { |
| tx_sub, |
| rx_event, |
| agent_status: watch::channel(AgentStatus::PendingInit).1, |
| session_loop_termination: session_loop_termination_from_handle(session_loop_handle), |
| }); |
|
|
| let waiter_1 = { |
| let io = Arc::clone(&io); |
| tokio::spawn(async move { io.shutdown_and_wait().await }) |
| }; |
| let waiter_2 = { |
| let io = Arc::clone(&io); |
| tokio::spawn(async move { io.shutdown_and_wait().await }) |
| }; |
|
|
| waiter_1 |
| .await |
| .expect("first shutdown waiter join") |
| .expect("first shutdown waiter"); |
| waiter_2 |
| .await |
| .expect("second shutdown waiter join") |
| .expect("second shutdown waiter"); |
| } |
|
|
| #[tokio::test] |
| async fn shutdown_and_wait_waits_when_shutdown_is_already_in_progress() { |
| let (_session, _turn_context) = make_session_and_context().await; |
| let (tx_sub, rx_sub) = async_channel::bounded(4); |
| drop(rx_sub); |
| let (_tx_event, rx_event) = async_channel::unbounded(); |
| let (shutdown_complete_tx, shutdown_complete_rx) = tokio::sync::oneshot::channel(); |
| let session_loop_handle = tokio::spawn(async move { |
| let _ = shutdown_complete_rx.await; |
| }); |
| let io = Arc::new(SessionIo { |
| tx_sub, |
| rx_event, |
| agent_status: watch::channel(AgentStatus::PendingInit).1, |
| session_loop_termination: session_loop_termination_from_handle(session_loop_handle), |
| }); |
|
|
| let waiter = { |
| let io = Arc::clone(&io); |
| tokio::spawn(async move { io.shutdown_and_wait().await }) |
| }; |
|
|
| tokio::time::sleep(StdDuration::from_millis(10)).await; |
| assert!(!waiter.is_finished()); |
|
|
| shutdown_complete_tx |
| .send(()) |
| .expect("session loop should still be waiting to terminate"); |
|
|
| waiter |
| .await |
| .expect("shutdown waiter join") |
| .expect("shutdown waiter"); |
| } |
|
|
| pub(crate) async fn make_session_and_context_with_auth_and_config_and_rx<F>( |
| auth: CodexAuth, |
| dynamic_tools: Vec<DynamicToolSpec>, |
| configure_config: F, |
| ) -> ( |
| Arc<Session>, |
| Arc<TurnContext>, |
| async_channel::Receiver<Event>, |
| ) |
| where |
| F: FnOnce(&mut Config), |
| { |
| let codex_home = tempfile::tempdir().expect("create temp dir"); |
| make_session_and_context_with_auth_config_home_and_rx( |
| auth, |
| dynamic_tools, |
| codex_home.path(), |
| configure_config, |
| ) |
| .await |
| } |
|
|
| async fn make_session_and_context_with_auth_config_home_and_rx<F>( |
| auth: CodexAuth, |
| dynamic_tools: Vec<DynamicToolSpec>, |
| codex_home: &Path, |
| configure_config: F, |
| ) -> ( |
| Arc<Session>, |
| Arc<TurnContext>, |
| async_channel::Receiver<Event>, |
| ) |
| where |
| F: FnOnce(&mut Config), |
| { |
| let (tx_event, rx_event) = async_channel::unbounded(); |
| let mut config = build_test_config(codex_home).await; |
| configure_config(&mut config); |
| let state_db = None; |
| let config = Arc::new(config); |
| let thread_id = ThreadId::default(); |
| let auth_manager = AuthManager::from_auth_for_testing_with_home(auth, codex_home.to_path_buf()); |
| let models_manager = models_manager_with_provider( |
| config.codex_home.to_path_buf(), |
| auth_manager.clone(), |
| config.model_provider.clone(), |
| ); |
| let agent_control = AgentControl::default(); |
| let exec_policy = Arc::new(ExecPolicyManager::default()); |
| let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); |
| let model = get_model_offline_for_tests(config.model.as_deref()); |
| let model_info = |
| construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); |
| let reasoning_effort = config.model_reasoning_effort.clone(); |
| let collaboration_mode = CollaborationMode { |
| mode: ModeKind::Default, |
| settings: Settings { |
| model, |
| reasoning_effort, |
| developer_instructions: None, |
| }, |
| }; |
| let default_environments = vec![local(config.cwd.clone())]; |
| let session_configuration = SessionConfiguration { |
| provider: create_model_provider( |
| config.model_provider.clone(), |
| Some(Arc::clone(&auth_manager)), |
| ), |
| step_settings: Arc::new(StepSettings { |
| collaboration_mode, |
| reasoning_summary: config.model_reasoning_summary, |
| service_tier: None, |
| personality: config.personality, |
| approval_policy: config.permissions.approval_policy.clone(), |
| approvals_reviewer: config.approvals_reviewer, |
| }), |
| model_info_overrides: config.to_models_manager_config().into(), |
| developer_instructions: config.developer_instructions.clone(), |
| base_instructions: config |
| .base_instructions |
| .clone() |
| .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), |
| permission_profile_state: config.permissions.permission_profile_state().clone(), |
| allow_login_shell: config.permissions.allow_login_shell, |
| shell_environment_policy: config.permissions.shell_environment_policy.clone(), |
| windows_sandbox_level: WindowsSandboxLevel::from_config(&config), |
| windows_sandbox_type: config.permissions.windows_sandbox_type, |
| windows_sandbox_private_desktop: config.permissions.windows_sandbox_private_desktop, |
| use_legacy_landlock: config.features.use_legacy_landlock(), |
| legacy_fallback_cwd: config.cwd.clone(), |
| runtime_workspace_roots: config.workspace_roots.clone(), |
| codex_home: config.codex_home.clone(), |
| thread_name: None, |
| disabled_plugin_ids: Vec::new(), |
| original_config_do_not_use: Arc::clone(&config), |
| metrics_service_name: None, |
| app_server_client_name: None, |
| app_server_client_version: None, |
| trusted_guardian_reviewer: false, |
| session_source: SessionSource::Exec, |
| history_mode: Default::default(), |
| forked_from_thread_id: None, |
| parent_thread_id: None, |
| thread_source: None, |
| originator: "test_originator".to_string(), |
| dynamic_tools, |
| user_shell_override: None, |
| }; |
| let session_telemetry = session_telemetry( |
| thread_id, |
| config.as_ref(), |
| &model_info, |
| session_configuration.session_source.clone(), |
| ); |
|
|
| let mut state = SessionState::new(session_configuration.clone()); |
| state.history = ContextManager::with_guardian_context_mode( |
| GuardianContextMode::from_features(&config.features), |
| &session_configuration.session_source, |
| ); |
| let (environment_manager, resolved_turn_environments) = |
| resolved_environments_for_configuration(&session_configuration, &default_environments) |
| .await; |
| let turn_environments = Arc::new(ThreadEnvironments::new( |
| environment_manager, |
| default_user_shell(), |
| session_configuration.inferred_environment_config(), |
| ShellSnapshot::disabled(), |
| resolved_turn_environments.clone(), |
| false, |
| )); |
| let environment = Arc::clone( |
| &resolved_turn_environments |
| .primary() |
| .expect("primary environment") |
| .environment, |
| ); |
| let plugins_manager = Arc::new(plugins_manager_for_config( |
| &config, |
| Arc::clone(&auth_manager), |
| )); |
| let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); |
| let skills_service = Arc::new(HostSkillsService::new( |
| config.codex_home.clone(), |
| true, |
| )); |
| let network_approval = Arc::new(NetworkApprovalService::default()); |
| let mcp_runtime = Arc::new(codex_mcp::McpRuntime::empty(config.prefix_mcp_tool_names())); |
| let executed_tool_calls = |
| crate::state::ExecutedToolCalls::new(&config.features, &InitialHistory::Forked(Vec::new())); |
| let (hooks, async_hook_results) = Hooks::new( |
| HooksConfig { |
| legacy_notify_argv: config.notify.clone(), |
| ..HooksConfig::default() |
| }, |
| thread_id, |
| Arc::new(CoreHookMcpExecutor { |
| runtime: Arc::clone(&mcp_runtime), |
| thread_id, |
| }), |
| ) |
| .expect("initialize test hooks"); |
| let services = SessionServices { |
| mcp_runtime, |
| mcp_handler_cache: Default::default(), |
| unified_exec_manager: UnifiedExecProcessManager::new( |
| config.background_terminal_max_timeout, |
| ), |
| elicitations: crate::elicitation::ElicitationService::new(), |
| shell_zsh_path: None, |
| main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), |
| analytics_events_client: AnalyticsEventsClient::new( |
| Arc::clone(&auth_manager), |
| config.chatgpt_base_url.trim_end_matches('/').to_string(), |
| config.analytics_enabled, |
| ), |
| hooks: arc_swap::ArcSwap::from_pointee(hooks), |
| rollout_thread_trace: codex_rollout_trace::ThreadTraceContext::disabled(), |
| user_shell: Arc::new(default_user_shell()), |
| show_raw_agent_reasoning: config.show_raw_agent_reasoning, |
| exec_policy, |
| auth_manager: Arc::clone(&auth_manager), |
| openai_file_upload_client_pool: RouteAwareClientPool::new_without_request_logging( |
| config.http_client_factory(), |
| ClientRouteClass::Api, |
| ) |
| .with_legacy_custom_ca_fallback(), |
| session_telemetry: session_telemetry.clone(), |
| models_manager: Arc::clone(&models_manager), |
| git_root_discovery: Arc::default(), |
| tool_approvals: Mutex::new(ApprovalStore::default()), |
| runtime_handle: tokio::runtime::Handle::current(), |
| skills_service, |
| agents_md_manager: Arc::new(AgentsMdManager::new(SessionInstructions::default())), |
| plugins_manager, |
| mcp_manager, |
| extensions: Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), |
| session_extension_data: codex_extension_api::ExtensionData::new( |
| agent_control.session_id().to_string(), |
| ), |
| thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()), |
| selected_capability_roots: Vec::new(), |
| mcp_thread_init: codex_extension_api::ExtensionDataInit::default(), |
| client_mcp_extensions: ClientMcpExtensions::default(), |
| agent_control, |
| network_proxy: arc_swap::ArcSwapOption::from(None), |
| network_proxy_audit_metadata: crate::config::NetworkProxyAuditMetadata::default(), |
| managed_network_requirements_configured: false, |
| network_approval: Arc::clone(&network_approval), |
| state_db: state_db.clone(), |
| live_thread: None, |
| image_store: crate::passthrough_image_store(), |
| thread_store: Arc::new(codex_thread_store::LocalThreadStore::new( |
| codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), |
| state_db, |
| )), |
| attestation_provider: None, |
| time_provider: Arc::new(crate::current_time::SystemTimeProvider), |
| model_client: ModelClient::new( |
| Some(Arc::clone(&auth_manager)), |
| AgentIdentityAuthPolicy::JwtOnly, |
| thread_id, |
| session_configuration.provider.info().clone(), |
| session_configuration.session_source.clone(), |
| session_configuration.originator.clone(), |
| config.model_verbosity, |
| config.features.enabled(Feature::ContentItemKinds), |
| config.features.enabled(Feature::EnableRequestCompression), |
| config.features.enabled(Feature::RuntimeMetrics), |
| Session::build_model_client_beta_features_header(config.as_ref()), |
| |
| config |
| .features |
| .enabled(Feature::ConcurrentReasoningSummaries), |
| None, |
| config.http_client_factory(), |
| config.workspace_routing_context(), |
| ), |
| executed_tool_calls: executed_tool_calls.clone(), |
| code_mode_service: crate::tools::code_mode::CodeModeService::new( |
| thread_id, |
| Arc::new(codex_code_mode::DisabledCodeModeSessionProvider), |
| &config.code_mode, |
| executed_tool_calls, |
| ), |
| tool_search_handler_cache: Default::default(), |
| turn_environments: Arc::clone(&turn_environments), |
| }; |
|
|
| let session = Arc::new(Session { |
| thread_id, |
| installation_id: "11111111-1111-4111-8111-111111111111".to_string(), |
| tx_event, |
| agent_status: agent_status_tx, |
| state: Mutex::new(state), |
| thread_settings_persistence: Semaphore::new( 1), |
| managed_network_proxy_refresh_lock: Semaphore::new( 1), |
| features: config.features.clone(), |
| guardian_context_mode: GuardianContextMode::from_features(&config.features), |
| isolation: codex_extension_api::SessionIsolation::Inherit, |
| allowed_tools: None, |
| windows_sandbox_proxy_settings_mode: |
| codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile, |
| multi_agent_version: OnceLock::from(config.multi_agent_version_from_features()), |
| mcp_refresh: McpRefresh::new(), |
| mcp_tool_approval_metadata: Default::default(), |
| mcp_elicitation_reviewer_handle: OnceLock::new(), |
| mcp_elicitation_lifecycle_handle: OnceLock::new(), |
| mcp_prewarm_tx: async_channel::bounded(1).0, |
| mcp_prewarm_shutdown: CancellationToken::new(), |
| mcp_prewarm_task: std::sync::Mutex::new(None), |
| conversation: Arc::new(RealtimeConversationManager::new()), |
| realtime_history: None, |
| active_turn: Mutex::new(None), |
| async_hook_results, |
| input_queue: super::input_queue::InputQueue::new(), |
| services, |
| git_enrichment_policy: GitEnrichmentPolicy::Fresh, |
| fork_persistence: ForkPersistence::Copied, |
| forked_from_ordinal_exclusive: None, |
| next_internal_sub_id: AtomicU64::new(0), |
| }); |
| let per_turn_config = |
| session.build_per_turn_config(&session_configuration, session_configuration.cwd().clone()); |
| let plugins_input = per_turn_config.plugins_config_input(); |
| let plugin_outcome = session |
| .services |
| .plugins_manager |
| .plugins_for_config(&plugins_input) |
| .await; |
| let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); |
| let plugin_skill_snapshots = session |
| .services |
| .plugins_manager |
| .plugin_skill_snapshots_for_config(&plugins_input); |
| let skills_input = |
| crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots) |
| .with_plugin_skill_snapshots(plugin_skill_snapshots); |
| let skill_fs = environment.get_filesystem(); |
| let skills_snapshot = session |
| .services |
| .skills_service |
| .snapshot_for_config(&skills_input, Some(Arc::clone(&skill_fs))) |
| .await; |
| let turn_context = Arc::new(Session::make_turn_context( |
| thread_id, |
| SessionId::from(thread_id), |
| Some(Arc::clone(&auth_manager)), |
| &session_telemetry, |
| session_configuration.provider.clone(), |
| &session_configuration, |
| config.multi_agent_version_from_features(), |
| session.services.user_shell.as_ref(), |
| session.services.shell_zsh_path.as_ref(), |
| session.services.main_execve_wrapper_exe.as_ref(), |
| per_turn_config, |
| Arc::new(super::step_settings::ResolvedStepSettings::new( |
| Arc::clone(&session_configuration.step_settings), |
| Arc::new(model_info), |
| config.features.enabled(Feature::FastMode), |
| )), |
| &models_manager, |
| None, |
| resolved_turn_environments, |
| session_configuration.cwd().clone(), |
| "turn_id".to_string(), |
| skills_snapshot, |
| )); |
| session.mark_mcp_runtime_dirty(); |
| crate::guardian::test_host::install(&session, &turn_context.config); |
| (session, turn_context, rx_event) |
| } |
|
|
| pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( |
| dynamic_tools: Vec<DynamicToolSpec>, |
| ) -> ( |
| Arc<Session>, |
| Arc<TurnContext>, |
| async_channel::Receiver<Event>, |
| ) { |
| make_session_and_context_with_auth_and_config_and_rx( |
| CodexAuth::from_api_key("Test API Key"), |
| dynamic_tools, |
| |_config| {}, |
| ) |
| .await |
| } |
|
|
| |
| |
| pub(crate) async fn make_session_and_context_with_rx() -> ( |
| Arc<Session>, |
| Arc<TurnContext>, |
| async_channel::Receiver<Event>, |
| ) { |
| make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await |
| } |
|
|
| #[tokio::test] |
| async fn refresh_mcp_servers_uses_latest_state_for_existing_turns() { |
| let (session, turn_context) = make_session_and_context().await; |
| let session = Arc::new(session); |
| let turn_context = Arc::new(turn_context); |
| let old_step = session |
| .capture_step_context(Arc::clone(&turn_context), &CancellationToken::new()) |
| .await |
| .expect("a fresh cancellation token cannot be cancelled"); |
|
|
| let refreshed_mcp_servers = serde_json::from_value::<HashMap<String, McpServerConfig>>(json!({ |
| "refreshed": { |
| "url": "https://refreshed.example/mcp", |
| "enabled": false |
| } |
| })) |
| .expect("parse refreshed MCP servers"); |
| { |
| let mut state = session.state.lock().await; |
| let mut config = (*state.session_configuration.original_config_do_not_use).clone(); |
| config |
| .mcp_servers |
| .set(refreshed_mcp_servers.clone()) |
| .expect("set refreshed MCP servers"); |
| config.mcp_oauth_credentials_store_mode = |
| codex_config::types::OAuthCredentialsStoreMode::Auto; |
| config |
| .features |
| .set_enabled(Feature::SecretAuthStorage, true) |
| .expect("enable secret auth storage"); |
| state.session_configuration.original_config_do_not_use = Arc::new(config); |
| } |
| session.mark_mcp_runtime_dirty(); |
|
|
| let next_turn = session.new_default_turn().await; |
| let new_step = session |
| .capture_step_context(next_turn, &CancellationToken::new()) |
| .await |
| .expect("a fresh cancellation token cannot be cancelled"); |
| assert!( |
| !Arc::ptr_eq(&old_step.mcp, &new_step.mcp), |
| "publishing a new MCP runtime must invalidate cached bindings" |
| ); |
| let refreshed_old_step = session |
| .capture_step_context(Arc::clone(&turn_context), &CancellationToken::new()) |
| .await |
| .expect("capture an existing turn after its MCP runtime is republished"); |
| assert!( |
| Arc::ptr_eq(&new_step.mcp, &refreshed_old_step.mcp), |
| "existing turns should reuse the newly published immutable MCP binding" |
| ); |
| let rematerialized_old = session |
| .mcp_runtime_for_step( |
| &turn_context, |
| &[], |
| &[], |
| &HashSet::new(), |
| ) |
| .await; |
|
|
| let configured_servers = codex_mcp::configured_mcp_servers(new_step.mcp.config()); |
| assert_eq!( |
| configured_servers.get("refreshed"), |
| refreshed_mcp_servers.get("refreshed") |
| ); |
| assert!( |
| !codex_mcp::configured_mcp_servers(old_step.mcp.config()).contains_key("refreshed"), |
| "an already-bound step must keep its captured config" |
| ); |
| assert!( |
| codex_mcp::configured_mcp_servers(rematerialized_old.config()).contains_key("refreshed"), |
| "an older turn should resolve the latest MCP state" |
| ); |
| let current = session |
| .services |
| .mcp_runtime |
| .current_binding() |
| .await |
| .expect("current MCP binding"); |
| assert!( |
| codex_mcp::configured_mcp_servers(current.config()).contains_key("refreshed"), |
| "the refreshed state should remain globally current" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn refreshed_mcp_binding_captures_current_approval_authority() { |
| let (session, old_turn) = make_session_and_context().await; |
| let session = Arc::new(session); |
| let old_turn = Arc::new(old_turn); |
| let previous_policy = old_turn.approval_policy(); |
| assert_ne!(previous_policy, AskForApproval::Never); |
| assert_eq!( |
| old_turn.config.permissions.approval_policy.value(), |
| previous_policy |
| ); |
| let old_step = session |
| .capture_step_context(Arc::clone(&old_turn), &CancellationToken::new()) |
| .await |
| .expect("capture initial sampling step"); |
|
|
| session |
| .update_settings(SessionSettingsUpdate { |
| step_settings: StepSettingsUpdate { |
| approval_policy: Some(AskForApproval::Never), |
| approvals_reviewer: Some(ApprovalsReviewer::AutoReview), |
| ..Default::default() |
| }, |
| permission_profile: Some(PermissionProfile::Disabled), |
| ..Default::default() |
| }) |
| .await |
| .expect("approval settings should update"); |
| session.refresh_mcp_if_dirty().await; |
|
|
| let binding = session |
| .services |
| .mcp_runtime |
| .current_binding() |
| .await |
| .expect("refreshed runtime should be available"); |
| assert!( |
| !Arc::ptr_eq(&old_step.mcp, &binding), |
| "changed approval authority must invalidate the cached MCP binding" |
| ); |
| let refreshed_step = session |
| .capture_step_context(Arc::clone(&old_turn), &CancellationToken::new()) |
| .await |
| .expect("capture existing turn after its approval authority changes"); |
| assert!( |
| Arc::ptr_eq(&binding, &refreshed_step.mcp), |
| "existing turns must use the MCP binding with current approval authority" |
| ); |
| let config = binding.config(); |
| assert_eq!( |
| ( |
| config.approval_policy.value(), |
| &config.permission_profile, |
| config.approvals_reviewer, |
| ), |
| ( |
| AskForApproval::Never, |
| &PermissionProfile::Disabled, |
| ApprovalsReviewer::AutoReview, |
| ) |
| ); |
| assert_eq!(old_turn.approval_policy(), previous_policy); |
| assert_eq!( |
| old_turn.config.permissions.approval_policy.value(), |
| previous_policy |
| ); |
|
|
| let new_turn = session.new_default_turn().await; |
| assert_eq!(new_turn.approval_policy(), AskForApproval::Never); |
| assert_eq!( |
| new_turn.config.permissions.approval_policy.value(), |
| AskForApproval::Never |
| ); |
| } |
|
|
| #[test_case(false; "legacy thread reviewer")] |
| #[test_case(true; "explicit live reviewer")] |
| #[tokio::test] |
| async fn mcp_elicitation_reviewer_uses_active_reviewer_and_latest_runtime_policy( |
| live_update: bool, |
| ) { |
| let guardian_server = start_mock_server().await; |
| mount_sse_once( |
| &guardian_server, |
| sse(vec![ |
| ev_response_created("guardian-review"), |
| ev_assistant_message("guardian-review", r#"{"outcome":"allow"}"#), |
| ev_completed("guardian-review"), |
| ]), |
| ) |
| .await; |
| let (session, old_turn, rx) = make_session_and_context_with_auth_and_config_and_rx( |
| CodexAuth::from_api_key("Test API Key"), |
| Vec::new(), |
| |config| { |
| config.model_provider.base_url = Some(format!("{}/v1", guardian_server.uri())); |
| config |
| .mcp_servers |
| .set( |
| serde_json::from_value(json!({ |
| "browser-use": { "command": "missing-test-mcp-server" } |
| })) |
| .expect("test MCP server configuration should deserialize"), |
| ) |
| .expect("test MCP server should be configurable"); |
| }, |
| ) |
| .await; |
| assert_eq!(old_turn.config.approvals_reviewer, ApprovalsReviewer::User); |
| session |
| .spawn_task( |
| Arc::clone(&old_turn), |
| Vec::new(), |
| NeverEndingTask { |
| kind: TaskKind::Regular, |
| listen_to_cancellation_token: true, |
| }, |
| ) |
| .await; |
|
|
| session.mark_mcp_runtime_dirty(); |
| session.refresh_mcp_if_dirty().await; |
| if live_update { |
| assert_eq!( |
| session |
| .apply_turn_settings( |
| &old_turn.sub_id, |
| codex_protocol::protocol::TurnSettingsUpdate { |
| approvals_reviewer: Some(ApprovalsReviewer::AutoReview), |
| ..Default::default() |
| }, |
| ) |
| .await, |
| codex_protocol::protocol::TurnSettingsUpdateOutcome::Applied |
| ); |
| } else { |
| session |
| .update_settings(SessionSettingsUpdate { |
| step_settings: StepSettingsUpdate { |
| approvals_reviewer: Some(ApprovalsReviewer::AutoReview), |
| ..Default::default() |
| }, |
| ..Default::default() |
| }) |
| .await |
| .expect("reviewer settings should update"); |
| session.refresh_mcp_if_dirty().await; |
| } |
|
|
| let request = codex_mcp::ElicitationReviewRequest { |
| server_name: "browser-use".to_string(), |
| request_id: rmcp::model::NumberOrString::Number(7), |
| elicitation: codex_rmcp_client::Elicitation::Mcp( |
| rmcp::model::ElicitRequestParams::FormElicitationParams { |
| meta: Some(rmcp::model::RequestMetaObject::from( |
| serde_json::Map::from_iter([ |
| ("codex_approval_kind".to_string(), json!("mcp_tool_call")), |
| ("codex_request_type".to_string(), json!("approval_request")), |
| ("tool_name".to_string(), json!("access_browser_origin")), |
| ]), |
| )), |
| message: "Allow origin?".to_string(), |
| requested_schema: rmcp::model::ElicitationSchema::builder() |
| .build() |
| .expect("schema should build"), |
| }, |
| ), |
| }; |
| assert!( |
| session |
| .mcp_elicitation_reviewer() |
| .review(request.clone()) |
| .await |
| .expect("elicitation review should succeed") |
| .is_some() |
| ); |
| assert!( |
| std::iter::from_fn(|| rx.try_recv().ok()) |
| .any(|event| matches!(event.msg, EventMsg::GuardianAssessment(_))), |
| "a valid elicitation should reach Guardian" |
| ); |
|
|
| session |
| .update_settings(SessionSettingsUpdate { |
| step_settings: StepSettingsUpdate { |
| approval_policy: Some(AskForApproval::Never), |
| ..Default::default() |
| }, |
| ..Default::default() |
| }) |
| .await |
| .expect("approval policy should update"); |
| session.refresh_mcp_if_dirty().await; |
| assert_eq!( |
| session |
| .mcp_elicitation_reviewer() |
| .review(request.clone()) |
| .await |
| .expect("elicitation review should succeed"), |
| Some(ElicitationResponse { |
| action: ElicitationAction::Decline, |
| content: None, |
| meta: Some(json!({ "approvals_reviewer": "auto_review" })), |
| }) |
| ); |
|
|
| session |
| .update_settings(SessionSettingsUpdate { |
| permission_profile: Some(PermissionProfile::Disabled), |
| ..Default::default() |
| }) |
| .await |
| .expect("permission profile should update"); |
| session.refresh_mcp_if_dirty().await; |
| assert_eq!( |
| session |
| .mcp_elicitation_reviewer() |
| .review(request.clone()) |
| .await |
| .expect("elicitation review should succeed"), |
| Some(ElicitationResponse { |
| action: ElicitationAction::Accept, |
| content: Some(json!({})), |
| meta: None, |
| }) |
| ); |
|
|
| let selection = session |
| .services |
| .turn_environments |
| .selections() |
| .into_iter() |
| .next() |
| .expect("session should select its executor environment"); |
| let mut owner_config = old_turn |
| .environments |
| .primary() |
| .expect("ready environment") |
| .config() |
| .clone(); |
| owner_config.permission_profile = |
| PermissionProfileSnapshot::legacy(PermissionProfile::read_only()); |
| session |
| .environment_ready(&selection, owner_config) |
| .await |
| .expect("attachment owner should install its restricted permissions"); |
| session.refresh_mcp_if_dirty().await; |
| assert_eq!( |
| session |
| .mcp_elicitation_reviewer() |
| .review(request) |
| .await |
| .expect("elicitation review should succeed"), |
| Some(ElicitationResponse { |
| action: ElicitationAction::Decline, |
| content: None, |
| meta: Some(json!({ "approvals_reviewer": "auto_review" })), |
| }) |
| ); |
|
|
| session.abort_all_tasks(TurnAbortReason::Interrupted).await; |
| } |
|
|
| #[tokio::test] |
| async fn cancelled_mcp_refresh_remains_pending() { |
| let (session, _turn_context) = make_session_and_context().await; |
| let session = Arc::new(session); |
|
|
| { |
| let _state = session.state.lock().await; |
| { |
| let mut refresh = Box::pin(session.refresh_mcp_if_dirty()); |
| let mut context = std::task::Context::from_waker(futures::task::noop_waker_ref()); |
| assert!(std::future::Future::poll(refresh.as_mut(), &mut context).is_pending()); |
| assert!( |
| !session.mcp_refresh.is_pending(), |
| "the refresh should have claimed its pending invalidation" |
| ); |
| } |
| } |
|
|
| assert!( |
| session.mcp_refresh.is_pending(), |
| "a cancelled refresh must leave the runtime dirty" |
| ); |
|
|
| session.refresh_mcp_if_dirty().await; |
| assert!( |
| !session.mcp_refresh.is_pending(), |
| "the next refresh should publish the pending runtime" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn mcp_elicitation_reviewer_is_reused_across_runtime_refreshes() { |
| let (session, _turn_context) = make_session_and_context().await; |
| let session = Arc::new(session); |
| let previous = session.mcp_elicitation_reviewer(); |
|
|
| session.mark_mcp_runtime_dirty(); |
| session.refresh_mcp_if_dirty().await; |
|
|
| assert!(Arc::ptr_eq(&previous, &session.mcp_elicitation_reviewer())); |
| } |
|
|
| #[tokio::test] |
| async fn mcp_policy_changes_schedule_runtime_refresh() { |
| let (session, _turn_context) = make_session_and_context().await; |
| let session = Arc::new(session); |
|
|
| session |
| .new_turn_with_sub_id( |
| "policy-change".to_string(), |
| SessionSettingsUpdate { |
| step_settings: StepSettingsUpdate { |
| approval_policy: Some(AskForApproval::Never), |
| ..Default::default() |
| }, |
| ..Default::default() |
| }, |
| Default::default(), |
| ) |
| .await |
| .expect("approval policy update should succeed"); |
|
|
| assert!(session.mcp_refresh.is_pending()); |
| } |
|
|
| #[tokio::test] |
| async fn mcp_refresh_detects_shared_auth_manager_changes() { |
| let (session, _turn_context) = make_session_and_context().await; |
| let session = Arc::new(session); |
|
|
| assert_eq!( |
| session.services.plugins_manager.auth_mode(), |
| Some(codex_protocol::auth::AuthMode::ApiKey) |
| ); |
| session.refresh_mcp_if_dirty().await; |
| assert!( |
| session |
| .services |
| .mcp_runtime |
| .current_auth_matches(session.services.auth_manager.auth_cached().as_ref()) |
| ); |
|
|
| session |
| .services |
| .auth_manager |
| .logout() |
| .await |
| .expect("logout should succeed"); |
| assert_eq!(session.services.plugins_manager.auth_mode(), None); |
| assert!( |
| !session |
| .services |
| .mcp_runtime |
| .current_auth_matches(session.services.auth_manager.auth_cached().as_ref()) |
| ); |
|
|
| session.refresh_mcp_if_dirty().await; |
|
|
| assert!( |
| session |
| .services |
| .mcp_runtime |
| .current_binding() |
| .await |
| .is_some() |
| ); |
| assert!( |
| session |
| .services |
| .mcp_runtime |
| .current_auth_matches(session.services.auth_manager.auth_cached().as_ref()) |
| ); |
| } |
|
|
| #[tokio::test] |
| #[tracing_test::traced_test] |
| async fn conflicting_ready_environment_root_ids_keep_first_location() { |
| let (session, turn_context) = make_session_and_context().await; |
| let selected_root = |
| |environment_id: &str, path: &str| codex_protocol::capabilities::SelectedCapabilityRoot { |
| id: "shared-root".to_string(), |
| location: codex_protocol::capabilities::CapabilityRootLocation::Environment { |
| environment_id: environment_id.to_string(), |
| path: PathUri::parse(path).expect("root URI"), |
| }, |
| }; |
| let selected_roots = [ |
| selected_root("executor-a", "file:///plugins/a"), |
| selected_root("executor-b", "file:///plugins/b"), |
| ]; |
| let local_environment = turn_context |
| .environments |
| .primary() |
| .expect("ready local environment"); |
| let mut turn_environments = Vec::new(); |
| for selected_root in &selected_roots { |
| let codex_protocol::capabilities::CapabilityRootLocation::Environment { |
| environment_id, |
| .. |
| } = &selected_root.location; |
| let mut environment_config = local_environment.config().clone(); |
| environment_config.selected_capability_roots = vec![selected_root.clone()]; |
| turn_environments.push(TurnEnvironment::new( |
| TurnEnvironmentSelection { |
| environment_id: environment_id.clone(), |
| cwd: local_environment.cwd().clone(), |
| workspace_roots: local_environment.workspace_roots().to_vec(), |
| config: EnvironmentConfigState::Ready(environment_config.clone()), |
| }, |
| EnvironmentConfigOrigin::Owner, |
| Arc::new( |
| codex_exec_server::Environment::create_for_tests( None) |
| .expect("create test environment"), |
| ), |
| local_environment.shell.clone(), |
| )); |
| } |
| let environments = TurnEnvironmentSnapshot { |
| environments: turn_environments |
| .into_iter() |
| .map(TurnEnvironmentState::Ready) |
| .collect(), |
| }; |
|
|
| let resolved_roots = session |
| .resolve_selected_capability_roots_for_step(&environments) |
| .await; |
|
|
| assert_eq!( |
| resolved_roots |
| .iter() |
| .map(|root| root.selected_root().clone()) |
| .collect::<Vec<_>>(), |
| vec![selected_roots[0].clone()] |
| ); |
| logs_assert(|lines: &[&str]| { |
| lines |
| .iter() |
| .find(|line| { |
| line.contains("ignoring selected capability root with conflicting location") |
| && line.contains("root_id=\"shared-root\"") |
| }) |
| .map(|_| Ok(())) |
| .unwrap_or_else(|| Err("expected conflicting root location warning".to_string())) |
| }); |
| } |
|
|
| |
| |
| #[tokio::test] |
| async fn capability_discovery_uses_environment_permission_profile() { |
| let (session, mut turn_context) = make_session_and_context().await; |
| let config = Arc::make_mut(&mut turn_context.config); |
| config |
| .permissions |
| .set_permission_profile(PermissionProfile::Disabled) |
| .expect("unrestricted permission profile should be allowed"); |
| config.permissions.windows_sandbox_mode = Some(WindowsSandboxModeToml::Unelevated); |
| config.permissions.windows_sandbox_private_desktop = true; |
| config |
| .features |
| .disable(Feature::UseLegacyLandlock) |
| .expect("disable legacy Landlock"); |
| turn_context.windows_sandbox_level = WindowsSandboxLevel::RestrictedToken; |
| let mut environment = turn_context |
| .environments |
| .primary() |
| .expect("primary environment") |
| .clone(); |
| let mut file_system_policy = PermissionProfile::read_only().file_system_sandbox_policy(); |
| file_system_policy.entries.push(FileSystemSandboxEntry { |
| path: FileSystemPath::GlobPattern { |
| pattern: "**/*.env".to_string(), |
| }, |
| access: FileSystemAccessMode::Deny, |
| missing_path_behavior: None, |
| }); |
| let environment_config = environment.config_mut(); |
| environment_config.permission_profile = |
| PermissionProfileSnapshot::legacy(PermissionProfile::from_runtime_permissions( |
| &file_system_policy, |
| NetworkSandboxPolicy::Restricted, |
| )); |
| environment_config.windows_sandbox_level = WindowsSandboxLevel::Elevated; |
| environment_config.windows_sandbox_private_desktop = false; |
| environment_config.use_legacy_landlock = true; |
| let expected_sandbox = FileSystemSandboxContext { |
| permissions: environment.permission_profile().clone().into(), |
| cwd: Some(environment.cwd().clone()), |
| workspace_roots: environment.workspace_roots().to_vec(), |
| user_home_dir: environment.user_home_dir.clone(), |
| temporary_directories: environment.temporary_directories.clone(), |
| windows_sandbox_selection: if cfg!(windows) { |
| codex_file_system::WindowsSandboxSelection::Elevated |
| } else { |
| codex_file_system::WindowsSandboxSelection::Disabled |
| }, |
| windows_sandbox_private_desktop: false, |
| windows_sandbox_proxy_settings_mode: None, |
| use_legacy_landlock: true, |
| }; |
| let environment_id = environment.selection.environment_id.clone(); |
| turn_context.environments.environments[0] = TurnEnvironmentState::Ready(environment); |
|
|
| let discovery = session |
| .executor_capability_discovery_for_step( |
| &turn_context.config, |
| &[], |
| &turn_context.environments, |
| ) |
| .await |
| .expect("restricted environment should trigger capability discovery"); |
|
|
| assert_eq!( |
| discovery.sandbox_contexts().get(&environment_id), |
| Some(&expected_sandbox) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn step_context_keeps_its_mcp_runtime_for_tools() -> anyhow::Result<()> { |
| let (session, turn_context) = make_session_and_context().await; |
| let session = Arc::new(session); |
| let turn_context = Arc::new(turn_context); |
| let step_context = session |
| .capture_step_context(turn_context, &CancellationToken::new()) |
| .await?; |
|
|
| let mut refresh_config = step_context.turn.config.as_ref().clone(); |
| refresh_config.mcp_servers.set(HashMap::from([( |
| "newer".to_string(), |
| McpServerConfig { |
| auth: Default::default(), |
| transport: McpServerTransportConfig::Stdio { |
| command: "missing-test-mcp-server".to_string(), |
| args: Vec::new(), |
| env: None, |
| env_vars: Vec::new(), |
| cwd: None, |
| }, |
| environment_id: DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), |
| enabled: true, |
| required: false, |
| supports_parallel_tool_calls: false, |
| omit_tools_from: None, |
| disabled_reason: None, |
| startup_timeout_sec: None, |
| tool_timeout_sec: None, |
| default_tools_approval_mode: None, |
| enabled_tools: None, |
| disabled_tools: None, |
| scopes: None, |
| oauth: None, |
| oauth_resource: None, |
| tools: HashMap::new(), |
| }, |
| )]))?; |
| session |
| .refresh_mcp_servers_now( |
| step_context.turn.as_ref(), |
| &refresh_config, |
| None, |
| ) |
| .await; |
|
|
| let next_step = session |
| .capture_step_context(Arc::clone(&step_context.turn), &CancellationToken::new()) |
| .await |
| .expect("a fresh cancellation token cannot be cancelled"); |
| assert!(codex_mcp::configured_mcp_servers(next_step.mcp.config()).contains_key("newer")); |
|
|
| session.mark_mcp_runtime_dirty(); |
| session.refresh_mcp_if_dirty().await; |
| let current = session |
| .services |
| .mcp_runtime |
| .current_binding() |
| .await |
| .expect("refreshed runtime should be available"); |
| assert!(codex_mcp::configured_mcp_servers(current.config()).contains_key("newer")); |
|
|
| let router = &step_context.tool_router; |
| assert!( |
| !router |
| .registered_tool_names_for_test() |
| .iter() |
| .any(|name| name.to_string() == "list_mcp_resources") |
| ); |
| Ok(()) |
| } |
|
|
| #[tokio::test] |
| async fn spawn_task_does_not_update_previous_turn_settings_for_non_run_turn_tasks() { |
| let (sess, tc, _rx) = make_session_and_context_with_rx().await; |
| sess.set_previous_turn_settings( None) |
| .await; |
| let input = vec![TurnInput::UserInput { |
| acceptance_order: None, |
| content: vec![UserInput::Text { |
| text: "hello".to_string(), |
| text_elements: Vec::new(), |
| }], |
| client_id: None, |
| }]; |
|
|
| sess.spawn_task( |
| Arc::clone(&tc), |
| input, |
| NeverEndingTask { |
| kind: TaskKind::Regular, |
| listen_to_cancellation_token: true, |
| }, |
| ) |
| .await; |
|
|
| sess.abort_all_tasks(TurnAbortReason::Interrupted).await; |
| assert_eq!(sess.previous_turn_settings().await, None); |
| } |
|
|
| #[tokio::test] |
| async fn record_context_updates_emits_environment_item_for_network_changes() { |
| let (session, previous_context) = make_session_and_context().await; |
| let previous_context = Arc::new(previous_context); |
| let mut current_context = previous_context |
| .with_model( |
| previous_context.model_info().slug.clone(), |
| &session.services.models_manager, |
| ) |
| .await; |
|
|
| let mut config = (*current_context.config).clone(); |
| let mut requirements = config.config_layer_stack.requirements().clone(); |
| requirements.network = Some(Sourced::new( |
| NetworkConstraints { |
| domains: Some(NetworkDomainPermissionsToml { |
| entries: std::collections::BTreeMap::from([ |
| ( |
| "api.example.com".to_string(), |
| NetworkDomainPermissionToml::Allow, |
| ), |
| ( |
| "blocked.example.com".to_string(), |
| NetworkDomainPermissionToml::Deny, |
| ), |
| ]), |
| }), |
| ..Default::default() |
| }, |
| RequirementSource::LegacyManagedConfigTomlFromMdm, |
| )); |
| let layers = config |
| .config_layer_stack |
| .all_layers_low_to_high() |
| .cloned() |
| .collect(); |
| config.config_layer_stack = ConfigLayerStack::new( |
| layers, |
| requirements, |
| config.config_layer_stack.requirements_toml().clone(), |
| ) |
| .expect("rebuild config layer stack with network requirements"); |
| current_context.config = Arc::new(config); |
|
|
| let update_items = |
| record_context_update_items(&session, previous_context, current_context).await; |
|
|
| 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\"><allowed>api.example.com</allowed><denied>blocked.example.com</denied></network>" |
| )); |
| } |
|
|
| #[tokio::test] |
| async fn record_context_updates_emits_environment_item_for_cwd_changes() { |
| let (session, previous_context) = make_session_and_context().await; |
| let previous_context = Arc::new(previous_context); |
| let mut current_context = previous_context |
| .with_model( |
| previous_context.model_info().slug.clone(), |
| &session.services.models_manager, |
| ) |
| .await; |
| let cwd = test_path_buf("/new-repo").abs(); |
| let environment = current_context |
| .environments |
| .primary() |
| .expect("primary environment") |
| .clone(); |
| let environment_config = environment.config().clone(); |
| current_context.environments.environments[0] = |
| TurnEnvironmentState::Ready(TurnEnvironment::new( |
| TurnEnvironmentSelection { |
| environment_id: environment.selection.environment_id, |
| cwd: PathUri::from_abs_path(&cwd), |
| workspace_roots: Vec::new(), |
| config: EnvironmentConfigState::Ready(environment_config), |
| }, |
| environment.config_origin, |
| environment.environment, |
| environment.shell, |
| )); |
|
|
| let update_items = |
| record_context_update_items(&session, previous_context, current_context).await; |
|
|
| 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(&format!("<cwd>{}</cwd>", cwd.display())), |
| "{environment_update}" |
| ); |
| assert!(!environment_update.contains("<environments>")); |
| } |
|
|
| #[tokio::test] |
| async fn record_context_updates_use_environment_permission_profile_and_workspace_roots() { |
| let (session, mut previous_context) = make_session_and_context().await; |
| Arc::make_mut(&mut previous_context.config) |
| .permissions |
| .set_permission_profile(PermissionProfile::Disabled) |
| .expect("unrestricted permission profile should be allowed"); |
| let mut previous_environment = previous_context |
| .environments |
| .primary() |
| .expect("primary environment") |
| .clone(); |
| previous_environment.config_mut().permission_profile = |
| PermissionProfileSnapshot::legacy(PermissionProfile::Disabled); |
| previous_context.environments.environments[0] = |
| TurnEnvironmentState::Ready(previous_environment); |
| 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 environment = current_context |
| .environments |
| .primary() |
| .expect("primary environment") |
| .clone(); |
| let cwd = environment.cwd().clone(); |
| let workspace_root = current_context.config.cwd.join("selected-workspace"); |
| let mut environment_config = environment.config().clone(); |
| environment_config.workspace_roots = vec![PathUri::from_abs_path(&workspace_root)]; |
| environment_config.permission_profile = |
| PermissionProfileSnapshot::legacy(PermissionProfile::workspace_write()); |
| current_context.environments.environments[0] = |
| TurnEnvironmentState::Ready(TurnEnvironment::new( |
| TurnEnvironmentSelection { |
| environment_id: environment.selection.environment_id, |
| cwd, |
| workspace_roots: vec![PathUri::from_abs_path(&workspace_root)], |
| config: EnvironmentConfigState::Ready(environment_config), |
| }, |
| environment.config_origin, |
| environment.environment, |
| environment.shell, |
| )); |
|
|
| let update_items = |
| record_context_update_items(&session, previous_context, current_context).await; |
| let permissions_update = developer_input_texts(&update_items) |
| .into_iter() |
| .find(|text| text.contains("<permissions instructions>")) |
| .expect("permissions update should be emitted"); |
| assert!( |
| permissions_update.contains(workspace_root.to_string_lossy().as_ref()), |
| "selected workspace root should be visible in permissions: {permissions_update}" |
| ); |
| let environment_update = user_input_texts(&update_items) |
| .into_iter() |
| .find(|text| text.contains("<environment_context>")) |
| .expect("environment update should be emitted"); |
| assert!( |
| environment_update.contains("<permission_profile type=\"managed\">") |
| && environment_update.contains(workspace_root.to_string_lossy().as_ref()), |
| "selected environment permissions should be visible: {environment_update}" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn record_context_updates_emits_environment_item_for_time_changes() { |
| let (session, previous_context) = make_session_and_context().await; |
| let previous_context = Arc::new(previous_context); |
| let mut current_context = previous_context |
| .with_model( |
| previous_context.model_info().slug.clone(), |
| &session.services.models_manager, |
| ) |
| .await; |
| current_context.timezone = Some("Europe/Berlin".to_string()); |
|
|
| let update_items = |
| record_context_update_items(&session, previous_context, current_context).await; |
|
|
| let environment_update = user_input_texts(&update_items) |
| .into_iter() |
| .find(|text| text.contains("<environment_context>")) |
| .expect("environment update item should be emitted"); |
| let current_date = chrono::Local::now().format("%Y-%m-%d").to_string(); |
| assert!(environment_update.contains(&format!("<current_date>{current_date}</current_date>"))); |
| assert!(environment_update.contains("<timezone>Europe/Berlin</timezone>")); |
| } |
|
|
| #[tokio::test] |
| async fn record_context_updates_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); |
| let environment = current_context |
| .environments |
| .primary() |
| .expect("primary environment") |
| .clone(); |
| let environment_config = environment.config().clone(); |
| current_context.environments.environments[0] = |
| TurnEnvironmentState::Ready(TurnEnvironment::new( |
| TurnEnvironmentSelection { |
| environment_id: environment.selection.environment_id, |
| cwd: PathUri::from_abs_path(&test_path_buf("/new-repo").abs()), |
| workspace_roots: Vec::new(), |
| config: EnvironmentConfigState::Ready(environment_config), |
| }, |
| environment.config_origin, |
| environment.environment, |
| environment.shell, |
| )); |
|
|
| let update_items = |
| record_context_update_items(&session, previous_context, 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:?}" |
| ); |
| } |
|
|
| async fn record_context_update_items( |
| session: &Session, |
| previous_context: Arc<TurnContext>, |
| current_context: TurnContext, |
| ) -> Vec<ResponseItem> { |
| let previous_step = StepContext::for_test(previous_context); |
| session |
| .record_context_updates_and_set_reference_context_item(&previous_step) |
| .await |
| .expect("world state should build"); |
| let previous_len = session.clone_history().await.raw_items().len(); |
|
|
| let current_step = StepContext::for_test(Arc::new(current_context)); |
| session |
| .record_context_updates_and_set_reference_context_item(¤t_step) |
| .await |
| .expect("world state should build"); |
| let history = session.clone_history().await; |
| history.raw_items().skip(previous_len).cloned().collect() |
| } |
|
|
| #[tokio::test] |
| async fn record_context_updates_emits_realtime_start_when_session_becomes_live() { |
| let (session, previous_context) = make_session_and_context().await; |
| let previous_context = Arc::new(previous_context); |
| let mut current_context = previous_context |
| .with_model( |
| previous_context.model_info().slug.clone(), |
| &session.services.models_manager, |
| ) |
| .await; |
| current_context.realtime_active = true; |
|
|
| let update_items = |
| record_context_update_items(&session, previous_context, current_context).await; |
|
|
| let developer_texts = developer_input_texts(&update_items); |
| assert!( |
| developer_texts |
| .iter() |
| .any(|text| text.contains("<realtime_conversation>")), |
| "expected a realtime start update, got {developer_texts:?}" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn record_context_updates_emits_realtime_end_when_session_stops_being_live() { |
| let (session, mut previous_context) = make_session_and_context().await; |
| previous_context.realtime_active = true; |
| let mut current_context = previous_context |
| .with_model( |
| previous_context.model_info().slug.clone(), |
| &session.services.models_manager, |
| ) |
| .await; |
| current_context.realtime_active = false; |
|
|
| let update_items = |
| record_context_update_items(&session, Arc::new(previous_context), current_context).await; |
|
|
| let developer_texts = developer_input_texts(&update_items); |
| assert!( |
| developer_texts |
| .iter() |
| .any(|text| text.contains("<realtime_conversation>")), |
| "expected a realtime end update, got {developer_texts:?}" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn build_initial_context_reuses_in_flight_recommendation_prewarm() { |
| use wiremock::Mock; |
| use wiremock::ResponseTemplate; |
| use wiremock::matchers::method; |
| use wiremock::matchers::path; |
| use wiremock::matchers::query_param; |
|
|
| core_test_support::skip_if_no_network!(); |
|
|
| let server = start_mock_server().await; |
| Mock::given(method("GET")) |
| .and(path("/ps/plugins/suggested/codex")) |
| .and(query_param("scope", "GLOBAL")) |
| .respond_with(ResponseTemplate::new(200).set_body_json(json!({ |
| "enabled": true, |
| "plugins": [{ |
| "id": "plugin_github", |
| "name": "github", |
| "display_name": "GitHub" |
| }] |
| }))) |
| .expect(1) |
| .mount(&server) |
| .await; |
| let (session, turn_context, _rx_event) = make_session_and_context_with_auth_and_config_and_rx( |
| CodexAuth::create_dummy_chatgpt_auth_for_testing(), |
| Vec::new(), |
| |config| { |
| config.chatgpt_base_url = server.uri(); |
| config |
| .features |
| .disable(Feature::ToolSuggest) |
| .expect("test config should allow feature update"); |
| for enabled_feature in [ |
| Feature::Apps, |
| Feature::Plugins, |
| Feature::RemotePlugin, |
| Feature::RecommendedPlugins, |
| ] { |
| config |
| .features |
| .enable(enabled_feature) |
| .expect("test config should allow feature update"); |
| } |
| }, |
| ) |
| .await; |
| let plugins_manager = &session.services.plugins_manager; |
| let plugins_config = turn_context.config.plugins_config_input(); |
| let auth = session.services.auth_manager.auth().await; |
| |
| |
| plugins_manager.plugins_for_config(&plugins_config).await; |
| let prewarm = |
| plugins_manager.recommended_plugins_mode_for_config(&plugins_config, auth.as_ref()); |
| tokio::pin!(prewarm); |
| assert!(futures::poll!(prewarm.as_mut()).is_pending()); |
|
|
| |
| |
| let world_state = WorldState::default(); |
| let step_context = StepContext::for_test(Arc::clone(&turn_context)); |
| let initial_context = |
| session.build_initial_context_with_world_state(&step_context, &world_state); |
| tokio::pin!(initial_context); |
| assert!(futures::poll!(initial_context.as_mut()).is_pending()); |
|
|
| let (_, initial_context) = tokio::join!(prewarm, initial_context); |
| assert_eq!( |
| user_input_texts(&initial_context), |
| vec![concat!( |
| "<recommended_plugins>\n", |
| "Here is a list of plugins that are available but not installed.\n\n", |
| "- GitHub (github@openai-curated-remote)\n", |
| "</recommended_plugins>", |
| )] |
| ); |
| server.verify().await; |
| } |
|
|
| #[tokio::test] |
| async fn build_initial_context_describes_active_realtime_state() { |
| let (session, mut turn_context) = make_session_and_context().await; |
| turn_context.realtime_active = true; |
| let turn_context = Arc::new(turn_context); |
|
|
| let initial_context = build_initial_context(&session, &turn_context).await; |
| let developer_texts = developer_input_texts(&initial_context); |
| assert!( |
| developer_texts |
| .iter() |
| .any(|text| text.contains("<realtime_conversation>")), |
| "expected initial context to describe active realtime state, got {developer_texts:?}" |
| ); |
| } |
|
|
| async fn make_multi_agent_v2_usage_hint_test_session( |
| enable_multi_agent_v2: bool, |
| ) -> (Arc<Session>, Arc<TurnContext>) { |
| let (session, turn_context, _rx_event) = make_session_and_context_with_auth_and_config_and_rx( |
| CodexAuth::from_api_key("Test API Key"), |
| Vec::new(), |
| |config| { |
| if enable_multi_agent_v2 { |
| let _ = config.features.enable(Feature::MultiAgentV2); |
| } |
| config.multi_agent_v2.root_agent_usage_hint_text = Some("Root guidance.".to_string()); |
| config.multi_agent_v2.subagent_usage_hint_text = Some("Subagent guidance.".to_string()); |
| }, |
| ) |
| .await; |
| (session, turn_context) |
| } |
|
|
| struct PromptExtensionTestContributor; |
| struct PromptExtensionTestState; |
| struct TurnContextExtensionTestContributor; |
| struct TurnContextExtensionTestState { |
| expected_model_context_window: Option<i64>, |
| } |
|
|
| impl codex_extension_api::ContextContributor for PromptExtensionTestContributor { |
| fn contribute_thread_context<'a>( |
| &'a self, |
| _session_store: &'a codex_extension_api::ExtensionData, |
| thread_store: &'a codex_extension_api::ExtensionData, |
| ) -> std::pin::Pin< |
| Box<dyn std::future::Future<Output = Vec<codex_extension_api::PromptFragment>> + Send + 'a>, |
| > { |
| Box::pin(async move { |
| thread_store |
| .get::<PromptExtensionTestState>() |
| .is_some() |
| .then(|| { |
| codex_extension_api::PromptFragment::developer_policy( |
| "prompt extension enabled", |
| codex_extension_api::ContentItemKind("test.prompt_extension".to_string()), |
| ) |
| }) |
| .into_iter() |
| .collect() |
| }) |
| } |
| } |
|
|
| fn prompt_extension_test_registry() |
| -> Arc<codex_extension_api::ExtensionRegistry<crate::config::Config>> { |
| let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); |
| builder.prompt_contributor(Arc::new(PromptExtensionTestContributor)); |
| Arc::new(builder.build()) |
| } |
|
|
| impl codex_extension_api::ContextContributor for TurnContextExtensionTestContributor { |
| fn contribute_turn_context<'a>( |
| &'a self, |
| input: codex_extension_api::TurnContextContributionInput<'a>, |
| ) -> std::pin::Pin< |
| Box<dyn std::future::Future<Output = Vec<codex_extension_api::PromptFragment>> + Send + 'a>, |
| > { |
| Box::pin(async move { |
| let Some(state) = input.turn_store.get::<TurnContextExtensionTestState>() else { |
| return Vec::new(); |
| }; |
| (input.model_context_window == state.expected_model_context_window |
| && input.model_context_window.is_some() |
| && !input.turn_id.is_empty()) |
| .then(|| { |
| codex_extension_api::PromptFragment::developer_policy( |
| "turn context extension enabled", |
| codex_extension_api::ContentItemKind("test.turn_context".to_string()), |
| ) |
| }) |
| .into_iter() |
| .collect() |
| }) |
| } |
| } |
|
|
| #[tokio::test] |
| async fn build_initial_context_includes_prompt_fragments_from_extensions() { |
| let (mut session, turn_context) = make_session_and_context().await; |
| session.services.extensions = prompt_extension_test_registry(); |
| session |
| .services |
| .thread_extension_data |
| .insert(PromptExtensionTestState); |
| let turn_context = Arc::new(turn_context); |
|
|
| let initial_context = build_initial_context(&session, &turn_context).await; |
| let developer_messages = developer_message_texts(&initial_context); |
|
|
| assert!( |
| developer_messages |
| .iter() |
| .flatten() |
| .any(|text| *text == "prompt extension enabled"), |
| "expected prompt extension developer text, got {developer_messages:?}" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn build_initial_context_includes_turn_context_fragments_from_extensions() { |
| let (mut session, mut turn_context) = make_session_and_context().await; |
| let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); |
| builder.prompt_contributor(Arc::new(TurnContextExtensionTestContributor)); |
| session.services.extensions = Arc::new(builder.build()); |
| update_turn_settings_for_test(&mut turn_context, |settings| { |
| Arc::make_mut(&mut settings.model_info).context_window = Some(100); |
| Arc::make_mut(&mut settings.model_info).effective_context_window_percent = 50; |
| }); |
| turn_context |
| .extension_data |
| .insert(TurnContextExtensionTestState { |
| expected_model_context_window: Some(50), |
| }); |
| let turn_context = Arc::new(turn_context); |
|
|
| let initial_context = build_initial_context(&session, &turn_context).await; |
| let developer_messages = developer_message_texts(&initial_context); |
|
|
| assert!( |
| developer_messages |
| .iter() |
| .flatten() |
| .any(|text| *text == "turn context extension enabled"), |
| "expected turn context extension developer text, got {developer_messages:?}" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn record_context_updates_includes_turn_context_fragments_on_steady_state_turns() { |
| let (mut session, mut turn_context) = make_session_and_context().await; |
| let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); |
| builder.prompt_contributor(Arc::new(TurnContextExtensionTestContributor)); |
| session.services.extensions = Arc::new(builder.build()); |
| update_turn_settings_for_test(&mut turn_context, |settings| { |
| Arc::make_mut(&mut settings.model_info).context_window = Some(200); |
| Arc::make_mut(&mut settings.model_info).effective_context_window_percent = 25; |
| }); |
| turn_context |
| .extension_data |
| .insert(TurnContextExtensionTestState { |
| expected_model_context_window: Some(50), |
| }); |
| let mut previous_context_item = turn_context.to_turn_context_item(); |
| previous_context_item.turn_id = Some("previous-turn-id".to_string()); |
| let turn_context = Arc::new(turn_context); |
| let world_state = build_world_state_from_turn_context(&session, &turn_context).await; |
| { |
| let mut state = session.state.lock().await; |
| state.set_reference_context_item(Some(previous_context_item)); |
| state |
| .history |
| .set_world_state_baseline(world_state.snapshot()); |
| } |
|
|
| let step_context = StepContext::for_test(Arc::clone(&turn_context)); |
| session |
| .record_context_updates_and_set_reference_context_item(&step_context) |
| .await |
| .expect("world state should build"); |
|
|
| let history = session.clone_history().await; |
| let history_items = raw_history_items(&history); |
| let developer_messages = developer_message_texts(&history_items); |
| assert!( |
| developer_messages |
| .iter() |
| .flatten() |
| .any(|text| *text == "turn context extension enabled"), |
| "expected steady-state turn context extension developer text, got {developer_messages:?}" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn build_initial_context_omits_prompt_fragments_without_extension_state() { |
| let (mut session, turn_context) = make_session_and_context().await; |
| session.services.extensions = prompt_extension_test_registry(); |
| let turn_context = Arc::new(turn_context); |
|
|
| let initial_context = build_initial_context(&session, &turn_context).await; |
| let developer_messages = developer_message_texts(&initial_context); |
|
|
| assert!( |
| !developer_messages |
| .iter() |
| .flatten() |
| .any(|text| *text == "prompt extension enabled"), |
| "did not expect prompt extension developer text, got {developer_messages:?}" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn build_initial_context_adds_multi_agent_v2_root_usage_hint_as_developer_message() { |
| let (session, turn_context) = |
| make_multi_agent_v2_usage_hint_test_session( true).await; |
|
|
| let initial_context = build_initial_context(&session, &turn_context).await; |
|
|
| let developer_messages = developer_message_texts(&initial_context); |
| assert!( |
| developer_messages |
| .iter() |
| .any(|message| message.as_slice() == ["Root guidance."]), |
| "expected standalone root usage hint developer message, got {developer_messages:?}" |
| ); |
| assert!( |
| !developer_messages |
| .iter() |
| .any(|message| message.as_slice() == ["Subagent guidance."]), |
| "did not expect subagent usage hint for root thread, got {developer_messages:?}" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn build_initial_context_adds_multi_agent_v2_subagent_usage_hint_as_developer_message() { |
| let (session, mut turn_context) = |
| make_multi_agent_v2_usage_hint_test_session( true).await; |
| let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id: ThreadId::new(), |
| depth: 1, |
| agent_path: Some(AgentPath::try_from("/root/worker").expect("agent path should parse")), |
| agent_nickname: Some("worker".to_string()), |
| agent_role: None, |
| }); |
| session |
| .state |
| .lock() |
| .await |
| .session_configuration |
| .session_source = session_source.clone(); |
| let turn_context_mut = |
| Arc::get_mut(&mut turn_context).expect("thread settings should not be shared"); |
| turn_context_mut.session_source = session_source; |
| let config = Arc::make_mut(&mut turn_context_mut.config); |
| config.token_budget = Some(crate::config::TokenBudgetConfig::default()); |
| config |
| .features |
| .enable(Feature::TokenBudget) |
| .expect("test config should allow token budget"); |
|
|
| let initial_context = build_initial_context(&session, &turn_context).await; |
|
|
| let developer_messages = developer_message_texts(&initial_context); |
| assert!( |
| developer_messages |
| .iter() |
| .flatten() |
| .any(|text| text.contains("<context_window>\nAgent name: /root/worker\n")), |
| "expected subagent context window to include its canonical name, got {developer_messages:?}" |
| ); |
| assert!( |
| developer_messages |
| .iter() |
| .any(|message| message.as_slice() == ["Subagent guidance."]), |
| "expected standalone subagent usage hint developer message, got {developer_messages:?}" |
| ); |
| assert!( |
| !developer_messages |
| .iter() |
| .any(|message| message.as_slice() == ["Root guidance."]), |
| "did not expect root usage hint for subagent thread, got {developer_messages:?}" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_feature_disabled() { |
| let (session, turn_context) = |
| make_multi_agent_v2_usage_hint_test_session( false).await; |
|
|
| let initial_context = build_initial_context(&session, &turn_context).await; |
|
|
| let developer_messages = developer_message_texts(&initial_context); |
| assert!( |
| !developer_messages.iter().any(|message| { |
| matches!( |
| message.as_slice(), |
| ["Root guidance."] | ["Subagent guidance."] |
| ) |
| }), |
| "did not expect multi-agent v2 usage hint developer messages, got {developer_messages:?}" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_hint_is_empty() { |
| let (session, turn_context, _rx_event) = make_session_and_context_with_auth_and_config_and_rx( |
| CodexAuth::from_api_key("Test API Key"), |
| Vec::new(), |
| |config| { |
| let _ = config.features.enable(Feature::MultiAgentV2); |
| config.multi_agent_v2.root_agent_usage_hint_text = Some(String::new()); |
| config.multi_agent_v2.subagent_usage_hint_text = Some(String::new()); |
| }, |
| ) |
| .await; |
|
|
| let initial_context = build_initial_context(&session, &turn_context).await; |
|
|
| let developer_messages = developer_message_texts(&initial_context); |
| assert!( |
| !developer_messages.iter().any(|message| { |
| matches!( |
| message.as_slice(), |
| ["Root guidance."] | ["Subagent guidance."] |
| ) || message.iter().any(|text| { |
| text.contains("You are `/root`, the primary agent") |
| || text.contains("You are an agent in a team of agents") |
| }) |
| }), |
| "did not expect multi-agent v2 usage hint developer messages, got {developer_messages:?}" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn build_initial_context_restates_realtime_start_when_reference_context_is_missing() { |
| let (session, mut turn_context) = make_session_and_context().await; |
| turn_context.realtime_active = true; |
| let previous_turn_settings = PreviousTurnSettings { |
| model: turn_context.model_info().slug.clone(), |
| comp_hash: None, |
| realtime_active: Some(true), |
| }; |
|
|
| session |
| .set_previous_turn_settings(Some(previous_turn_settings)) |
| .await; |
| let turn_context = Arc::new(turn_context); |
| let initial_context = build_initial_context(&session, &turn_context).await; |
| let developer_texts = developer_input_texts(&initial_context); |
| assert!( |
| developer_texts |
| .iter() |
| .any(|text| text.contains("<realtime_conversation>")), |
| "expected initial context to restate active realtime when the reference context is missing, got {developer_texts:?}" |
| ); |
| } |
|
|
| fn file_system_policy_with_unreadable_glob(turn_context: &TurnContext) -> FileSystemSandboxPolicy { |
| #[allow(deprecated)] |
| let mut policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( |
| &turn_context.sandbox_policy(), |
| &turn_context.cwd, |
| ); |
| #[allow(deprecated)] |
| let cwd_display = turn_context.cwd.as_path().display().to_string(); |
| policy.entries.push(FileSystemSandboxEntry { |
| path: FileSystemPath::GlobPattern { |
| pattern: format!("{cwd_display}/**/*.env"), |
| }, |
| access: FileSystemAccessMode::Deny, |
| missing_path_behavior: None, |
| }); |
| policy |
| } |
|
|
| #[tokio::test] |
| async fn turn_context_item_stores_local_cwd() { |
| let (_session, mut turn_context) = make_session_and_context().await; |
| let environment = turn_context |
| .environments |
| .primary() |
| .expect("primary environment") |
| .clone(); |
| let cwd = PathUri::parse("file:///C:/windows").expect("Windows cwd URI"); |
| let environment_config = environment.config().clone(); |
| turn_context.environments.environments[0] = TurnEnvironmentState::Ready(TurnEnvironment::new( |
| TurnEnvironmentSelection { |
| environment_id: "remote".to_string(), |
| cwd, |
| workspace_roots: Vec::new(), |
| config: EnvironmentConfigState::Ready(environment_config), |
| }, |
| environment.config_origin, |
| environment.environment, |
| environment.shell, |
| )); |
|
|
| #[allow(deprecated)] |
| let local_cwd = turn_context.cwd.clone(); |
| assert_eq!(turn_context.to_turn_context_item().cwd, local_cwd); |
| } |
|
|
| #[tokio::test] |
| async fn turn_context_item_omits_legacy_equivalent_file_system_sandbox_policy() { |
| let (_session, turn_context) = make_session_and_context().await; |
|
|
| let item = turn_context.to_turn_context_item(); |
|
|
| assert_eq!(item.file_system_sandbox_policy, None); |
| assert_eq!( |
| item.permission_profile, |
| Some(turn_context.permission_profile()) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn turn_context_item_stores_active_permission_profile() { |
| let (_session, mut turn_context) = make_session_and_context().await; |
| let active_permission_profile = ActivePermissionProfile::read_only(); |
| let TurnEnvironmentState::Ready(environment) = &mut turn_context.environments.environments[0] |
| else { |
| panic!("turn environment should be ready"); |
| }; |
| environment.config_origin = EnvironmentConfigOrigin::Owner; |
| environment.config_mut().permission_profile = PermissionProfileSnapshot::active( |
| PermissionProfile::read_only(), |
| active_permission_profile.clone(), |
| ); |
|
|
| assert_eq!( |
| turn_context |
| .to_turn_context_item() |
| .active_permission_profile, |
| Some(active_permission_profile) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn turn_context_item_stores_split_file_system_sandbox_policy_when_different() { |
| let (_session, mut turn_context) = make_session_and_context().await; |
| let file_system_sandbox_policy = file_system_policy_with_unreadable_glob(&turn_context); |
| let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( |
| turn_context.permission_profile().enforcement(), |
| &file_system_sandbox_policy, |
| turn_context.network_sandbox_policy(), |
| ); |
| let TurnEnvironmentState::Ready(environment) = &mut turn_context.environments.environments[0] |
| else { |
| panic!("turn environment should be ready"); |
| }; |
| environment.config_mut().permission_profile = |
| PermissionProfileSnapshot::legacy(permission_profile); |
|
|
| let item = turn_context.to_turn_context_item(); |
|
|
| assert_eq!( |
| item.file_system_sandbox_policy, |
| Some( |
| file_system_sandbox_policy |
| .try_into() |
| .expect("serializable split policy"), |
| ) |
| ); |
| assert_eq!( |
| item.permission_profile, |
| Some(turn_context.permission_profile()) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn record_context_updates_and_set_reference_context_item_injects_full_context_when_baseline_missing() |
| { |
| let (session, turn_context) = make_session_and_context().await; |
| let turn_context = Arc::new(turn_context); |
| let step_context = StepContext::for_test(Arc::clone(&turn_context)); |
| session |
| .record_context_updates_and_set_reference_context_item(&step_context) |
| .await |
| .expect("world state should build"); |
| let history = session.clone_history().await; |
| let initial_context = build_initial_context(&session, &turn_context).await; |
| assert_eq!( |
| strip_response_item_ids(&strip_metadata_from_items(&raw_history_items(&history))), |
| strip_response_item_ids(&strip_metadata_from_items(&initial_context)) |
| ); |
|
|
| let current_context = session.reference_context_item().await; |
| assert_eq!( |
| serde_json::to_value(current_context).expect("serialize current context item"), |
| serde_json::to_value(Some(turn_context.to_turn_context_item())) |
| .expect("serialize expected context item") |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn record_context_updates_and_set_reference_context_item_reinjects_full_context_after_clear() |
| { |
| let (session, turn_context) = make_session_and_context().await; |
| let turn_context = Arc::new(turn_context); |
| let step_context = StepContext::for_test(Arc::clone(&turn_context)); |
| let compacted_summary = ResponseItem::Message { |
| id: None, |
| role: "user".to_string(), |
| content: vec![ContentItem::InputText { |
| text: format!("{}\nsummary", crate::compact::SUMMARY_PREFIX), |
| }], |
| phase: None, |
| internal_chat_message_metadata_passthrough: None, |
| }; |
| session |
| .record_conversation_items( |
| &turn_context, |
| turn_context.model_info(), |
| std::slice::from_ref(&compacted_summary), |
| ) |
| .await; |
| session |
| .record_context_updates_and_set_reference_context_item(&step_context) |
| .await |
| .expect("world state should build"); |
| { |
| let mut state = session.state.lock().await; |
| state.set_reference_context_item( None); |
| } |
| session |
| .replace_history( |
| vec![compacted_summary.clone()], |
| None, |
| ) |
| .await; |
|
|
| session |
| .record_context_updates_and_set_reference_context_item(&step_context) |
| .await |
| .expect("world state should build"); |
|
|
| let history = session.clone_history().await; |
| let mut expected_history = vec![compacted_summary]; |
| let initial_context = build_initial_context(&session, &turn_context).await; |
| expected_history.extend(initial_context); |
| assert_eq!( |
| strip_response_item_ids(&strip_metadata_from_items(&raw_history_items(&history))), |
| strip_response_item_ids(&strip_metadata_from_items(&expected_history)) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn record_context_updates_and_set_reference_context_item_persists_baseline_without_emitting_diffs() |
| { |
| let (mut session, turn_context) = make_session_and_context().await; |
| let previous_context_item = turn_context.to_turn_context_item(); |
| let previous_context = Arc::new(turn_context); |
| let world_state = build_world_state_from_turn_context(&session, &previous_context).await; |
| let retained_world_state = world_state |
| .render_full() |
| .into_iter() |
| .map(ContextualUserFragment::into_boxed_response_item) |
| .collect::<Vec<_>>(); |
| session |
| .replace_history( |
| retained_world_state.clone(), |
| Some(previous_context_item.clone()), |
| ) |
| .await; |
| let mut turn_context = Arc::try_unwrap(previous_context) |
| .unwrap_or_else(|_| panic!("previous turn context should have no remaining references")); |
| turn_context.sub_id = format!("{}-next", turn_context.sub_id); |
| { |
| let mut state = session.state.lock().await; |
| state |
| .history |
| .set_world_state_baseline(world_state.snapshot()); |
| } |
| let rollout_path = attach_thread_persistence(&mut session).await; |
|
|
| let turn_context = Arc::new(turn_context); |
| let step_context = StepContext::for_test(Arc::clone(&turn_context)); |
| session |
| .record_context_updates_and_set_reference_context_item(&step_context) |
| .await |
| .expect("world state should build"); |
|
|
| assert_eq!( |
| raw_history_items(&session.clone_history().await), |
| retained_world_state |
| ); |
| assert_eq!( |
| serde_json::to_value(session.reference_context_item().await) |
| .expect("serialize current context item"), |
| serde_json::to_value(Some(turn_context.to_turn_context_item())) |
| .expect("serialize expected context item") |
| ); |
| session |
| .ensure_rollout_materialized(PersistContext::Standard) |
| .await; |
| session.flush_rollout().await.expect("rollout should flush"); |
|
|
| let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path) |
| .await |
| .expect("read rollout history") |
| else { |
| panic!("expected resumed rollout history"); |
| }; |
| let persisted_turn_context = resumed.history.iter().find_map(|item| match item { |
| RolloutItem::TurnContext(ctx) => Some(ctx.clone()), |
| _ => None, |
| }); |
| assert_eq!( |
| serde_json::to_value(persisted_turn_context) |
| .expect("serialize persisted turn context item"), |
| serde_json::to_value(Some(turn_context.to_turn_context_item())) |
| .expect("serialize expected turn context item") |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn record_context_updates_and_set_reference_context_item_persists_split_file_system_policy_to_rollout() |
| { |
| let (mut session, mut turn_context) = make_session_and_context().await; |
| let file_system_sandbox_policy = file_system_policy_with_unreadable_glob(&turn_context); |
| let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( |
| turn_context.permission_profile().enforcement(), |
| &file_system_sandbox_policy, |
| turn_context.network_sandbox_policy(), |
| ); |
| let TurnEnvironmentState::Ready(environment) = &mut turn_context.environments.environments[0] |
| else { |
| panic!("turn environment should be ready"); |
| }; |
| environment.config_mut().permission_profile = |
| PermissionProfileSnapshot::legacy(permission_profile); |
| let rollout_path = attach_thread_persistence(&mut session).await; |
|
|
| let turn_context = Arc::new(turn_context); |
| let step_context = StepContext::for_test(Arc::clone(&turn_context)); |
| session |
| .record_context_updates_and_set_reference_context_item(&step_context) |
| .await |
| .expect("world state should build"); |
| session |
| .ensure_rollout_materialized(PersistContext::Standard) |
| .await; |
| session.flush_rollout().await.expect("rollout should flush"); |
|
|
| let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path) |
| .await |
| .expect("read rollout history") |
| else { |
| panic!("expected resumed rollout history"); |
| }; |
| let persisted_file_system_sandbox_policy = resumed.history.iter().find_map(|item| match item { |
| RolloutItem::TurnContext(ctx) => ctx.file_system_sandbox_policy.clone(), |
| _ => None, |
| }); |
| assert_eq!( |
| persisted_file_system_sandbox_policy, |
| Some( |
| file_system_sandbox_policy |
| .try_into() |
| .expect("serializable split policy"), |
| ) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn build_initial_context_uses_retained_step_after_model_change() { |
| let (mut session, mut turn_context, _rx_event) = |
| make_session_and_context_with_auth_and_config_and_rx( |
| CodexAuth::from_api_key("Test API Key"), |
| Vec::new(), |
| |config| { |
| config.features.enable(Feature::TokenBudget).unwrap(); |
| }, |
| ) |
| .await; |
| let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); |
| builder.prompt_contributor(Arc::new(TurnContextExtensionTestContributor)); |
| Arc::get_mut(&mut session) |
| .expect("unshared test session") |
| .services |
| .extensions = Arc::new(builder.build()); |
| turn_context |
| .extension_data |
| .insert(TurnContextExtensionTestState { |
| expected_model_context_window: Some(64_000), |
| }); |
| update_turn_settings_for_test(Arc::get_mut(&mut turn_context).unwrap(), |settings| { |
| let model_info = Arc::make_mut(&mut settings.model_info); |
| model_info.slug = "model-a".to_string(); |
| model_info.context_window = None; |
| model_info.max_context_window = None; |
| let messages = model_info.model_messages.as_mut().unwrap(); |
| messages.instructions_template = Some("A instructions".to_string()); |
| messages.instructions_variables = None; |
| }); |
| session |
| .set_previous_turn_settings(Some(PreviousTurnSettings { |
| model: "base-model".to_string(), |
| comp_hash: None, |
| realtime_active: None, |
| })) |
| .await; |
| let step_a = session |
| .capture_step_context(Arc::clone(&turn_context), &CancellationToken::new()) |
| .await |
| .unwrap(); |
| let world_a = Arc::new(session.build_world_state_for_step(&step_a).await.unwrap()); |
| let retained = crate::compact::InitialContextInjection::BeforeLastUserMessage { |
| world_state: Arc::clone(&world_a), |
| step_context: Arc::clone(&step_a), |
| }; |
| let (initial_a, _) = |
| crate::compact::build_compaction_initial_context(&session, &retained).await; |
|
|
| let mut selected_b = step_a.settings.selected().clone(); |
| selected_b.collaboration_mode.settings.model = "model-b".to_string(); |
| let mut model_b = step_a.settings.model_info.as_ref().clone(); |
| model_b.slug = "model-b".to_string(); |
| model_b.context_window = Some(128_000); |
| model_b.effective_context_window_percent = 50; |
| model_b |
| .model_messages |
| .as_mut() |
| .unwrap() |
| .instructions_template = Some("B instructions".to_string()); |
| turn_context |
| .current_settings |
| .store(Arc::new(ResolvedStepSettings::new( |
| Arc::new(selected_b), |
| Arc::new(model_b), |
| false, |
| ))); |
| let step_b = session |
| .capture_step_context(Arc::clone(&turn_context), &CancellationToken::new()) |
| .await |
| .unwrap(); |
| let world_b = session.build_world_state_for_step(&step_b).await.unwrap(); |
| let initial_b = session |
| .build_initial_context_with_world_state(&step_b, &world_b) |
| .await; |
| let turn_contributions_b = session.build_turn_context_contribution_items(&step_b).await; |
| let (restored_a, restored_world) = |
| crate::compact::build_compaction_initial_context(&session, &retained).await; |
|
|
| assert_eq!(restored_a, initial_a); |
| assert!(Arc::ptr_eq(restored_world.as_ref().unwrap(), &world_a)); |
| let initial_a = initial_a |
| .into_iter() |
| .map(ResponseItemEnvelope::into_item) |
| .collect::<Vec<_>>(); |
| let a_text = developer_input_texts(&initial_a).join("\n"); |
| let b_text = developer_input_texts(&initial_b).join("\n"); |
| assert!(a_text.contains("A instructions")); |
| assert!(!a_text.contains("<context_window>")); |
| assert!(b_text.contains("B instructions")); |
| assert!(!b_text.contains("A instructions:")); |
| assert!(!a_text.contains("turn context extension enabled")); |
| assert!(b_text.contains("turn context extension enabled")); |
| assert!( |
| developer_input_texts(&turn_contributions_b) |
| .join("\n") |
| .contains("turn context extension enabled") |
| ); |
| assert!( |
| b_text.contains("<context_window>"), |
| "full-context metadata must use B's window even though the turn started without one" |
| ); |
| assert_eq!( |
| step_b.environments.to_selections(), |
| step_a.environments.to_selections() |
| ); |
| assert!(Arc::ptr_eq(&step_b.turn.config, &step_a.turn.config)); |
| } |
|
|
| #[tokio::test] |
| async fn build_initial_context_prepends_model_switch_message() { |
| let (session, turn_context) = make_session_and_context().await; |
| let previous_turn_settings = PreviousTurnSettings { |
| model: "previous-regular-model".to_string(), |
| comp_hash: None, |
| realtime_active: None, |
| }; |
|
|
| session |
| .set_previous_turn_settings(Some(previous_turn_settings)) |
| .await; |
| let turn_context = Arc::new(turn_context); |
| let initial_context = build_initial_context(&session, &turn_context).await; |
|
|
| let ResponseItem::Message { role, content, .. } = &initial_context[0] else { |
| panic!("expected developer message"); |
| }; |
| assert_eq!(role, "developer"); |
| let [ContentItem::InputText { text }, ..] = content.as_slice() else { |
| panic!("expected developer text"); |
| }; |
| assert!(text.contains("<model_switch>")); |
| } |
|
|
| #[tokio::test] |
| async fn record_context_updates_and_set_reference_context_item_persists_full_reinjection_to_rollout() |
| { |
| let (mut session, previous_context) = make_session_and_context().await; |
| let next_model = if previous_context.model_info().slug == "gpt-5.4" { |
| "gpt-5.2" |
| } else { |
| "gpt-5.4" |
| }; |
| let turn_context = previous_context |
| .with_model(next_model.to_string(), &session.services.models_manager) |
| .await; |
| let rollout_path = attach_thread_persistence(&mut session).await; |
|
|
| session |
| .persist_rollout_items(&[RolloutItem::EventMsg(EventMsg::UserMessage( |
| UserMessageEvent { |
| client_id: None, |
| message: "seed rollout".to_string(), |
| images: None, |
| local_images: Vec::new(), |
| text_elements: Vec::new(), |
| ..Default::default() |
| }, |
| ))]) |
| .await; |
| { |
| let mut state = session.state.lock().await; |
| state.set_reference_context_item( None); |
| } |
|
|
| session |
| .set_previous_turn_settings(Some(PreviousTurnSettings { |
| model: previous_context.model_info().slug.clone(), |
| comp_hash: None, |
| realtime_active: Some(previous_context.realtime_active), |
| })) |
| .await; |
| let turn_context = Arc::new(turn_context); |
| let step_context = StepContext::for_test(Arc::clone(&turn_context)); |
| session |
| .record_context_updates_and_set_reference_context_item(&step_context) |
| .await |
| .expect("world state should build"); |
| session |
| .ensure_rollout_materialized(PersistContext::Standard) |
| .await; |
| session.flush_rollout().await.expect("rollout should flush"); |
|
|
| let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path) |
| .await |
| .expect("read rollout history") |
| else { |
| panic!("expected resumed rollout history"); |
| }; |
| let persisted_turn_context = resumed.history.iter().find_map(|item| match item { |
| RolloutItem::TurnContext(ctx) => Some(ctx.clone()), |
| _ => None, |
| }); |
|
|
| assert_eq!( |
| serde_json::to_value(persisted_turn_context) |
| .expect("serialize persisted turn context item"), |
| serde_json::to_value(Some(turn_context.to_turn_context_item())) |
| .expect("serialize expected turn context item") |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn run_user_shell_command_does_not_set_reference_context_item() { |
| let (session, _turn_context, rx) = make_session_and_context_with_rx().await; |
| { |
| let mut state = session.state.lock().await; |
| state.set_reference_context_item( None); |
| } |
|
|
| handlers::run_user_shell_command( |
| &session, |
| "sub-id".to_string(), |
| "echo shell".to_string(), |
| None, |
| ) |
| .await; |
|
|
| let deadline = StdDuration::from_secs(15); |
| let start = std::time::Instant::now(); |
| loop { |
| let remaining = deadline.saturating_sub(start.elapsed()); |
| let evt = tokio::time::timeout(remaining, rx.recv()) |
| .await |
| .expect("timeout waiting for event") |
| .expect("event"); |
| if matches!(evt.msg, EventMsg::TurnComplete(_)) { |
| break; |
| } |
| } |
|
|
| assert!( |
| session.reference_context_item().await.is_none(), |
| "standalone shell tasks should not mutate previous context" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn realtime_conversation_list_voices_emits_builtin_list() { |
| let (session, _turn_context, rx) = make_session_and_context_with_rx().await; |
|
|
| handlers::realtime_conversation_list_voices(&session, "sub-id".to_string()).await; |
|
|
| let event = rx.recv().await.expect("event"); |
| let voices = match event.msg { |
| EventMsg::RealtimeConversationListVoicesResponse( |
| RealtimeConversationListVoicesResponseEvent { voices }, |
| ) => voices, |
| msg => panic!("expected list voices response, got {msg:?}"), |
| }; |
| assert_eq!( |
| voices, |
| RealtimeVoicesList { |
| v1: vec![ |
| RealtimeVoice::Juniper, |
| RealtimeVoice::Maple, |
| RealtimeVoice::Spruce, |
| RealtimeVoice::Ember, |
| RealtimeVoice::Vale, |
| RealtimeVoice::Breeze, |
| RealtimeVoice::Arbor, |
| RealtimeVoice::Sol, |
| RealtimeVoice::Cove, |
| ], |
| v2: vec![ |
| RealtimeVoice::Alloy, |
| RealtimeVoice::Ash, |
| RealtimeVoice::Ballad, |
| RealtimeVoice::Coral, |
| RealtimeVoice::Echo, |
| RealtimeVoice::Sage, |
| RealtimeVoice::Shimmer, |
| RealtimeVoice::Verse, |
| RealtimeVoice::Marin, |
| RealtimeVoice::Cedar, |
| ], |
| default_v1: RealtimeVoice::Cove, |
| default_v2: RealtimeVoice::Marin, |
| }, |
| ); |
| } |
|
|
| #[derive(Clone, Copy)] |
| struct CompletingTask; |
|
|
| impl SessionTask for CompletingTask { |
| fn kind(&self) -> TaskKind { |
| TaskKind::Regular |
| } |
|
|
| fn span_name(&self) -> &'static str { |
| "session_task.completing" |
| } |
|
|
| async fn run( |
| self: Arc<Self>, |
| _session: Arc<Session>, |
| _ctx: Arc<TurnContext>, |
| _input: Vec<TurnInput>, |
| _cancellation_token: CancellationToken, |
| ) -> SessionTaskResult { |
| Ok(None) |
| } |
| } |
|
|
| #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| enum TerminalEventKind { |
| TurnComplete, |
| TurnAborted, |
| } |
|
|
| async fn attach_in_memory_thread_store( |
| session: &mut Session, |
| ) -> Arc<codex_thread_store::InMemoryThreadStore> { |
| let store = Arc::new(codex_thread_store::InMemoryThreadStore::default()); |
| let thread_store: Arc<dyn codex_thread_store::ThreadStore> = store.clone(); |
| let config = session.get_config().await; |
| let live_thread = LiveThread::create( |
| Arc::clone(&thread_store), |
| CreateThreadParams { |
| session_id: session.session_id(), |
| thread_id: session.thread_id, |
| extra_config: None, |
| forked_from_id: None, |
| parent_thread_id: None, |
| source: SessionSource::Exec, |
| thread_source: None, |
| originator: "test_originator".to_string(), |
| base_instructions: BaseInstructions::default(), |
| dynamic_tools: Vec::new(), |
| selected_capability_roots: Vec::new(), |
| multi_agent_version: None, |
| history_mode: Default::default(), |
| subagent_history_start_ordinal: None, |
| history_base: None, |
| initial_window_id: Uuid::now_v7().to_string(), |
| runtime_workspace_roots: None, |
| metadata: ThreadPersistenceMetadata { |
| cwd: Some(config.cwd.to_path_buf()), |
| model_provider: config.model_provider_id.clone(), |
| memory_mode: if config.memories.generate_memories { |
| ThreadMemoryMode::Enabled |
| } else { |
| ThreadMemoryMode::Disabled |
| }, |
| }, |
| }, |
| ) |
| .await |
| .expect("create thread persistence"); |
| session.services.thread_store = thread_store; |
| session.services.live_thread = Some(live_thread); |
| store |
| } |
|
|
| #[tokio::test] |
| async fn hook_transcript_path_does_not_persist_non_local_thread_store() { |
| let (mut session, _) = make_session_and_context().await; |
| let store = attach_in_memory_thread_store(&mut session).await; |
|
|
| assert_eq!(session.hook_transcript_path().await, None); |
| assert_eq!( |
| store.calls().await, |
| codex_thread_store::InMemoryThreadStoreCalls { |
| create_thread: 1, |
| ..Default::default() |
| } |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn hook_transcript_path_materializes_lazy_local_thread() { |
| let (mut session, _) = make_session_and_context().await; |
| let rollout_path = open_thread_persistence(&mut session).await; |
| assert!(!rollout_path.exists()); |
|
|
| assert_eq!( |
| session.hook_transcript_path().await, |
| Some(rollout_path.clone()) |
| ); |
| let (items, thread_id, parse_errors) = RolloutRecorder::load_rollout_items(&rollout_path) |
| .await |
| .expect("read materialized rollout"); |
| assert_eq!((thread_id, parse_errors), (Some(session.thread_id), 0)); |
| assert!(matches!( |
| items.as_slice(), |
| [RolloutItem::SessionMeta(meta)] if meta.meta.id == session.thread_id |
| )); |
| } |
|
|
| async fn wait_for_flush_count( |
| store: &codex_thread_store::InMemoryThreadStore, |
| expected_flushes: usize, |
| ) -> codex_thread_store::InMemoryThreadStoreCalls { |
| timeout(Duration::from_secs(2), async { |
| loop { |
| let calls = store.calls().await; |
| if calls.flush_thread >= expected_flushes { |
| return calls; |
| } |
| sleep(Duration::from_millis(10)).await; |
| } |
| }) |
| .await |
| .expect("store should observe expected flush count") |
| } |
|
|
| async fn recv_terminal_event( |
| rx: &async_channel::Receiver<Event>, |
| expected: TerminalEventKind, |
| ) -> Event { |
| timeout(Duration::from_secs(2), async { |
| loop { |
| let event = rx.recv().await.expect("event"); |
| match (&event.msg, expected) { |
| (EventMsg::TurnComplete(_), TerminalEventKind::TurnComplete) |
| | (EventMsg::TurnAborted(_), TerminalEventKind::TurnAborted) => return event, |
| (EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_), _) => { |
| panic!("unexpected terminal event: {:?}", event.msg) |
| } |
| _ => {} |
| } |
| } |
| }) |
| .await |
| .expect("terminal event should be delivered") |
| } |
|
|
| #[derive(Clone, Copy)] |
| struct NeverEndingTask { |
| kind: TaskKind, |
| listen_to_cancellation_token: bool, |
| } |
|
|
| impl SessionTask for NeverEndingTask { |
| fn kind(&self) -> TaskKind { |
| self.kind |
| } |
|
|
| fn span_name(&self) -> &'static str { |
| "session_task.never_ending" |
| } |
|
|
| async fn run( |
| self: Arc<Self>, |
| _session: Arc<Session>, |
| _ctx: Arc<TurnContext>, |
| _input: Vec<TurnInput>, |
| cancellation_token: CancellationToken, |
| ) -> SessionTaskResult { |
| if self.listen_to_cancellation_token { |
| cancellation_token.cancelled().await; |
| return Ok(None); |
| } |
| loop { |
| sleep(Duration::from_secs(60)).await; |
| } |
| } |
| } |
|
|
| #[derive(Clone, Copy)] |
| struct ExtensionInterruptedTask; |
|
|
| impl SessionTask for ExtensionInterruptedTask { |
| fn kind(&self) -> TaskKind { |
| TaskKind::Regular |
| } |
|
|
| fn span_name(&self) -> &'static str { |
| "session_task.extension_interrupted" |
| } |
|
|
| async fn run( |
| self: Arc<Self>, |
| session: Arc<Session>, |
| ctx: Arc<TurnContext>, |
| _input: Vec<TurnInput>, |
| cancellation_token: CancellationToken, |
| ) -> SessionTaskResult { |
| session |
| .interrupt_turn_with_warning( |
| &ctx.sub_id, |
| EventMsg::Warning(codex_protocol::protocol::WarningEvent { |
| message: "extension interrupted this turn".into(), |
| }), |
| ) |
| .await; |
|
|
| cancellation_token.cancelled().await; |
| Ok(None) |
| } |
| } |
|
|
| pub(super) struct HeldStepTask { |
| pub(super) kind: TaskKind, |
| pub(super) finish: Arc<Notify>, |
| } |
|
|
| impl SessionTask for HeldStepTask { |
| fn kind(&self) -> TaskKind { |
| self.kind |
| } |
|
|
| fn span_name(&self) -> &'static str { |
| "session_task.step_activation_test" |
| } |
|
|
| async fn run( |
| self: Arc<Self>, |
| _session: Arc<Session>, |
| _turn: Arc<TurnContext>, |
| _input: Vec<TurnInput>, |
| cancellation_token: CancellationToken, |
| ) -> SessionTaskResult { |
| tokio::select! { |
| _ = cancellation_token.cancelled() => {}, |
| _ = self.finish.notified() => {}, |
| } |
| Ok(None) |
| } |
| } |
|
|
| #[test_case(TerminalEventKind::TurnComplete; "completion")] |
| #[test_case(TerminalEventKind::TurnAborted; "interruption")] |
| #[tokio::test] |
| async fn finished_turn_retains_last_known_step_context(terminal: TerminalEventKind) { |
| let (session, turn, events) = make_session_and_context_with_rx().await; |
| let finish = Arc::new(Notify::new()); |
| session |
| .spawn_task( |
| Arc::clone(&turn), |
| Vec::new(), |
| HeldStepTask { |
| kind: TaskKind::Regular, |
| finish: Arc::clone(&finish), |
| }, |
| ) |
| .await; |
| let expected = session |
| .capture_step_context(turn, &CancellationToken::new()) |
| .await |
| .expect("capture executing step"); |
| let state = { |
| let active = session.active_turn.lock().await; |
| Arc::clone(&active.as_ref().expect("active turn").turn_state) |
| }; |
|
|
| match terminal { |
| TerminalEventKind::TurnComplete => finish.notify_one(), |
| TerminalEventKind::TurnAborted => { |
| session.abort_all_tasks(TurnAbortReason::Interrupted).await; |
| } |
| } |
| recv_terminal_event(&events, terminal).await; |
|
|
| assert!(session.active_turn.lock().await.is_none()); |
| assert_eq!( |
| state |
| .lock() |
| .await |
| .last_known_step_context |
| .as_ref() |
| .map(Arc::as_ptr), |
| Some(Arc::as_ptr(&expected)), |
| ); |
| } |
|
|
| #[derive(Clone, Copy)] |
| enum FirstAttempt { |
| Succeeds, |
| Retries, |
| } |
|
|
| async fn make_remote_compaction_session( |
| server_uri: &str, |
| ) -> ( |
| Arc<Session>, |
| Arc<TurnContext>, |
| async_channel::Receiver<Event>, |
| ) { |
| let mut provider = built_in_model_providers( None)["openai"].clone(); |
| provider.base_url = Some(format!("{server_uri}/v1")); |
| provider.supports_websockets = false; |
| make_session_and_context_with_auth_and_config_and_rx( |
| CodexAuth::create_dummy_chatgpt_auth_for_testing(), |
| Vec::new(), |
| move |config| { |
| config.model = Some("gpt-5.2".to_string()); |
| config.model_provider = provider; |
| let _ = config.features.disable(Feature::TokenBudget); |
| }, |
| ) |
| .await |
| } |
|
|
| #[test_case(FirstAttempt::Succeeds; "primary succeeds")] |
| #[test_case(FirstAttempt::Retries; "fallback executes")] |
| #[tokio::test] |
| async fn remote_compaction_v2_retains_only_the_selected_step(first_attempt: FirstAttempt) { |
| let server = responses::start_mock_server().await; |
| let (session, turn, events) = make_remote_compaction_session(&server.uri()).await; |
| session |
| .record_conversation_items( |
| &turn, |
| turn.model_info(), |
| &[user_message("before compaction")], |
| ) |
| .await; |
| session |
| .spawn_task( |
| Arc::clone(&turn), |
| Vec::new(), |
| NeverEndingTask { |
| kind: TaskKind::Regular, |
| listen_to_cancellation_token: true, |
| }, |
| ) |
| .await; |
| let primary_turn = Arc::new( |
| turn.with_model("gpt-5.4".to_string(), &session.services.models_manager) |
| .await, |
| ); |
| let primary = session |
| .capture_step_context(primary_turn, &CancellationToken::new()) |
| .await |
| .expect("capture primary step"); |
| let fallback = session |
| .capture_speculative_step_context(turn, &CancellationToken::new()) |
| .await |
| .expect("capture speculative fallback"); |
| let state = { |
| let active = session.active_turn.lock().await; |
| Arc::clone(&active.as_ref().expect("active turn").turn_state) |
| }; |
| assert_eq!( |
| state |
| .lock() |
| .await |
| .last_known_step_context |
| .as_ref() |
| .map(Arc::as_ptr), |
| Some(Arc::as_ptr(&primary)), |
| ); |
|
|
| let success = ResponseTemplate::new( 200) |
| .insert_header("content-type", "text/event-stream") |
| .set_body_string(responses::sse(vec![ |
| json!({ |
| "type": "response.output_item.done", |
| "item": { "type": "compaction", "encrypted_content": "summary" }, |
| }), |
| responses::ev_completed("compact-response"), |
| ])); |
| let replies = match first_attempt { |
| FirstAttempt::Succeeds => vec![success], |
| FirstAttempt::Retries => vec![ |
| ResponseTemplate::new( 400) |
| .set_body_json(json!({ "detail": "previous model unavailable" })), |
| success, |
| ], |
| }; |
| let requests = responses::mount_response_sequence(&server, replies).await; |
| let mut client_session = session.services.model_client.new_session(); |
| crate::compact_remote_v2::run_inline_remote_auto_compact_task( |
| Arc::clone(&session), |
| Arc::clone(&primary), |
| Some(Arc::clone(&fallback)), |
| &mut client_session, |
| InitialContextInjection::DoNotInject, |
| CompactionReason::ModelDownshift, |
| CompactionPhase::PreTurn, |
| ) |
| .await |
| .expect("compaction succeeds"); |
|
|
| let (expected, models) = match first_attempt { |
| FirstAttempt::Succeeds => (&primary, vec![json!("gpt-5.4")]), |
| FirstAttempt::Retries => (&fallback, vec![json!("gpt-5.4"), json!("gpt-5.2")]), |
| }; |
| assert_eq!( |
| state |
| .lock() |
| .await |
| .last_known_step_context |
| .as_ref() |
| .map(Arc::as_ptr), |
| Some(Arc::as_ptr(expected)), |
| ); |
| assert_eq!( |
| requests |
| .requests() |
| .iter() |
| .map(|request| request.body_json()["model"].clone()) |
| .collect::<Vec<_>>(), |
| models, |
| ); |
| assert!( |
| requests |
| .requests() |
| .iter() |
| .all(|request| request.inputs_of_type("compaction_trigger").len() == 1), |
| ); |
| session.abort_all_tasks(TurnAbortReason::Interrupted).await; |
| recv_terminal_event(&events, TerminalEventKind::TurnAborted).await; |
| } |
|
|
| #[tokio::test] |
| async fn interrupting_compaction_fallback_retains_last_known_step_context() { |
| let (release_primary, primary_gate) = tokio::sync::oneshot::channel(); |
| let (release_fallback, fallback_gate) = tokio::sync::oneshot::channel(); |
| let (server, _) = start_streaming_sse_server(vec![ |
| vec![StreamingSseChunk { |
| gate: Some(primary_gate), |
| body: responses::sse_failed( |
| "primary", |
| "context_length_exceeded", |
| "compact with the current model", |
| ), |
| }], |
| vec![StreamingSseChunk { |
| gate: Some(fallback_gate), |
| body: responses::sse_completed("fallback"), |
| }], |
| ]) |
| .await; |
| let (session, mut turn, events) = make_remote_compaction_session(server.uri()).await; |
| update_turn_settings_for_test( |
| Arc::get_mut(&mut turn).expect("unshared turn"), |
| |settings| { |
| Arc::make_mut(&mut settings.model_info).comp_hash = Some("new".to_string()); |
| }, |
| ); |
| session |
| .set_previous_turn_settings(Some(PreviousTurnSettings { |
| model: "gpt-5.4".to_string(), |
| comp_hash: Some("old".to_string()), |
| realtime_active: Some(turn.realtime_active), |
| })) |
| .await; |
| session |
| .record_conversation_items( |
| &turn, |
| turn.model_info(), |
| &[user_message("before compaction")], |
| ) |
| .await; |
| session |
| .spawn_task(turn, Vec::new(), crate::tasks::RegularTask::new()) |
| .await; |
| let state = { |
| let active = session.active_turn.lock().await; |
| Arc::clone(&active.as_ref().expect("active turn").turn_state) |
| }; |
|
|
| |
| timeout( |
| Duration::from_secs( 10), |
| server.wait_for_request_count( 1), |
| ) |
| .await |
| .expect("primary compaction request"); |
| let primary = state |
| .lock() |
| .await |
| .last_known_step_context |
| .clone() |
| .expect("primary step"); |
| assert_eq!(primary.settings.model_info.slug, "gpt-5.4"); |
|
|
| release_primary.send(()).expect("release primary failure"); |
| timeout( |
| Duration::from_secs( 10), |
| server.wait_for_request_count( 2), |
| ) |
| .await |
| .expect("fallback compaction request"); |
| let fallback = state |
| .lock() |
| .await |
| .last_known_step_context |
| .clone() |
| .expect("fallback step"); |
| assert_eq!(fallback.settings.model_info.slug, "gpt-5.2"); |
|
|
| session.abort_all_tasks(TurnAbortReason::Interrupted).await; |
| recv_terminal_event(&events, TerminalEventKind::TurnAborted).await; |
| assert_eq!( |
| state |
| .lock() |
| .await |
| .last_known_step_context |
| .as_ref() |
| .map(Arc::as_ptr), |
| Some(Arc::as_ptr(&fallback)), |
| ); |
| drop(release_fallback); |
| server.shutdown().await; |
| } |
|
|
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| async fn extension_interrupt_emits_thread_idle() { |
| struct ThreadIdleRecorder(async_channel::Sender<()>); |
|
|
| impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for ThreadIdleRecorder { |
| fn on_thread_idle<'a>( |
| &'a self, |
| _input: codex_extension_api::ThreadIdleInput<'a>, |
| ) -> codex_extension_api::ExtensionFuture<'a, ()> { |
| Box::pin(async move { |
| self.0.send(()).await.expect("idle receiver open"); |
| }) |
| } |
| } |
|
|
| let (mut session, turn_context) = make_session_and_context().await; |
| let (idle_tx, idle_rx) = async_channel::bounded(1); |
| let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new(); |
| builder.thread_lifecycle_contributor(Arc::new(ThreadIdleRecorder(idle_tx))); |
| session.services.extensions = Arc::new(builder.build()); |
|
|
| Arc::new(session) |
| .spawn_task(Arc::new(turn_context), Vec::new(), ExtensionInterruptedTask) |
| .await; |
|
|
| timeout(StdDuration::from_secs(5), idle_rx.recv()) |
| .await |
| .expect("extension interrupt should emit thread idle lifecycle") |
| .expect("idle receiver open"); |
| } |
|
|
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| async fn extension_interrupt_survives_the_calling_runtime() { |
| let (sess, tc, rx) = make_session_and_context_with_rx().await; |
| let input = vec![TurnInput::UserInput { |
| acceptance_order: None, |
| content: vec![UserInput::Text { |
| text: "keep turn active for extension interruption".to_string(), |
| text_elements: Vec::new(), |
| }], |
| client_id: None, |
| }]; |
| sess.spawn_task( |
| Arc::clone(&tc), |
| input, |
| NeverEndingTask { |
| kind: TaskKind::Regular, |
| listen_to_cancellation_token: true, |
| }, |
| ) |
| .await; |
|
|
| let session_for_review = Arc::clone(&sess); |
| let review_thread = std::thread::spawn(move || { |
| let runtime = tokio::runtime::Builder::new_current_thread() |
| .enable_all() |
| .build() |
| .expect("helper review runtime"); |
| runtime.block_on(async move { |
| session_for_review |
| .interrupt_turn_with_warning( |
| &tc.sub_id, |
| EventMsg::Warning(codex_protocol::protocol::WarningEvent { |
| message: "extension interrupted this turn".into(), |
| }), |
| ) |
| .await; |
| }); |
| }); |
| review_thread.join().expect("helper review thread"); |
|
|
| let mut observed = Vec::new(); |
| let aborted = timeout(StdDuration::from_secs(5), async { |
| loop { |
| let event = rx.recv().await.expect("event"); |
| if let EventMsg::TurnAborted(event) = &event.msg { |
| let event = event.clone(); |
| observed.push(EventMsg::TurnAborted(event.clone())); |
| break event; |
| } |
| observed.push(event.msg); |
| } |
| }) |
| .await |
| .unwrap_or_else(|_| { |
| panic!("extension should interrupt the turn; observed events: {observed:?}") |
| }); |
| assert_eq!(aborted.reason, TurnAbortReason::Interrupted); |
| } |
|
|
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| async fn turn_complete_flushes_terminal_event_after_delivery() { |
| let (mut sess, tc, rx) = make_session_and_context_with_rx().await; |
| let store = attach_in_memory_thread_store( |
| Arc::get_mut(&mut sess).expect("session should be uniquely owned"), |
| ) |
| .await; |
|
|
| let input = vec![TurnInput::UserInput { |
| acceptance_order: None, |
| content: vec![UserInput::Text { |
| text: "complete normally".to_string(), |
| text_elements: Vec::new(), |
| }], |
| client_id: None, |
| }]; |
| sess.spawn_task(Arc::clone(&tc), input, CompletingTask) |
| .await; |
|
|
| let event = recv_terminal_event(&rx, TerminalEventKind::TurnComplete).await; |
| assert!(matches!(event.msg, EventMsg::TurnComplete(_))); |
| |
| |
| |
| let calls = wait_for_flush_count(&store, 2).await; |
| assert_eq!(2, calls.flush_thread); |
| } |
|
|
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| async fn turn_aborted_flushes_terminal_event_after_delivery() { |
| let (mut sess, tc, rx) = make_session_and_context_with_rx().await; |
| let store = attach_in_memory_thread_store( |
| Arc::get_mut(&mut sess).expect("session should be uniquely owned"), |
| ) |
| .await; |
|
|
| let input = vec![TurnInput::UserInput { |
| acceptance_order: None, |
| content: vec![UserInput::Text { |
| text: "interrupt me".to_string(), |
| text_elements: Vec::new(), |
| }], |
| client_id: None, |
| }]; |
| sess.spawn_task( |
| Arc::clone(&tc), |
| input, |
| NeverEndingTask { |
| kind: TaskKind::Regular, |
| listen_to_cancellation_token: true, |
| }, |
| ) |
| .await; |
|
|
| let abort_task = tokio::spawn({ |
| let sess = Arc::clone(&sess); |
| async move { |
| sess.abort_all_tasks(TurnAbortReason::Interrupted).await; |
| } |
| }); |
|
|
| let event = recv_terminal_event(&rx, TerminalEventKind::TurnAborted).await; |
| match event.msg { |
| EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), |
| other => panic!("unexpected event: {other:?}"), |
| } |
| abort_task.await.expect("abort task should finish"); |
| |
| |
| |
| |
| let calls = wait_for_flush_count(&store, 3).await; |
| assert_eq!(3, calls.flush_thread); |
| } |
|
|
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| #[test_log::test] |
| async fn abort_regular_task_emits_marker_before_turn_aborted() { |
| let (sess, tc, rx) = make_session_and_context_with_rx().await; |
| let input = vec![TurnInput::UserInput { |
| acceptance_order: None, |
| content: vec![UserInput::Text { |
| text: "hello".to_string(), |
| text_elements: Vec::new(), |
| }], |
| client_id: None, |
| }]; |
| sess.spawn_task( |
| Arc::clone(&tc), |
| input, |
| NeverEndingTask { |
| kind: TaskKind::Regular, |
| listen_to_cancellation_token: false, |
| }, |
| ) |
| .await; |
|
|
| sess.abort_all_tasks(TurnAbortReason::Interrupted).await; |
|
|
| |
| let marker_evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) |
| .await |
| .expect("timeout waiting for marker event") |
| .expect("event"); |
| assert!(matches!(marker_evt.msg, EventMsg::RawResponseItem(_))); |
|
|
| let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) |
| .await |
| .expect("timeout waiting for event") |
| .expect("event"); |
| match evt.msg { |
| EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), |
| other => panic!("unexpected event: {other:?}"), |
| } |
| |
| assert!(rx.try_recv().is_err()); |
| } |
|
|
| #[tokio::test] |
| async fn abort_gracefully_emits_marker_before_turn_aborted() { |
| let (sess, tc, rx) = make_session_and_context_with_rx().await; |
| let input = vec![TurnInput::UserInput { |
| acceptance_order: None, |
| content: vec![UserInput::Text { |
| text: "hello".to_string(), |
| text_elements: Vec::new(), |
| }], |
| client_id: None, |
| }]; |
| sess.spawn_task( |
| Arc::clone(&tc), |
| input, |
| NeverEndingTask { |
| kind: TaskKind::Regular, |
| listen_to_cancellation_token: true, |
| }, |
| ) |
| .await; |
|
|
| sess.abort_all_tasks(TurnAbortReason::Interrupted).await; |
|
|
| |
| let marker_evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) |
| .await |
| .expect("timeout waiting for marker event") |
| .expect("event"); |
| assert!(matches!(marker_evt.msg, EventMsg::RawResponseItem(_))); |
|
|
| let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) |
| .await |
| .expect("timeout waiting for event") |
| .expect("event"); |
| match evt.msg { |
| EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), |
| other => panic!("unexpected event: {other:?}"), |
| } |
| |
| assert!(rx.try_recv().is_err()); |
| } |
|
|
| async fn submit_steer_only( |
| sess: &Arc<Session>, |
| input: Vec<UserInput>, |
| expected_turn_id: &str, |
| ) -> TurnInputSubmission { |
| super::turn_input::handle( |
| sess, |
| TurnInputRequest::new(SubmittedTurnInput::UserInput { |
| content: input, |
| client_id: None, |
| }), |
| TurnInputMode::Steer { |
| expected_turn_id: expected_turn_id.to_string(), |
| }, |
| "test-submission".to_string(), |
| ) |
| .await |
| .expect("steer-only submission should be valid") |
| } |
|
|
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() { |
| let (sess, mut tc, rx) = make_session_and_context_with_auth_and_config_and_rx( |
| CodexAuth::from_api_key("Test API Key"), |
| Vec::new(), |
| |config| { |
| config.features.enable(Feature::UnifiedImageBudget).unwrap(); |
| }, |
| ) |
| .await; |
| update_turn_settings_for_test( |
| Arc::get_mut(&mut tc).expect("unshared context"), |
| |settings| { |
| let model = Arc::make_mut(&mut settings.model_info); |
| model.use_responses_lite = false; |
| model.supports_image_detail_original = false; |
| }, |
| ); |
| let input = vec![TurnInput::UserInput { |
| acceptance_order: None, |
| content: vec![UserInput::Text { |
| text: "hello".to_string(), |
| text_elements: Vec::new(), |
| }], |
| client_id: None, |
| }]; |
| sess.spawn_task( |
| Arc::clone(&tc), |
| input, |
| NeverEndingTask { |
| kind: TaskKind::Regular, |
| listen_to_cancellation_token: false, |
| }, |
| ) |
| .await; |
|
|
| while rx.try_recv().is_ok() {} |
|
|
| let mut encoded = std::io::Cursor::new(Vec::new()); |
| image::DynamicImage::new_rgba8( 8, 8) |
| .write_to(&mut encoded, image::ImageFormat::Png) |
| .expect("encode image"); |
| let image_url = codex_utils_image::data_url_from_bytes("image/png", &encoded.into_inner()); |
| let text_element = codex_protocol::user_input::TextElement::new( |
| codex_protocol::user_input::ByteRange { start: 5, end: 12 }, |
| Some("pending marker".to_string()), |
| ); |
| let pending_user_input = vec![ |
| UserInput::Text { |
| text: "late pending input".to_string(), |
| text_elements: vec![text_element.clone()], |
| }, |
| UserInput::Image { |
| image: ImageReference::Inline { |
| image_url: image_url.clone(), |
| }, |
| detail: Some(ImageDetail::High), |
| }, |
| ]; |
| let submission = submit_steer_only(&sess, pending_user_input.clone(), &tc.sub_id).await; |
| assert!(matches!(submission, TurnInputSubmission::Steered { .. })); |
|
|
| let mut current = tc.initial_settings.as_ref().clone(); |
| Arc::make_mut(&mut current.model_info).supports_image_detail_original = true; |
| tc.current_settings.store(Arc::new(current)); |
|
|
| sess.on_task_finished(Arc::clone(&tc), Ok(None)) |
| .await; |
|
|
| let history = sess.clone_history().await; |
| let expected = ResponseItem::Message { |
| id: None, |
| role: "user".to_string(), |
| content: vec![ |
| ContentItem::InputText { |
| text: "late pending input".to_string(), |
| }, |
| ContentItem::InputImage { |
| image: ImageReference::Inline { |
| image_url: image_url.clone(), |
| }, |
| detail: Some(ImageDetail::Original), |
| }, |
| ], |
| phase: None, |
| internal_chat_message_metadata_passthrough: None, |
| }; |
| assert!( |
| strip_response_item_ids(&strip_metadata_from_items(&raw_history_items(&history))) |
| .contains(&expected), |
| "expected pending input to use the current model's image rules on turn completion" |
| ); |
|
|
| let first = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) |
| .await |
| .expect("expected raw response item event") |
| .expect("channel open"); |
| assert!(matches!(first.msg, EventMsg::RawResponseItem(_))); |
|
|
| let second = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) |
| .await |
| .expect("expected item started event") |
| .expect("channel open"); |
| assert!(matches!( |
| second.msg, |
| EventMsg::ItemStarted(ItemStartedEvent { |
| item: TurnItem::UserMessage(UserMessageItem { content, .. }), |
| .. |
| }) if content == pending_user_input |
| )); |
|
|
| let third = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) |
| .await |
| .expect("expected item completed event") |
| .expect("channel open"); |
| assert!(matches!( |
| third.msg, |
| EventMsg::ItemCompleted(ItemCompletedEvent { |
| item: TurnItem::UserMessage(UserMessageItem { content, .. }), |
| .. |
| }) if content == pending_user_input |
| )); |
|
|
| let fourth = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) |
| .await |
| .expect("expected legacy user message event") |
| .expect("channel open"); |
| assert!(matches!( |
| fourth.msg, |
| EventMsg::UserMessage(UserMessageEvent { |
| client_id: None, |
| message, |
| images, |
| text_elements, |
| local_images, |
| .. |
| }) if message == "late pending input" |
| && images == Some(vec![image_url]) |
| && text_elements == vec![text_element] |
| && local_images.is_empty() |
| )); |
|
|
| let fifth = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) |
| .await |
| .expect("expected turn complete event") |
| .expect("channel open"); |
| assert!(matches!( |
| fifth.msg, |
| EventMsg::TurnComplete(TurnCompleteEvent { |
| turn_id, |
| last_agent_message: None, |
| error: None, |
| time_to_first_token_ms: None, |
| .. |
| }) if turn_id == tc.sub_id |
| )); |
| } |
|
|
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| async fn task_finish_emits_thread_idle_lifecycle_after_active_turn_clears() { |
| struct ThreadIdleRecorder { |
| calls: Arc<std::sync::atomic::AtomicUsize>, |
| idle_tx: async_channel::Sender<()>, |
| expected_thread_id: ThreadId, |
| } |
|
|
| impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for ThreadIdleRecorder { |
| fn on_thread_idle<'a>( |
| &'a self, |
| input: codex_extension_api::ThreadIdleInput<'a>, |
| ) -> codex_extension_api::ExtensionFuture<'a, ()> { |
| Box::pin(async move { |
| assert_eq!( |
| self.expected_thread_id.to_string(), |
| input.thread_store.level_id() |
| ); |
| self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); |
| self.idle_tx.send(()).await.expect("idle receiver open"); |
| }) |
| } |
| } |
|
|
| let (mut session, turn_context) = make_session_and_context().await; |
| let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); |
| let (idle_tx, idle_rx) = async_channel::bounded(1); |
| let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new(); |
| builder.thread_lifecycle_contributor(Arc::new(ThreadIdleRecorder { |
| calls: Arc::clone(&calls), |
| idle_tx, |
| expected_thread_id: session.thread_id, |
| })); |
| session.services.extensions = Arc::new(builder.build()); |
|
|
| let session = Arc::new(session); |
| session |
| .spawn_task(Arc::new(turn_context), Vec::new(), CompletingTask) |
| .await; |
|
|
| timeout(StdDuration::from_secs(2), idle_rx.recv()) |
| .await |
| .expect("thread idle lifecycle") |
| .expect("idle receiver open"); |
| assert_eq!(1, calls.load(std::sync::atomic::Ordering::SeqCst)); |
| assert!(session.active_turn.lock().await.is_none()); |
| } |
|
|
| #[tokio::test] |
| async fn thread_idle_lifecycle_waits_for_trigger_turn_mailbox_work() { |
| struct ThreadIdleRecorder { |
| calls: Arc<std::sync::atomic::AtomicUsize>, |
| } |
|
|
| impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for ThreadIdleRecorder { |
| fn on_thread_idle<'a>( |
| &'a self, |
| _input: codex_extension_api::ThreadIdleInput<'a>, |
| ) -> codex_extension_api::ExtensionFuture<'a, ()> { |
| Box::pin(async move { |
| self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); |
| }) |
| } |
| } |
|
|
| let (mut session, _turn_context) = make_session_and_context().await; |
| let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); |
| let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new(); |
| builder.thread_lifecycle_contributor(Arc::new(ThreadIdleRecorder { |
| calls: Arc::clone(&calls), |
| })); |
| session.services.extensions = Arc::new(builder.build()); |
| session |
| .input_queue |
| .enqueue_mailbox_communication( |
| InterAgentCommunication::new( |
| AgentPath::root(), |
| AgentPath::root(), |
| Vec::new(), |
| "pending trigger".to_string(), |
| true, |
| ), |
| Default::default(), |
| ) |
| .await; |
|
|
| session |
| .emit_thread_idle_lifecycle_if_idle(codex_extension_api::ThreadIdleCause::Completed) |
| .await; |
|
|
| assert_eq!(0, calls.load(std::sync::atomic::Ordering::SeqCst)); |
| } |
|
|
| #[tokio::test] |
| async fn abort_empty_active_turn_preserves_pending_input() { |
| let (sess, _tc, _rx) = make_session_and_context_with_rx().await; |
| let pending_item = ResponseItem::Message { |
| id: None, |
| role: "user".to_string(), |
| content: vec![ContentItem::InputText { |
| text: "late pending input".to_string(), |
| }], |
| phase: None, |
| internal_chat_message_metadata_passthrough: None, |
| }; |
| let turn_state = { |
| let mut active = sess.active_turn.lock().await; |
| let active_turn = active.get_or_insert_with(ActiveTurn::default); |
| Arc::clone(&active_turn.turn_state) |
| }; |
| sess.input_queue |
| .extend_pending_input_for_turn_state( |
| turn_state.as_ref(), |
| vec![TurnInput::ResponseItem(pending_item.clone().into())], |
| ) |
| .await; |
|
|
| sess.abort_all_tasks(TurnAbortReason::Replaced).await; |
|
|
| assert!(sess.active_turn.lock().await.is_none()); |
| assert_eq!( |
| sess.input_queue |
| .take_pending_input_for_turn_state(turn_state.as_ref()) |
| .await, |
| vec![TurnInput::ResponseItem(pending_item.into())] |
| ); |
| } |
|
|
| async fn set_total_token_usage(sess: &Session, total_token_usage: TokenUsage) { |
| let mut state = sess.state.lock().await; |
| state.set_token_info(Some(TokenUsageInfo { |
| total_token_usage, |
| last_token_usage: TokenUsage::default(), |
| model_context_window: None, |
| })); |
| } |
|
|
| #[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(), |
| false, |
| ); |
| sess.spawn_task( |
| Arc::clone(&tc), |
| Vec::new(), |
| NeverEndingTask { |
| kind: TaskKind::Regular, |
| listen_to_cancellation_token: true, |
| }, |
| ) |
| .await; |
|
|
| sess.input_queue |
| .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) |
| .await; |
| sess.input_queue |
| .enqueue_mailbox_communication(communication.clone(), Default::default()) |
| .await; |
|
|
| assert!( |
| !sess.input_queue.has_pending_input(&sess.active_turn).await, |
| "queue-only mailbox mail should stay buffered once the current turn emitted its answer" |
| ); |
| assert_eq!( |
| sess.input_queue |
| .get_pending_input(&sess.active_turn) |
| .await |
| .0, |
| Vec::new() |
| ); |
|
|
| sess.abort_all_tasks(TurnAbortReason::Replaced).await; |
|
|
| assert_eq!( |
| (sess.input_queue.get_pending_input(&sess.active_turn).await).0, |
| vec![TurnInput::InterAgentCommunication(communication)], |
| ); |
| } |
|
|
| #[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.input_queue |
| .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) |
| .await; |
| sess.input_queue |
| .enqueue_mailbox_communication( |
| InterAgentCommunication::new( |
| AgentPath::try_from("/root/worker").expect("worker path should parse"), |
| AgentPath::root(), |
| Vec::new(), |
| "late trigger update".to_string(), |
| true, |
| ), |
| Default::default(), |
| ) |
| .await; |
|
|
| assert!( |
| !sess.input_queue.has_pending_input(&sess.active_turn).await, |
| "trigger-turn mailbox mail should not extend the current turn after its answer boundary" |
| ); |
|
|
| sess.abort_all_tasks(TurnAbortReason::Replaced).await; |
|
|
| assert!(sess.input_queue.has_trigger_turn_mailbox_items().await); |
| } |
|
|
| #[test_case(None; "independent root")] |
| #[test_case(Some("root-a"); "inherited root")] |
| #[tokio::test] |
| async fn active_turn_keeps_first_root_when_mail_coalesces(inherited_root: Option<&str>) { |
| let (sess, tc, _rx) = make_session_and_context_with_rx().await; |
| if let Some(root) = inherited_root { |
| tc.turn_metadata_state.set_root_turn_id(root.to_string()); |
| } |
| let first = InterAgentCommunication::new( |
| AgentPath::try_from("/root/worker_a").expect("worker path should parse"), |
| AgentPath::root(), |
| Vec::new(), |
| "first".to_string(), |
| true, |
| ); |
| let second = InterAgentCommunication::new( |
| AgentPath::try_from("/root/worker_b").expect("worker path should parse"), |
| AgentPath::root(), |
| Vec::new(), |
| "second".to_string(), |
| true, |
| ); |
| for (index, (communication, parent_turn_id, root_turn_id)) in [ |
| (first.clone(), "parent-a", "root-a"), |
| (second.clone(), "parent-b", "root-b"), |
| ] |
| .into_iter() |
| .enumerate() |
| { |
| sess.input_queue |
| .enqueue_mailbox_communication( |
| communication, |
| codex_protocol::turn_input::TurnStartOptions { |
| parent_turn_id: Some(parent_turn_id.to_string()), |
| root_turn_id: Some(root_turn_id.to_string()), |
| ..Default::default() |
| }, |
| ) |
| .await; |
| if index == 0 { |
| |
| |
| sess.spawn_task( |
| Arc::clone(&tc), |
| Vec::new(), |
| NeverEndingTask { |
| kind: TaskKind::Regular, |
| listen_to_cancellation_token: true, |
| }, |
| ) |
| .await; |
| } |
| } |
|
|
| assert_eq!( |
| (sess.input_queue.get_pending_input(&sess.active_turn).await).0, |
| vec![ |
| TurnInput::InterAgentCommunication(first), |
| TurnInput::InterAgentCommunication(second), |
| ] |
| ); |
| assert_eq!( |
| tc.turn_metadata_state.root_turn_id().as_deref(), |
| Some(inherited_root.unwrap_or(&tc.sub_id)) |
| ); |
| assert!(!sess.input_queue.has_pending_mailbox_items().await); |
|
|
| sess.abort_all_tasks(TurnAbortReason::Replaced).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(), |
| false, |
| ); |
| sess.spawn_task( |
| Arc::clone(&tc), |
| Vec::new(), |
| NeverEndingTask { |
| kind: TaskKind::Regular, |
| listen_to_cancellation_token: true, |
| }, |
| ) |
| .await; |
|
|
| sess.input_queue |
| .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) |
| .await; |
| sess.input_queue |
| .enqueue_mailbox_communication(communication.clone(), Default::default()) |
| .await; |
| let submission = submit_steer_only( |
| &sess, |
| vec![UserInput::Text { |
| text: "follow up".to_string(), |
| text_elements: Vec::new(), |
| }], |
| &tc.sub_id, |
| ) |
| .await; |
| assert!(matches!(submission, TurnInputSubmission::Steered { .. })); |
|
|
| assert_eq!( |
| (sess.input_queue.get_pending_input(&sess.active_turn).await).0, |
| vec![ |
| TurnInput::UserInput { |
| acceptance_order: None, |
| content: vec![UserInput::Text { |
| text: "follow up".to_string(), |
| text_elements: Vec::new(), |
| }], |
| client_id: None, |
| }, |
| TurnInput::InterAgentCommunication(communication), |
| ], |
| ); |
| } |
|
|
| #[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(), |
| false, |
| ); |
| sess.spawn_task( |
| Arc::clone(&tc), |
| Vec::new(), |
| NeverEndingTask { |
| kind: TaskKind::Regular, |
| listen_to_cancellation_token: true, |
| }, |
| ) |
| .await; |
|
|
| sess.input_queue |
| .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) |
| .await; |
| sess.input_queue |
| .enqueue_mailbox_communication(communication.clone(), Default::default()) |
| .await; |
| let submission = submit_steer_only( |
| &sess, |
| vec![UserInput::Text { |
| text: "follow up".to_string(), |
| text_elements: Vec::new(), |
| }], |
| &tc.sub_id, |
| ) |
| .await; |
| assert!(matches!(submission, TurnInputSubmission::Steered { .. })); |
|
|
| sess.input_queue |
| .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) |
| .await; |
|
|
| assert_eq!( |
| (sess.input_queue.get_pending_input(&sess.active_turn).await).0, |
| vec![ |
| TurnInput::UserInput { |
| acceptance_order: None, |
| content: vec![UserInput::Text { |
| text: "follow up".to_string(), |
| text_elements: Vec::new(), |
| }], |
| client_id: None, |
| }, |
| TurnInput::InterAgentCommunication(communication), |
| ], |
| ); |
| } |
|
|
| #[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(), |
| false, |
| ); |
| sess.spawn_task( |
| Arc::clone(&tc), |
| Vec::new(), |
| NeverEndingTask { |
| kind: TaskKind::Regular, |
| listen_to_cancellation_token: true, |
| }, |
| ) |
| .await; |
|
|
| sess.input_queue |
| .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) |
| .await; |
| sess.input_queue |
| .enqueue_mailbox_communication(communication.clone(), Default::default()) |
| .await; |
|
|
| let item = ResponseItem::FunctionCall { |
| id: None, |
| name: "test_tool".to_string(), |
| namespace: None, |
| arguments: "{}".to_string(), |
| call_id: "call-1".to_string(), |
| encrypted_function_args: None, |
| internal_chat_message_metadata_passthrough: None, |
| }; |
| let mut ctx = HandleOutputCtx { |
| sess: Arc::clone(&sess), |
| step_context: StepContext::for_test(Arc::clone(&tc)), |
| turn_store: Arc::new(codex_extension_api::ExtensionData::new(tc.sub_id.clone())), |
| tool_runtime: test_tool_runtime(Arc::clone(&sess), Arc::clone(&tc)), |
| cancellation_token: CancellationToken::new(), |
| }; |
|
|
| let output = handle_output_item_done(&mut ctx, item, None) |
| .await |
| .expect("tool call should be handled"); |
|
|
| assert!(output.needs_follow_up); |
| assert!(output.tool_future.is_some()); |
| assert_eq!( |
| (sess.input_queue.get_pending_input(&sess.active_turn).await).0, |
| vec![TurnInput::InterAgentCommunication(communication)], |
| ); |
| } |
|
|
| #[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; |
| let input = vec![TurnInput::UserInput { |
| acceptance_order: None, |
| content: vec![UserInput::Text { |
| text: "start review".to_string(), |
| text_elements: Vec::new(), |
| }], |
| client_id: None, |
| }]; |
| sess.spawn_task(Arc::clone(&tc), input, ReviewTask::new()) |
| .await; |
|
|
| sess.abort_all_tasks(TurnAbortReason::Interrupted).await; |
|
|
| |
| |
| |
| let mut exited_review_mode_idx = None; |
| let mut turn_aborted_idx = None; |
| let mut idx = 0usize; |
| let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(3); |
| while tokio::time::Instant::now() < deadline { |
| let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); |
| let evt = tokio::time::timeout(remaining, rx.recv()) |
| .await |
| .expect("timeout waiting for event") |
| .expect("event"); |
| let event_idx = idx; |
| idx = idx.saturating_add(1); |
| match evt.msg { |
| EventMsg::ExitedReviewMode(ev) => { |
| assert!(ev.review_output.is_none()); |
| exited_review_mode_idx = Some(event_idx); |
| } |
| EventMsg::TurnAborted(ev) => { |
| assert_eq!(TurnAbortReason::Interrupted, ev.reason); |
| turn_aborted_idx = Some(event_idx); |
| break; |
| } |
| _ => {} |
| } |
| } |
| assert!( |
| exited_review_mode_idx.is_some(), |
| "expected ExitedReviewMode after abort" |
| ); |
| assert!( |
| turn_aborted_idx.is_some(), |
| "expected TurnAborted after abort" |
| ); |
| assert!( |
| exited_review_mode_idx.unwrap() < turn_aborted_idx.unwrap(), |
| "expected ExitedReviewMode before TurnAborted" |
| ); |
|
|
| let history = sess.clone_history().await; |
| |
| assert!( |
| history.raw_items().any(|item| { |
| let ResponseItem::Message { role, content, .. } = item else { |
| return false; |
| }; |
| if role != "user" { |
| return false; |
| } |
| content.iter().any(|content_item| { |
| let ContentItem::InputText { text } = content_item else { |
| return false; |
| }; |
| TurnAborted::matches_text(text) |
| }) |
| }), |
| "expected a model-visible turn aborted marker in history after interrupt" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn fatal_tool_error_stops_turn_and_reports_error() { |
| let (session, turn_context, _rx) = make_session_and_context_with_rx().await; |
| let step_context = StepContext::for_test(Arc::clone(&turn_context)); |
| let (registry, hosted_specs) = tool_registry_for_test_step(step_context.as_ref()); |
| let router = ToolRouter::from_registry( |
| step_context.turn.as_ref(), |
| step_context.turn.model_info(), |
| registry, |
| hosted_specs, |
| &Default::default(), |
| ); |
| let item = ResponseItem::CustomToolCall { |
| id: None, |
| status: None, |
| call_id: "call-1".to_string(), |
| name: "exec_command".to_string(), |
| namespace: None, |
| input: "{}".to_string(), |
| internal_chat_message_metadata_passthrough: None, |
| }; |
|
|
| let call = ToolRouter::build_tool_call(item.clone()) |
| .expect("build tool call") |
| .expect("tool call present"); |
| let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); |
| let err = router |
| .dispatch_tool_call_with_code_mode_result( |
| Arc::clone(&session), |
| step_context, |
| CancellationToken::new(), |
| tracker, |
| call, |
| ToolCallSource::Direct, |
| ) |
| .await |
| .err() |
| .expect("expected fatal error"); |
|
|
| match err { |
| FunctionCallError::Fatal(message) => { |
| assert_eq!( |
| message, |
| "tool exec_command invoked with incompatible payload" |
| ); |
| } |
| other => panic!("expected FunctionCallError::Fatal, got {other:?}"), |
| } |
| } |
|
|
| async fn sample_rollout( |
| session: &Session, |
| _turn_context: &TurnContext, |
| ) -> (Vec<RolloutItem>, Vec<ResponseItem>) { |
| let mut rollout_items = Vec::new(); |
| let mut live_history = ContextManager::new(); |
|
|
| |
| let reconstruction_turn = session.new_default_turn().await; |
| let initial_context = build_initial_context(session, &reconstruction_turn).await; |
| for item in &initial_context { |
| rollout_items.push(RolloutItem::ResponseItem(item.clone().into())); |
| } |
| live_history.record_items( |
| initial_context.iter(), |
| reconstruction_turn.model_info().truncation_policy.into(), |
| ); |
|
|
| let user1 = user_message("first user"); |
| live_history.record_items( |
| std::iter::once(&user1), |
| reconstruction_turn.model_info().truncation_policy.into(), |
| ); |
| rollout_items.push(RolloutItem::ResponseItem(user1.clone().into())); |
|
|
| let assistant1 = assistant_message("assistant reply one"); |
| live_history.record_items( |
| std::iter::once(&assistant1), |
| reconstruction_turn.model_info().truncation_policy.into(), |
| ); |
| rollout_items.push(RolloutItem::ResponseItem(assistant1.clone().into())); |
|
|
| let summary1 = "summary one"; |
| let snapshot1 = raw_history_items(&live_history); |
| let user_messages1 = collect_user_messages(&snapshot1); |
| let rebuilt1 = compact::build_compacted_history(Vec::new(), &user_messages1, summary1); |
| live_history.replace_annotated(rebuilt1); |
| let (window_number, window_ids) = session.advance_auto_compact_window().await; |
| rollout_items.push(RolloutItem::Compacted(CompactedItem { |
| message: summary1.to_string(), |
| replacement_history: None, |
| retained_context: None, |
| guardian_history: None, |
| mcp_resource_origins: None, |
| window_number: Some(window_number), |
| first_window_id: Some(window_ids.first_window_id.to_string()), |
| previous_window_id: window_ids.previous_window_id.map(|id| id.to_string()), |
| window_id: Some(window_ids.window_id.to_string()), |
| compaction_response_id: None, |
| latest_token_usage_record: None, |
| })); |
|
|
| let user2 = user_message("second user"); |
| live_history.record_items( |
| std::iter::once(&user2), |
| reconstruction_turn.model_info().truncation_policy.into(), |
| ); |
| rollout_items.push(RolloutItem::ResponseItem(user2.clone().into())); |
|
|
| let assistant2 = assistant_message("assistant reply two"); |
| live_history.record_items( |
| std::iter::once(&assistant2), |
| reconstruction_turn.model_info().truncation_policy.into(), |
| ); |
| rollout_items.push(RolloutItem::ResponseItem(assistant2.clone().into())); |
|
|
| let summary2 = "summary two"; |
| let snapshot2 = raw_history_items(&live_history); |
| let user_messages2 = collect_user_messages(&snapshot2); |
| let rebuilt2 = compact::build_compacted_history(Vec::new(), &user_messages2, summary2); |
| live_history.replace_annotated(rebuilt2); |
| let (window_number, window_ids) = session.advance_auto_compact_window().await; |
| rollout_items.push(RolloutItem::Compacted(CompactedItem { |
| message: summary2.to_string(), |
| replacement_history: None, |
| retained_context: None, |
| guardian_history: None, |
| mcp_resource_origins: None, |
| window_number: Some(window_number), |
| first_window_id: Some(window_ids.first_window_id.to_string()), |
| previous_window_id: window_ids.previous_window_id.map(|id| id.to_string()), |
| window_id: Some(window_ids.window_id.to_string()), |
| compaction_response_id: None, |
| latest_token_usage_record: None, |
| })); |
|
|
| let user3 = user_message("third user"); |
| live_history.record_items( |
| std::iter::once(&user3), |
| reconstruction_turn.model_info().truncation_policy.into(), |
| ); |
| rollout_items.push(RolloutItem::ResponseItem(user3.into())); |
|
|
| let assistant3 = assistant_message("assistant reply three"); |
| live_history.record_items( |
| std::iter::once(&assistant3), |
| reconstruction_turn.model_info().truncation_policy.into(), |
| ); |
| rollout_items.push(RolloutItem::ResponseItem(assistant3.into())); |
|
|
| (rollout_items, raw_history_items(&live_history)) |
| } |
|
|
| #[tokio::test] |
| async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() { |
| 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; |
| Arc::make_mut(&mut turn_context_raw.config) |
| .permissions |
| .approval_policy |
| .set(AskForApproval::Never) |
| .expect("test setup should allow updating approval policy"); |
| let session = Arc::new(session); |
| let turn_context = Arc::new(turn_context_raw); |
| let step_context = StepContext::for_test(Arc::clone(&turn_context)); |
| let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); |
|
|
| let handler = ExecCommandHandler::default(); |
| let resp = handler |
| .handle(ToolInvocation { |
| session: Arc::clone(&session), |
| turn: Arc::clone(&turn_context), |
| step_context, |
| cancellation_token: CancellationToken::new(), |
| tracker: Arc::clone(&tracker), |
| call_id: "exec-call".to_string(), |
| tool_name: codex_tools::ToolName::plain("exec_command"), |
| source: crate::tools::context::ToolCallSource::Direct, |
| payload: ToolPayload::Function { |
| arguments: serde_json::json!({ |
| "cmd": "echo hi", |
| "sandbox_permissions": SandboxPermissions::RequireEscalated, |
| "justification": "need unsandboxed execution", |
| }) |
| .to_string(), |
| }, |
| }) |
| .await; |
|
|
| let Err(FunctionCallError::RespondToModel(output)) = resp else { |
| panic!("expected error result"); |
| }; |
|
|
| let expected = format!( |
| "approval policy is {policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {policy:?}", |
| policy = turn_context.approval_policy() |
| ); |
|
|
| pretty_assertions::assert_eq!(output, expected); |
| } |
|
|
| #[tokio::test] |
| async fn session_start_hooks_only_load_from_trusted_project_layers() -> std::io::Result<()> { |
| let temp = tempfile::tempdir()?; |
| let codex_home = temp.path().join("home"); |
| let project_root = temp.path().join("project"); |
| let nested = project_root.join("nested"); |
| let root_dot_codex = project_root.join(".codex"); |
| let nested_dot_codex = nested.join(".codex"); |
|
|
| std::fs::create_dir_all(&codex_home)?; |
| std::fs::create_dir_all(&nested_dot_codex)?; |
| std::fs::write(project_root.join(".git"), "gitdir: here")?; |
| write_project_hooks(&root_dot_codex)?; |
| write_project_hooks(&nested_dot_codex)?; |
| write_project_trust_config(&codex_home, &[(&nested, TrustLevel::Trusted)]).await?; |
|
|
| let config = ConfigBuilder::default() |
| .codex_home(codex_home) |
| .fallback_cwd(Some(nested)) |
| .build() |
| .await?; |
|
|
| let hook_list = codex_hooks::list_hooks(codex_hooks::HooksConfig { |
| feature_enabled: true, |
| config_layer_stack: Some(config.config_layer_stack.clone()), |
| ..codex_hooks::HooksConfig::default() |
| }); |
| let expected_source_path = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path( |
| nested_dot_codex.join("hooks.json"), |
| )?; |
| assert_eq!( |
| hook_list |
| .hooks |
| .iter() |
| .map(|hook| &hook.source_path) |
| .collect::<Vec<_>>(), |
| vec![&expected_source_path], |
| ); |
| assert_eq!( |
| hook_list.hooks[0].trust_status, |
| codex_protocol::protocol::HookTrustStatus::Untrusted |
| ); |
| assert!(preview_session_start_hooks(&config).await?.is_empty()); |
|
|
| Ok(()) |
| } |
|
|
| #[tokio::test] |
| async fn session_start_hooks_require_project_trust_without_config_toml() -> std::io::Result<()> { |
| let temp = tempfile::tempdir()?; |
| let project_root = temp.path().join("project"); |
| let nested = project_root.join("nested"); |
| let dot_codex = project_root.join(".codex"); |
| std::fs::create_dir_all(&nested)?; |
| std::fs::write(project_root.join(".git"), "gitdir: here")?; |
| write_project_hooks(&dot_codex)?; |
|
|
| let cases = [ |
| ("unknown", Vec::<(&Path, TrustLevel)>::new(), 0_usize), |
| ( |
| "untrusted", |
| vec![(&project_root as &Path, TrustLevel::Untrusted)], |
| 0_usize, |
| ), |
| ( |
| "trusted", |
| vec![(&project_root as &Path, TrustLevel::Trusted)], |
| 1_usize, |
| ), |
| ]; |
|
|
| for (name, trust_entries, expected_hooks) in cases { |
| let codex_home = temp.path().join(format!("home_{name}")); |
| std::fs::create_dir_all(&codex_home)?; |
| write_project_trust_config(&codex_home, &trust_entries).await?; |
|
|
| let config = ConfigBuilder::default() |
| .codex_home(codex_home) |
| .fallback_cwd(Some(nested.clone())) |
| .build() |
| .await?; |
|
|
| let hook_list = codex_hooks::list_hooks(codex_hooks::HooksConfig { |
| feature_enabled: true, |
| config_layer_stack: Some(config.config_layer_stack.clone()), |
| ..codex_hooks::HooksConfig::default() |
| }); |
| assert_eq!( |
| hook_list.hooks.len(), |
| expected_hooks, |
| "unexpected discovered hook count for {name}", |
| ); |
| assert!(preview_session_start_hooks(&config).await?.is_empty()); |
| if expected_hooks == 1 { |
| assert_eq!( |
| hook_list.hooks[0].trust_status, |
| codex_protocol::protocol::HookTrustStatus::Untrusted |
| ); |
| } |
| } |
|
|
| Ok(()) |
| } |
|
|