Download codex-rs/core/src/session/tests.rs from SaylorTwift/codex: direct link, hf CLI and curl.
- Browser
- Download file 446 kB
-
https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/core/src/session/tests.rs
- Command line
-
hf download hf://SaylorTwift/codex/codex-rs/core/src/session/tests.rs
-
curl -L -o tests.rs https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/core/src/session/tests.rs
446 kB
| 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; | |
| 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(), | |
| )) | |
| } | |
| /// Updates both initial/current views before sharing a test turn context. | |
| 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(); | |
| // Unit fixtures still customize the legacy Config directly. | |
| // Production capture instead takes the turn's current snapshot. | |
| 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(), | |
| /*tool_namespaces_info*/ 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() | |
| }), | |
| } | |
| } | |
| 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_"))); | |
| } | |
| 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_"))); | |
| } | |
| 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() | |
| } | |
| 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", | |
| /*account_id*/ None, | |
| /*account_email*/ None, | |
| Some(TelemetryAuthMode::Chatgpt), | |
| "test_originator".to_string(), | |
| /*log_user_prompts*/ 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", | |
| /*value*/ 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", | |
| /*inc*/ 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()), | |
| ]) | |
| ); | |
| } | |
| 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", /*value*/ 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, | |
| } | |
| } | |
| 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; | |
| } | |
| 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(/*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()); | |
| } | |
| 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( | |
| /*auth_manager*/ None, | |
| AgentIdentityAuthPolicy::JwtOnly, | |
| thread_id, | |
| ModelProviderInfo::create_openai_provider(/* base_url */ /*base_url*/ None), | |
| codex_protocol::protocol::SessionSource::Exec, | |
| "test_originator".to_string(), | |
| /*model_verbosity*/ None, | |
| /*content_item_kinds_enabled*/ true, | |
| /*enable_request_compression*/ false, | |
| /*include_timing_metrics*/ false, | |
| /*beta_features_header*/ None, | |
| /*concurrent_reasoning_summaries_enabled*/ false, | |
| /*attestation_provider*/ 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(), | |
| /*tool_suggest_candidates*/ None, | |
| /*wait_for_environment_tool_config*/ 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(), | |
| } | |
| } | |
| fn assistant_message_stream_parsers_can_be_seeded_from_output_item_added_text() { | |
| let mut parsers = AssistantMessageStreamParsers::new(/*plan_mode*/ 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()); | |
| } | |
| fn assistant_message_stream_parsers_seed_buffered_prefix_stays_out_of_finish_tail() { | |
| let mut parsers = AssistantMessageStreamParsers::new(/*plan_mode*/ 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()); | |
| } | |
| fn assistant_message_stream_parsers_seed_plan_parser_across_added_and_delta_boundaries() { | |
| let mut parsers = AssistantMessageStreamParsers::new(/*plan_mode*/ 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()); | |
| } | |
| 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"); | |
| } | |
| 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")); | |
| } | |
| 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(), | |
| /*requirements*/ None, | |
| &permission_profile, | |
| )?; | |
| let mut exec_policy = Policy::empty(); | |
| exec_policy.add_network_rule( | |
| "example.com", | |
| NetworkRuleProtocol::Https, | |
| Decision::Allow, | |
| /*justification*/ None, | |
| )?; | |
| let (started_proxy, _) = Session::start_managed_network_proxy( | |
| &spec, | |
| &exec_policy, | |
| &permission_profile, | |
| SandboxType::None, | |
| /*network_policy_decider*/ None, | |
| /*blocked_request_observer*/ None, | |
| /*managed_network_requirements_enabled*/ 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(()) | |
| } | |
| 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, | |
| /*justification*/ None, | |
| )?; | |
| let (started_proxy, _) = Session::start_managed_network_proxy( | |
| &spec, | |
| &exec_policy, | |
| &permission_profile, | |
| SandboxType::None, | |
| /*network_policy_decider*/ None, | |
| /*blocked_request_observer*/ None, | |
| /*managed_network_requirements_enabled*/ 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(()) | |
| } | |
| 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), | |
| /*blocked_request_observer*/ None, | |
| /*managed_network_requirements_enabled*/ 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(()) | |
| } | |
| 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)), | |
| /*blocked_request_observer*/ None, | |
| /*managed_network_requirements_enabled*/ 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"); | |
| { | |
| 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()]) | |
| ); | |
| { | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> anyhow::Result<()> { | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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()); | |
| let command = r#"$val = $env:HTTP_PROXY; if ([string]::IsNullOrEmpty($val)) { $val = 'not-set' } ; [System.Console]::Write($val)"#.to_string(); | |
| let command = r#"sh -c "printf '%s' \"${HTTP_PROXY:-not-set}\"""#.to_string(); | |
| execute_user_shell_command( | |
| Arc::clone(&session), | |
| turn_context, | |
| command, | |
| /*timeout_ms*/ 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(()) | |
| } | |
| 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, /*use_login_shell*/ true); | |
| execute_user_shell_command( | |
| Arc::clone(&session), | |
| turn_context, | |
| command, | |
| /*timeout_ms*/ 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(()) | |
| } | |
| 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)); | |
| } | |
| 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); | |
| } | |
| 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") | |
| ); | |
| } | |
| 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(()) | |
| } | |
| 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); | |
| struct NormalizedHookIdentity { | |
| event_name: &'static str, | |
| 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(()) | |
| } | |
| 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"), | |
| ] | |
| ); | |
| } | |
| 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"), | |
| ] | |
| ); | |
| } | |
| 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); | |
| } | |
| 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()])); | |
| } | |
| 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()])); | |
| } | |
| 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()); | |
| } | |
| 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) | |
| ); | |
| } | |
| 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); | |
| } | |
| 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)); | |
| } | |
| 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); | |
| } | |
| 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"); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| 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(), | |
| /*trigger_turn*/ 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)) | |
| ); | |
| } | |
| 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(), | |
| /*trigger_turn*/ 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()) | |
| ); | |
| } | |
| 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(/*limit*/ 101) | |
| } else { | |
| codex_protocol::openai_models::TruncationPolicyConfig::tokens(/*limit*/ 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); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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()) | |
| ); | |
| } | |
| fn resolve_multi_agent_version_handles_unset_and_legacy_history() { | |
| let thread_id = ThreadId::default(); | |
| assert_eq!( | |
| resolve_multi_agent_version( | |
| &InitialHistory::New, | |
| /*inherited_multi_agent_version*/ None | |
| ), | |
| None | |
| ); | |
| assert_eq!( | |
| resolve_multi_agent_version( | |
| &InitialHistory::Resumed(ResumedHistory { | |
| conversation_id: thread_id, | |
| history: Arc::new(Vec::new()), | |
| rollout_path: None, | |
| }), | |
| /*inherited_multi_agent_version*/ 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()), | |
| /*inherited_multi_agent_version*/ None | |
| ), | |
| Some(MultiAgentVersion::V1) | |
| ); | |
| } | |
| 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, | |
| }) | |
| } | |
| 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) | |
| ); | |
| } | |
| 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)); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| async fn record_token_usage_info_notifies_extension_contributors() { | |
| struct SessionTokenUsageMarker; | |
| struct ThreadTokenUsageMarker; | |
| 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); | |
| } | |
| async fn turn_start_lifecycle_exposes_turn_metadata_and_token_baseline() { | |
| struct SessionTurnStartMarker; | |
| struct ThreadTurnStartMarker; | |
| 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); | |
| } | |
| async fn turn_error_lifecycle_exposes_error_and_stores() { | |
| struct SessionTurnErrorMarker; | |
| struct ThreadTurnErrorMarker; | |
| 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); | |
| } | |
| async fn config_change_contributor_observes_effective_config_changes() { | |
| struct SessionConfigMarker; | |
| struct ThreadConfigMarker; | |
| 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()), | |
| /*effort*/ None, | |
| /*developer_instructions*/ 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); | |
| } | |
| 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)) | |
| ); | |
| } | |
| 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))) | |
| ); | |
| } | |
| 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()) | |
| ); | |
| } | |
| 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(()) | |
| } | |
| 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; | |
| // Forking reads the persisted rollout JSONL, so force the completed source turn to disk | |
| // before snapshotting from it. | |
| 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(()) | |
| } | |
| 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, | |
| 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") | |
| ); | |
| } | |
| 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(), /*auth_manager*/ 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, | |
| }) | |
| ); | |
| } | |
| 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(), /*auth_manager*/ 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, | |
| }) | |
| ); | |
| } | |
| fn prefers_structured_content_when_present() { | |
| let ctr = McpCallToolResult { | |
| // Content present but should be ignored because structured_content is set. | |
| 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); | |
| } | |
| 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" | |
| ); | |
| } | |
| 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), | |
| /*fast_mode_enabled*/ 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()), | |
| /*fast_mode_enabled*/ 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; | |
| // Historical model selection starts from the frozen turn settings, not a | |
| // later publication, and retains its selected summary/tier. | |
| 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) | |
| ); | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| 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(), | |
| /*account_id*/ None, | |
| Some("test@test.com".to_string()), | |
| Some(TelemetryAuthMode::Chatgpt), | |
| "test_originator".to_string(), | |
| /*log_user_prompts*/ 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 | |
| } | |
| 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( | |
| /*configured_service_tier*/ None, | |
| /*fast_mode_enabled*/ true, | |
| &model_info, | |
| ), | |
| None | |
| ); | |
| } | |
| 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( | |
| /*configured_service_tier*/ None, | |
| /*fast_mode_enabled*/ false, | |
| &model_info, | |
| ), | |
| None | |
| ); | |
| } | |
| 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()), | |
| /*fast_mode_enabled*/ true, | |
| &model_info, | |
| ), | |
| Some(ServiceTier::Fast.request_value().to_string()) | |
| ); | |
| } | |
| fn get_service_tier_does_not_default_when_model_has_no_default() { | |
| let model_info = model_with_default_service_tier(/*default_service_tier*/ None); | |
| assert_eq!( | |
| get_service_tier( | |
| /*configured_service_tier*/ None, | |
| /*fast_mode_enabled*/ true, | |
| &model_info, | |
| ), | |
| None | |
| ); | |
| } | |
| 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()), | |
| /*fast_mode_enabled*/ true, | |
| &model_info, | |
| ), | |
| None | |
| ); | |
| assert_eq!( | |
| get_service_tier( | |
| Some(ServiceTier::Flex.request_value().to_string()), | |
| /*fast_mode_enabled*/ true, | |
| &model_info, | |
| ), | |
| None | |
| ); | |
| assert_eq!( | |
| get_service_tier( | |
| Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()), | |
| /*fast_mode_enabled*/ true, | |
| &model_info, | |
| ), | |
| Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()) | |
| ); | |
| } | |
| 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()), | |
| /*fast_mode_enabled*/ false, | |
| &model_info, | |
| ), | |
| None | |
| ); | |
| assert_eq!( | |
| get_service_tier( | |
| Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()), | |
| /*fast_mode_enabled*/ false, | |
| &model_info, | |
| ), | |
| None | |
| ); | |
| assert_eq!( | |
| get_service_tier( | |
| Some("unsupported".to_string()), | |
| /*fast_mode_enabled*/ false, | |
| &model_info, | |
| ), | |
| None | |
| ); | |
| assert_eq!( | |
| get_service_tier( | |
| /*configured_service_tier*/ None, | |
| /*fast_mode_enabled*/ false, | |
| &model_info, | |
| ), | |
| None | |
| ); | |
| } | |
| 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()) | |
| ); | |
| } | |
| 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(), /*auth_manager*/ 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, | |
| } | |
| } | |
| 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(), | |
| /*analytics_enabled*/ 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()), | |
| ); | |
| // Archive analytics exposes retained lineage even before a parent connection exists. | |
| 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(), | |
| /*non_blocking_snapshots*/ false, | |
| ); | |
| turn_environments.update_selections( | |
| environment_selections, | |
| &session_configuration.inferred_environment_config(), | |
| ); | |
| (environment_manager, turn_environments.snapshot().await) | |
| } | |
| 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())), | |
| ); | |
| } | |
| 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(/*subpath*/ 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 | |
| ); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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, | |
| } | |
| ); | |
| } | |
| 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(()) | |
| } | |
| 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()), | |
| /*force_reload*/ 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 | |
| ); | |
| } | |
| 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" | |
| ); | |
| } | |
| 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() | |
| }, | |
| ))); | |
| // Pause after committing settings but before their accepted snapshot is persisted. | |
| 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"))], | |
| /*reference_context_item*/ None, | |
| /*world_state_baseline*/ 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() | |
| ); | |
| // Direct runtime restoration may overlap postcommit work. Both checkpoints must wait | |
| // for the accepted event, then capture current settings rather than its older commit. | |
| 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), | |
| ] | |
| ); | |
| } | |
| 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() | |
| }, | |
| ))); | |
| // Pause after the commit, while its managed-network refresh is blocked. | |
| { | |
| 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); | |
| } | |
| async fn session_update_settings_does_not_rewrite_sticky_environment_cwds() { | |
| let (session, turn_context) = make_session_and_context().await; | |
| 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); | |
| let turn_cwd = turn_context.cwd.clone(); | |
| 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); | |
| } | |
| 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 | |
| ); | |
| } | |
| } | |
| 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()); | |
| } | |
| 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) | |
| ); | |
| } | |
| 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"); | |
| 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); | |
| } | |
| 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(), | |
| /*bundled_skills_enabled*/ true, | |
| )); | |
| let environment_manager = Arc::new(EnvironmentManager::default_for_tests()); | |
| let result = Session::new( | |
| /*startup*/ None, | |
| session_configuration, | |
| /*environment_selections*/ &[], | |
| 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(), | |
| /*reserved_thread_id*/ None, | |
| environment_manager, | |
| /*inherited_environments*/ None, | |
| /*analytics_events_client*/ None, | |
| crate::passthrough_image_store(), | |
| Arc::new(codex_thread_store::LocalThreadStore::new( | |
| codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), | |
| /*state_db*/ None, | |
| )), | |
| codex_rollout_trace::ThreadTraceContext::disabled(), | |
| /*attestation_provider*/ None, | |
| /*external_time_provider*/ 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") | |
| } | |
| 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)); | |
| } | |
| } | |
| // todo: use online model info | |
| 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, | |
| /*non_blocking_snapshots*/ 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(), | |
| /*bundled_skills_enabled*/ 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()), | |
| /*state_db*/ 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()), | |
| /*concurrent_reasoning_summaries_enabled*/ | |
| config | |
| .features | |
| .enabled(Feature::ConcurrentReasoningSummaries), | |
| /*attestation_provider*/ 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(/*permits*/ 1), | |
| managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 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, | |
| /*network*/ 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(), | |
| /*bundled_skills_enabled*/ true, | |
| )); | |
| let environment_manager = Arc::new(EnvironmentManager::default_for_tests()); | |
| let session = Session::new( | |
| /*startup*/ 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(), | |
| /*reserved_thread_id*/ None, | |
| environment_manager, | |
| /*inherited_environments*/ None, | |
| /*analytics_events_client*/ None, | |
| crate::passthrough_image_store(), | |
| Arc::new(codex_thread_store::LocalThreadStore::new( | |
| codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), | |
| /*state_db*/ None, | |
| )), | |
| codex_rollout_trace::ThreadTraceContext::disabled(), | |
| /*attestation_provider*/ None, | |
| /*external_time_provider*/ 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(), | |
| /*bundled_skills_enabled*/ true, | |
| )); | |
| let environment_manager = Arc::new(EnvironmentManager::default_for_tests()); | |
| let session = Session::new( | |
| /*startup*/ 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, | |
| /*reserved_thread_id*/ None, | |
| environment_manager, | |
| /*inherited_environments*/ None, | |
| /*analytics_events_client*/ 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(), | |
| /*attestation_provider*/ None, | |
| /*external_time_provider*/ None, | |
| Some(config.multi_agent_version_from_features()), | |
| GitEnrichmentPolicy::Fresh, | |
| codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile, | |
| ) | |
| .await?; | |
| Ok((session, rx_event)) | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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", | |
| /*originating_turn_state*/ None, | |
| ) | |
| .await; | |
| assert_eq!( | |
| session.granted_session_permissions("remote").await, | |
| Some(requested_permissions.into()) | |
| ); | |
| assert_eq!(session.granted_session_permissions("local").await, None); | |
| } | |
| 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() | |
| ); | |
| } | |
| 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, | |
| } | |
| ); | |
| } | |
| 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) | |
| ); | |
| 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)); | |
| } | |
| 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 = { | |
| 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( | |
| /*read*/ 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"); | |
| } | |
| 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); | |
| } | |
| 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(/*subpath*/ 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( | |
| /*read*/ 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()) | |
| ); | |
| } | |
| 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" | |
| ); | |
| } | |
| 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, | |
| /*trace*/ None, | |
| /*parent_turn_id*/ None, | |
| /*root_turn_id*/ 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)); | |
| } | |
| 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") | |
| ); | |
| } | |
| 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") | |
| ); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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() | |
| ); | |
| 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 | |
| )); | |
| } | |
| 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 | |
| )); | |
| let turn_cwd = turn_context.cwd.clone(); | |
| assert_eq!(turn_cwd, selected_cwd); | |
| assert_eq!(turn_context.config.cwd, selected_cwd); | |
| } | |
| 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() | |
| ); | |
| 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); | |
| } | |
| 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(); | |
| 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), | |
| /*shell*/ 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 | |
| ); | |
| } | |
| 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() | |
| ); | |
| 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); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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) | |
| ); | |
| } | |
| 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"); | |
| } | |
| 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(), | |
| /*non_blocking_snapshots*/ 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(), | |
| /*bundled_skills_enabled*/ 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()), | |
| /*concurrent_reasoning_summaries_enabled*/ | |
| config | |
| .features | |
| .enabled(Feature::ConcurrentReasoningSummaries), | |
| /*attestation_provider*/ 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(/*permits*/ 1), | |
| managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 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, | |
| /*network*/ 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 | |
| } | |
| // Like make_session_and_context, but returns Arc<Session> and the event receiver | |
| // so tests can assert on emitted events. | |
| 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 | |
| } | |
| 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, /*enabled*/ 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, | |
| /*selected_capability_roots*/ &[], | |
| /*required_servers*/ &[], | |
| /*required_plugins*/ &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" | |
| ); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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; | |
| } | |
| 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" | |
| ); | |
| } | |
| 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())); | |
| } | |
| 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()); | |
| } | |
| 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()) | |
| ); | |
| } | |
| 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(/*exec_server_url*/ 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())) | |
| }); | |
| } | |
| /// Capability discovery must use environment-owned permissions and sandbox backends, | |
| /// even when the thread defaults differ. | |
| 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, | |
| /*ready_selected_capability_roots*/ &[], | |
| &turn_context.environments, | |
| ) | |
| .await | |
| .expect("restricted environment should trigger capability discovery"); | |
| assert_eq!( | |
| discovery.sandbox_contexts().get(&environment_id), | |
| Some(&expected_sandbox) | |
| ); | |
| } | |
| 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, | |
| /*elicitation_reviewer*/ 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(()) | |
| } | |
| 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(/*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); | |
| } | |
| 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>" | |
| )); | |
| } | |
| 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>")); | |
| } | |
| 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}" | |
| ); | |
| } | |
| 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>")); | |
| } | |
| 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() | |
| } | |
| 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:?}" | |
| ); | |
| } | |
| 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:?}" | |
| ); | |
| } | |
| 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(), | |
| /*dynamic_tools*/ 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; | |
| // Cached plugin loading and auth let initial context reach the shared recommendation lookup | |
| // without awaiting unrelated I/O. | |
| 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()); | |
| // Keep the OnceCell initializer unpolled while first-thread context joins its in-flight fetch. | |
| // This does not depend on how quickly the HTTP server returns its response. | |
| 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; | |
| } | |
| 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() | |
| }) | |
| } | |
| } | |
| 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:?}" | |
| ); | |
| } | |
| 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:?}" | |
| ); | |
| } | |
| 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:?}" | |
| ); | |
| } | |
| 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:?}" | |
| ); | |
| } | |
| 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(/*enable_multi_agent_v2*/ 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:?}" | |
| ); | |
| } | |
| 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(/*enable_multi_agent_v2*/ 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:?}" | |
| ); | |
| } | |
| 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(/*enable_multi_agent_v2*/ 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:?}" | |
| ); | |
| } | |
| 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:?}" | |
| ); | |
| } | |
| 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 { | |
| let mut policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( | |
| &turn_context.sandbox_policy(), | |
| &turn_context.cwd, | |
| ); | |
| 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 | |
| } | |
| 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, | |
| )); | |
| let local_cwd = turn_context.cwd.clone(); | |
| assert_eq!(turn_context.to_turn_context_item().cwd, local_cwd); | |
| } | |
| 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()) | |
| ); | |
| } | |
| 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) | |
| ); | |
| } | |
| 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()) | |
| ); | |
| } | |
| 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") | |
| ); | |
| } | |
| 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(/*item*/ None); | |
| } | |
| session | |
| .replace_history( | |
| vec![compacted_summary.clone()], | |
| /*reference_context_item*/ 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)) | |
| ); | |
| } | |
| 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") | |
| ); | |
| } | |
| 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"), | |
| ) | |
| ); | |
| } | |
| 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), | |
| /*fast_mode_enabled*/ 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)); | |
| } | |
| 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>")); | |
| } | |
| 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(/*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") | |
| ); | |
| } | |
| 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(/*item*/ None); | |
| } | |
| handlers::run_user_shell_command( | |
| &session, | |
| "sub-id".to_string(), | |
| "echo shell".to_string(), | |
| /*timeout_ms*/ 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" | |
| ); | |
| } | |
| 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, | |
| }, | |
| ); | |
| } | |
| 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) | |
| } | |
| } | |
| 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 | |
| } | |
| 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() | |
| } | |
| ); | |
| } | |
| 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") | |
| } | |
| 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; | |
| } | |
| } | |
| } | |
| 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) | |
| } | |
| } | |
| 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)), | |
| ); | |
| } | |
| 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(/*openai_base_url*/ 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 | |
| } | |
| 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(/*status*/ 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(/*status*/ 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; | |
| } | |
| 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) | |
| }; | |
| // The real turn loop has prepared both contexts before sending its first compact request. | |
| timeout( | |
| Duration::from_secs(/*secs*/ 10), | |
| server.wait_for_request_count(/*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(/*secs*/ 10), | |
| server.wait_for_request_count(/*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; | |
| } | |
| 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"); | |
| } | |
| 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); | |
| } | |
| 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(_))); | |
| // Expected flushes: | |
| // 1. Task-runner flush after the task body finishes, before TurnComplete is emitted. | |
| // 2. Terminal-event flush after TurnComplete is appended. | |
| let calls = wait_for_flush_count(&store, /*expected_flushes*/ 2).await; | |
| assert_eq!(2, calls.flush_thread); | |
| } | |
| 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"); | |
| // Expected flushes: | |
| // 1. Task-runner flush after the task body observes cancellation. | |
| // 2. Interrupted-marker flush before TurnAborted so abort observers can reread it. | |
| // 3. Terminal-event flush after TurnAborted is appended. | |
| let calls = wait_for_flush_count(&store, /*expected_flushes*/ 3).await; | |
| assert_eq!(3, calls.flush_thread); | |
| } | |
| 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; | |
| // Interrupts surface the model-visible `<turn_aborted>` marker before the abort event. | |
| 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:?}"), | |
| } | |
| // No extra events should be emitted after an abort. | |
| assert!(rx.try_recv().is_err()); | |
| } | |
| 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; | |
| // Gracefully cancelled tasks surface the model-visible marker before the abort event too. | |
| 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:?}"), | |
| } | |
| // No extra events should be emitted after an abort. | |
| 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") | |
| } | |
| 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(/*w*/ 8, /*h*/ 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), /*task_result*/ 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 | |
| )); | |
| } | |
| 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()); | |
| } | |
| 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(), | |
| /*trigger_turn*/ 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)); | |
| } | |
| 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, | |
| })); | |
| } | |
| async fn queue_only_mailbox_mail_waits_for_next_turn_after_answer_boundary() { | |
| let (sess, tc, _rx) = make_session_and_context_with_rx().await; | |
| let communication = InterAgentCommunication::new( | |
| AgentPath::try_from("/root/worker").expect("worker path should parse"), | |
| AgentPath::root(), | |
| Vec::new(), | |
| "late queue-only update".to_string(), | |
| /*trigger_turn*/ false, | |
| ); | |
| sess.spawn_task( | |
| Arc::clone(&tc), | |
| Vec::new(), | |
| NeverEndingTask { | |
| kind: TaskKind::Regular, | |
| listen_to_cancellation_token: true, | |
| }, | |
| ) | |
| .await; | |
| sess.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)], | |
| ); | |
| } | |
| 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(), | |
| /*trigger_turn*/ 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); | |
| } | |
| 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(), | |
| /*trigger_turn*/ true, | |
| ); | |
| let second = InterAgentCommunication::new( | |
| AgentPath::try_from("/root/worker_b").expect("worker path should parse"), | |
| AgentPath::root(), | |
| Vec::new(), | |
| "second".to_string(), | |
| /*trigger_turn*/ 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 { | |
| // The first message is already queued when this independent task | |
| // starts; the second arrives after its root is established. | |
| 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; | |
| } | |
| async fn steered_input_reopens_mailbox_delivery_for_current_turn() { | |
| let (sess, tc, _rx) = make_session_and_context_with_rx().await; | |
| let communication = InterAgentCommunication::new( | |
| AgentPath::try_from("/root/worker").expect("worker path should parse"), | |
| AgentPath::root(), | |
| Vec::new(), | |
| "queued child update".to_string(), | |
| /*trigger_turn*/ false, | |
| ); | |
| sess.spawn_task( | |
| Arc::clone(&tc), | |
| Vec::new(), | |
| NeverEndingTask { | |
| kind: TaskKind::Regular, | |
| listen_to_cancellation_token: true, | |
| }, | |
| ) | |
| .await; | |
| sess.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), | |
| ], | |
| ); | |
| } | |
| async fn stale_defer_mailbox_delivery_does_not_override_steered_input() { | |
| let (sess, tc, _rx) = make_session_and_context_with_rx().await; | |
| let communication = InterAgentCommunication::new( | |
| AgentPath::try_from("/root/worker").expect("worker path should parse"), | |
| AgentPath::root(), | |
| Vec::new(), | |
| "queued child update".to_string(), | |
| /*trigger_turn*/ false, | |
| ); | |
| sess.spawn_task( | |
| Arc::clone(&tc), | |
| Vec::new(), | |
| NeverEndingTask { | |
| kind: TaskKind::Regular, | |
| listen_to_cancellation_token: true, | |
| }, | |
| ) | |
| .await; | |
| sess.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), | |
| ], | |
| ); | |
| } | |
| async fn tool_calls_reopen_mailbox_delivery_for_current_turn() { | |
| let (sess, tc, _rx) = make_session_and_context_with_rx().await; | |
| let communication = InterAgentCommunication::new( | |
| AgentPath::try_from("/root/worker").expect("worker path should parse"), | |
| AgentPath::root(), | |
| Vec::new(), | |
| "queued child update".to_string(), | |
| /*trigger_turn*/ false, | |
| ); | |
| sess.spawn_task( | |
| Arc::clone(&tc), | |
| Vec::new(), | |
| NeverEndingTask { | |
| kind: TaskKind::Regular, | |
| listen_to_cancellation_token: true, | |
| }, | |
| ) | |
| .await; | |
| sess.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, /*previously_active_item*/ None) | |
| .await | |
| .expect("tool call should be handled"); | |
| assert!(output.needs_follow_up); | |
| assert!(output.tool_future.is_some()); | |
| assert_eq!( | |
| (sess.input_queue.get_pending_input(&sess.active_turn).await).0, | |
| vec![TurnInput::InterAgentCommunication(communication)], | |
| ); | |
| } | |
| 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; | |
| // Aborting a review task should exit review mode before surfacing the abort to the client. | |
| // We scan for these events (rather than relying on fixed ordering) since unrelated events | |
| // may interleave. | |
| 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; | |
| // Verify the `<turn_aborted>` marker is still recorded in history for the model. | |
| 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" | |
| ); | |
| } | |
| 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(); | |
| // Use the same turn_context source as record_initial_history so model_info matches reconstruction. | |
| 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)) | |
| } | |
| 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); | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |