mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
## Why Executors older than `0.164.0` can advertise V2 discovery support without implementing the final contract. Enabling V2 also suppressed V1 discovery for incompatible executors, hiding selected capability roots and skills. ## What changed Require both the V2 capability flag and an executor version of at least `0.164.0` before issuing `capabilities/discoverV2`. Share this compatibility check between session discovery and the exec-server client. Fall back to V1 when incompatible executors provide no V2 snapshots, clearing any previously stored V2 inventory. ## Testing Add coverage for version boundaries, prereleases, missing and malformed versions, and transport recovery for compatible executors. Verify that an older executor retains selected plugins and skill instructions through V1 with V2 enabled. GitOrigin-RevId: 93adf52fc91f02b58fd14f8ff3b093712b3346b8
13591 lines
484 KiB
Rust
13591 lines
484 KiB
Rust
use crate::agent::LocalAgentControl;
|
|
#[path = "notification_tests.rs"]
|
|
mod notification_tests;
|
|
|
|
use super::mcp_refresh::McpRefresh;
|
|
#[path = "turn_start_mcp_tests.rs"]
|
|
mod turn_start_mcp_tests;
|
|
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::*;
|
|
#[path = "config_refresh_tests.rs"]
|
|
mod config_refresh_tests;
|
|
use crate::agents_md_manager::AgentsMdManager;
|
|
use crate::agents_md_manager::SessionInstructions;
|
|
use crate::config::ConfigBuilder;
|
|
use crate::config::ConfigOverrides;
|
|
use crate::config::RuntimeConfigRefresh;
|
|
use crate::context::ContextualUserFragment;
|
|
use crate::context::DeveloperInstructions;
|
|
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::realtime_conversation::RealtimeConversationSnapshot;
|
|
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::CompactionImplementation;
|
|
use codex_analytics::CompactionPhase;
|
|
use codex_analytics::CompactionReason;
|
|
use codex_analytics::CompactionTrigger;
|
|
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_prompts::render_model_instructions;
|
|
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::InternalSessionSource;
|
|
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::sandbox::SandboxOverride;
|
|
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::responses_metadata::CompactionTurnMetadata;
|
|
use crate::rollout::recorder::RolloutRecorder;
|
|
use crate::session::Submission;
|
|
use crate::state::ActiveTurn;
|
|
use crate::state::TaskKind;
|
|
use crate::tasks::SessionTask;
|
|
use crate::tasks::SessionTaskResult;
|
|
use crate::tasks::UserShellCommandMode;
|
|
use crate::tasks::execute_user_shell_command;
|
|
use crate::tools::ToolRouter;
|
|
use crate::tools::context::ToolInvocation;
|
|
use crate::tools::context::ToolPayload;
|
|
use crate::tools::handlers::ExecCommandHandler;
|
|
use crate::tools::handlers::RequestPermissionsHandler;
|
|
use crate::tools::registry::ToolExecutor;
|
|
use crate::tools::router::ToolCallSource;
|
|
use crate::turn_diff_tracker::TurnDiffTracker;
|
|
use codex_config::config_toml::ConfigToml;
|
|
use codex_config::config_toml::ProjectConfig;
|
|
use codex_config::permissions_toml::FilesystemPermissionToml;
|
|
use codex_config::permissions_toml::FilesystemPermissionsToml;
|
|
use codex_config::permissions_toml::NetworkToml;
|
|
use codex_config::permissions_toml::PermissionProfileToml;
|
|
use codex_config::permissions_toml::PermissionsToml;
|
|
use codex_execpolicy::Decision;
|
|
use codex_execpolicy::NetworkRuleProtocol;
|
|
use codex_execpolicy::Policy;
|
|
use codex_history::CodexHarnessMetadata;
|
|
use codex_history::CompactedItem;
|
|
use codex_history::InitialHistory;
|
|
use codex_history::ResponseItemEnvelope;
|
|
use codex_history::ResumedHistory;
|
|
use codex_history::RolloutItem;
|
|
#[cfg(windows)]
|
|
use codex_network_proxy::ManagedProxyRouting;
|
|
use codex_network_proxy::NetworkProxyConfig;
|
|
use codex_otel::MetricsClient;
|
|
use codex_otel::MetricsConfig;
|
|
use codex_otel::TelemetryAuthMode;
|
|
use codex_protocol::config_types::CollaborationMode;
|
|
use codex_protocol::config_types::ModeKind;
|
|
use codex_protocol::config_types::Settings;
|
|
use codex_protocol::items::HookPromptFragment;
|
|
use codex_protocol::items::build_hook_prompt_message;
|
|
use codex_protocol::mcp::McpAttribution;
|
|
use codex_protocol::mcp::McpAttributionSource;
|
|
use codex_protocol::mcp::McpAttributionStatus;
|
|
use codex_protocol::models::BaseInstructions;
|
|
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::ContentItemKind;
|
|
use codex_protocol::models::ContentItemMetadata;
|
|
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::ThreadSettingsOverrides;
|
|
use codex_protocol::protocol::TokenCountEvent;
|
|
use codex_protocol::protocol::TokenUsage;
|
|
use codex_protocol::protocol::TokenUsageInfo;
|
|
use codex_protocol::protocol::TokenUsageRecord;
|
|
use codex_protocol::protocol::TurnAbortedEvent;
|
|
use codex_protocol::protocol::TurnCompleteEvent;
|
|
use codex_protocol::protocol::TurnStartedEvent;
|
|
use codex_protocol::protocol::UserMessageEvent;
|
|
use codex_protocol::protocol::W3cTraceContext;
|
|
use codex_rmcp_client::ElicitationAction;
|
|
use core_test_support::PathBufExt;
|
|
use core_test_support::PathExt;
|
|
use core_test_support::context_snapshot;
|
|
use core_test_support::context_snapshot::ContextSnapshotOptions;
|
|
use core_test_support::responses;
|
|
use core_test_support::responses::ev_assistant_message;
|
|
use core_test_support::responses::ev_completed;
|
|
use core_test_support::responses::ev_response_created;
|
|
use core_test_support::responses::mount_sse_once;
|
|
use core_test_support::responses::sse;
|
|
use core_test_support::responses::start_mock_server;
|
|
use core_test_support::responses::strip_metadata_from_items;
|
|
use core_test_support::responses::strip_response_item_ids;
|
|
use core_test_support::responses::strip_response_item_ids_from_json;
|
|
use core_test_support::streaming_sse::StreamingSseChunk;
|
|
use core_test_support::streaming_sse::start_streaming_sse_server;
|
|
use core_test_support::test_codex::local;
|
|
use core_test_support::test_codex::test_codex;
|
|
use core_test_support::test_path_buf;
|
|
use core_test_support::tracing::install_test_tracing;
|
|
use core_test_support::wait_for_event;
|
|
use opentelemetry::trace::TraceContextExt;
|
|
use opentelemetry::trace::TraceId;
|
|
use opentelemetry_sdk::metrics::InMemoryMetricExporter;
|
|
use opentelemetry_sdk::metrics::data::AggregatedMetrics;
|
|
use opentelemetry_sdk::metrics::data::Metric;
|
|
use opentelemetry_sdk::metrics::data::MetricData;
|
|
use opentelemetry_sdk::metrics::data::ResourceMetrics;
|
|
use std::path::Path;
|
|
use std::time::Duration;
|
|
use test_case::test_case;
|
|
use tokio::sync::Notify;
|
|
use tokio::sync::Semaphore;
|
|
use tokio::time::sleep;
|
|
use tokio::time::timeout;
|
|
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
|
use wiremock::ResponseTemplate;
|
|
|
|
use uuid::Uuid;
|
|
|
|
use codex_protocol::mcp::CallToolResult as McpCallToolResult;
|
|
use pretty_assertions::assert_eq;
|
|
use serde::Deserialize;
|
|
use serde_json::json;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::OnceLock;
|
|
use std::time::Duration as StdDuration;
|
|
|
|
pub(crate) fn mcp_config_for_test(config: &crate::config::Config) -> Arc<codex_mcp::McpConfig> {
|
|
Arc::new(config.to_mcp_config_with_loaded_plugins(
|
|
&codex_core_plugins::PluginLoadOutcome::default(),
|
|
std::iter::empty(),
|
|
))
|
|
}
|
|
|
|
pub(crate) fn test_environment_id(turn: &TurnContext) -> &str {
|
|
&turn
|
|
.initial_environments
|
|
.single_local_environment()
|
|
.expect("test turn should have one local environment")
|
|
.selection
|
|
.environment_id
|
|
}
|
|
|
|
/// 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.next_step_settings.store(settings);
|
|
}
|
|
|
|
impl StepContext {
|
|
pub(crate) fn for_test(turn: Arc<TurnContext>) -> Arc<Self> {
|
|
let environments = turn.initial_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 {
|
|
preempt: turn
|
|
.config
|
|
.features
|
|
.enabled(Feature::InstantInterrupt)
|
|
.then(tokio_util::sync::CancellationToken::new),
|
|
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(),
|
|
realtime: RealtimeConversationSnapshot {
|
|
active: turn.realtime_active,
|
|
mode_instructions: None,
|
|
},
|
|
turn: Arc::clone(&turn),
|
|
inherited_environments: environments.clone(),
|
|
environments,
|
|
executor_hook_sources: Vec::new(),
|
|
extension_data: codex_extension_api::ExtensionData::new(turn.sub_id.clone()),
|
|
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 {
|
|
status: None,
|
|
encrypted_content: None,
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: text.to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: Some(InternalChatMessageMetadataPassthrough {
|
|
content_item_kinds: Some(vec![ContentItemKind("unknown".to_string())]),
|
|
..Default::default()
|
|
}),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn assign_missing_response_item_ids_assigns_agent_message_ids() {
|
|
let items = Cow::Owned(vec![
|
|
ResponseItem::AgentMessage {
|
|
id: None,
|
|
author: "worker".to_string(),
|
|
recipient: "root".to_string(),
|
|
content: vec![AgentMessageInputContent::InputText {
|
|
text: "done".to_string(),
|
|
}],
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
user_message("hello"),
|
|
]);
|
|
|
|
let items = Session::assign_missing_response_item_ids(items);
|
|
|
|
assert!(items[0].id().is_some_and(|id| id.starts_with("amsg_")));
|
|
assert!(items[1].id().is_some_and(|id| id.starts_with("msg_")));
|
|
}
|
|
|
|
#[test]
|
|
fn assign_missing_response_item_ids_assigns_additional_tools_ids() {
|
|
let items = Cow::Owned(vec![ResponseItem::AdditionalTools {
|
|
id: None,
|
|
role: "developer".to_string(),
|
|
tools: Arc::from([]),
|
|
}]);
|
|
|
|
let items = Session::assign_missing_response_item_ids(items);
|
|
|
|
assert!(items[0].id().is_some_and(|id| id.starts_with("at_")));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn default_turn_context_assigns_missing_response_item_ids() {
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
let response_item = user_message("hello");
|
|
|
|
let (items, _) = session
|
|
.prepare_conversation_items_for_history(
|
|
&turn_context,
|
|
turn_context.model_info(),
|
|
std::slice::from_ref(&response_item),
|
|
)
|
|
.await;
|
|
|
|
assert!(
|
|
items[0]
|
|
.id()
|
|
.is_some_and(|item_id| item_id.starts_with("msg_"))
|
|
);
|
|
}
|
|
|
|
fn assistant_message(text: &str) -> ResponseItem {
|
|
ResponseItem::Message {
|
|
status: None,
|
|
encrypted_content: None,
|
|
id: None,
|
|
role: "assistant".to_string(),
|
|
content: vec![ContentItem::OutputText {
|
|
annotations: None,
|
|
logprobs: None,
|
|
text: text.to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: Some(InternalChatMessageMetadataPassthrough {
|
|
content_item_kinds: Some(vec![ContentItemKind("unknown".to_string())]),
|
|
..Default::default()
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn find_metric<'a>(resource_metrics: &'a ResourceMetrics, name: &str) -> &'a Metric {
|
|
for scope_metrics in resource_metrics.scope_metrics() {
|
|
for metric in scope_metrics.metrics() {
|
|
if metric.name() == name {
|
|
return metric;
|
|
}
|
|
}
|
|
}
|
|
panic!("metric {name} missing");
|
|
}
|
|
|
|
fn single_histogram_attributes(
|
|
resource_metrics: &ResourceMetrics,
|
|
name: &str,
|
|
) -> BTreeMap<String, String> {
|
|
let metric = find_metric(resource_metrics, name);
|
|
let AggregatedMetrics::F64(data) = metric.data() else {
|
|
panic!("expected floating-point histogram");
|
|
};
|
|
let MetricData::Histogram(histogram) = data else {
|
|
panic!("expected histogram");
|
|
};
|
|
let points = histogram.data_points().collect::<Vec<_>>();
|
|
assert_eq!(points.len(), 1);
|
|
points[0]
|
|
.attributes()
|
|
.map(|attribute| {
|
|
(
|
|
attribute.key.as_str().to_string(),
|
|
attribute.value.as_str().to_string(),
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn extension_metrics_preserve_session_metadata_tags() {
|
|
let metrics = MetricsClient::new(
|
|
MetricsConfig::in_memory(
|
|
"test",
|
|
"codex-core",
|
|
env!("CARGO_PKG_VERSION"),
|
|
InMemoryMetricExporter::default(),
|
|
)
|
|
.with_runtime_reader(),
|
|
)
|
|
.expect("in-memory metrics client");
|
|
let session_telemetry = SessionTelemetry::new(
|
|
ThreadId::new(),
|
|
"gpt-5.4",
|
|
"gpt-5.4",
|
|
/*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()),
|
|
])
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn world_state_extension_metrics_follow_turn_model_switch() {
|
|
struct WorldStateMetricsRecorder;
|
|
|
|
impl codex_extension_api::ContextContributor for WorldStateMetricsRecorder {
|
|
fn contribute_world_state<'a>(
|
|
&'a self,
|
|
input: codex_extension_api::WorldStateContributionInput<'a>,
|
|
) -> codex_extension_api::ExtensionFuture<
|
|
'a,
|
|
Vec<codex_extension_api::WorldStateSectionContribution>,
|
|
> {
|
|
Box::pin(async move {
|
|
input
|
|
.extension_metrics
|
|
.expect("turn metrics should be available")
|
|
.histogram("codex.test.extension.turn", /*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 {
|
|
status: None,
|
|
encrypted_content: None,
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: text.to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn regular_turn_emits_turn_started_with_trace_id_without_waiting_for_startup_prewarm() {
|
|
let _trace_test_context = install_test_tracing("codex-core-tests");
|
|
let request_parent = W3cTraceContext {
|
|
traceparent: Some("00-00000000000000000000000000000011-0000000000000022-01".into()),
|
|
tracestate: Some("vendor=value".into()),
|
|
};
|
|
let request_span = info_span!("app_server.request");
|
|
assert!(set_parent_from_w3c_trace_context(
|
|
&request_span,
|
|
&request_parent
|
|
));
|
|
let (sess, tc, rx) = make_session_and_context_with_rx()
|
|
.instrument(request_span)
|
|
.await;
|
|
assert_eq!(
|
|
tc.trace_id.as_deref(),
|
|
Some("00000000000000000000000000000011")
|
|
);
|
|
let (_tx, startup_prewarm_rx) = tokio::sync::oneshot::channel::<()>();
|
|
let handle = tokio::spawn(async move {
|
|
let _ = startup_prewarm_rx.await;
|
|
Ok(test_model_client_session())
|
|
});
|
|
|
|
sess.set_session_startup_prewarm(
|
|
crate::session::startup_prewarm::SessionStartupPrewarmHandle::new(
|
|
handle,
|
|
std::time::Instant::now(),
|
|
crate::client::WEBSOCKET_CONNECT_TIMEOUT,
|
|
),
|
|
)
|
|
.await;
|
|
sess.spawn_task(
|
|
Arc::clone(&tc),
|
|
Vec::new(),
|
|
crate::tasks::RegularTask::new(),
|
|
)
|
|
.await;
|
|
|
|
let first = tokio::time::timeout(std::time::Duration::from_millis(200), rx.recv())
|
|
.await
|
|
.expect("expected turn started event without waiting for startup prewarm")
|
|
.expect("channel open");
|
|
let EventMsg::TurnStarted(turn_started) = first.msg else {
|
|
panic!("expected turn started event");
|
|
};
|
|
assert_eq!(turn_started.turn_id, tc.sub_id);
|
|
assert_eq!(turn_started.trace_id, tc.trace_id);
|
|
assert_eq!(turn_started.root_turn_id, Some(tc.sub_id.clone()));
|
|
|
|
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
|
}
|
|
|
|
#[test_case(SessionSource::Exec; "root")]
|
|
#[test_case(SessionSource::SubAgent(SubAgentSource::Review); "subagent")]
|
|
#[test_case(SessionSource::Internal(InternalSessionSource::Guardian); "guardian")]
|
|
#[tokio::test]
|
|
async fn request_mcp_server_elicitation_auto_accepts_when_auto_deny_is_enabled(
|
|
source: SessionSource,
|
|
) {
|
|
let (session, mut turn_context, rx) = make_session_and_context_with_rx().await;
|
|
Arc::get_mut(&mut turn_context)
|
|
.expect("turn context should not be shared")
|
|
.session_source = source;
|
|
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());
|
|
}
|
|
|
|
#[test_case(SessionSource::Exec; "root")]
|
|
#[test_case(SessionSource::SubAgent(SubAgentSource::Review); "subagent")]
|
|
#[test_case(SessionSource::Internal(InternalSessionSource::Guardian); "guardian")]
|
|
#[tokio::test]
|
|
async fn request_mcp_server_elicitation_waits_for_user_response(source: SessionSource) {
|
|
let (session, mut turn_context, rx) = make_session_and_context_with_rx().await;
|
|
Arc::get_mut(&mut turn_context)
|
|
.expect("turn context should not be shared")
|
|
.session_source = source;
|
|
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
|
let paused = session.subscribe_elicitation_pause_state();
|
|
let request = ElicitationRequest::Url {
|
|
meta: None,
|
|
message: "Connect this app to continue.".to_string(),
|
|
url: "https://example.com/connect".to_string(),
|
|
elicitation_id: "connect-1".to_string(),
|
|
};
|
|
let pending = tokio::spawn({
|
|
let session = Arc::clone(&session);
|
|
let turn_context = Arc::clone(&turn_context);
|
|
let request = request.clone();
|
|
async move {
|
|
session
|
|
.request_mcp_server_elicitation(
|
|
&turn_context,
|
|
"codex_apps".to_string(),
|
|
RequestId::String("request-1".into()),
|
|
request,
|
|
)
|
|
.await
|
|
}
|
|
});
|
|
let event = tokio::time::timeout(Duration::from_secs(1), rx.recv())
|
|
.await
|
|
.expect("elicitation event should arrive")
|
|
.expect("event channel should remain open");
|
|
let EventMsg::ElicitationRequest(event) = event.msg else {
|
|
panic!("expected MCP elicitation");
|
|
};
|
|
assert_eq!(
|
|
event,
|
|
codex_protocol::approvals::ElicitationRequestEvent {
|
|
turn_id: Some(turn_context.sub_id.clone()),
|
|
server_name: "codex_apps".to_string(),
|
|
id: codex_protocol::mcp::RequestId::String("request-1".to_string()),
|
|
request,
|
|
}
|
|
);
|
|
assert!(*paused.borrow());
|
|
assert!(!pending.is_finished());
|
|
|
|
let response = ElicitationResponse {
|
|
action: ElicitationAction::Accept,
|
|
content: None,
|
|
meta: None,
|
|
};
|
|
session
|
|
.resolve_elicitation(
|
|
"codex_apps".to_string(),
|
|
RequestId::String("request-1".into()),
|
|
response.clone(),
|
|
)
|
|
.await
|
|
.expect("user response should resolve the elicitation");
|
|
let outcome = tokio::time::timeout(Duration::from_secs(1), pending)
|
|
.await
|
|
.expect("elicitation should finish")
|
|
.expect("elicitation task should succeed");
|
|
assert_eq!(outcome.response, Some(response));
|
|
assert!(outcome.sent);
|
|
assert!(!*paused.borrow());
|
|
assert!(rx.try_recv().is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted() {
|
|
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
|
let (_tx, startup_prewarm_rx) = tokio::sync::oneshot::channel::<()>();
|
|
let handle = tokio::spawn(async move {
|
|
let _ = startup_prewarm_rx.await;
|
|
Ok(test_model_client_session())
|
|
});
|
|
|
|
sess.set_session_startup_prewarm(
|
|
crate::session::startup_prewarm::SessionStartupPrewarmHandle::new(
|
|
handle,
|
|
std::time::Instant::now(),
|
|
crate::client::WEBSOCKET_CONNECT_TIMEOUT,
|
|
),
|
|
)
|
|
.await;
|
|
sess.spawn_task(
|
|
Arc::clone(&tc),
|
|
Vec::new(),
|
|
crate::tasks::RegularTask::new(),
|
|
)
|
|
.await;
|
|
|
|
let first = tokio::time::timeout(std::time::Duration::from_millis(200), rx.recv())
|
|
.await
|
|
.expect("expected turn started event without waiting for startup prewarm")
|
|
.expect("channel open");
|
|
assert!(matches!(
|
|
first.msg,
|
|
EventMsg::TurnStarted(TurnStartedEvent { turn_id, .. }) if turn_id == tc.sub_id
|
|
));
|
|
|
|
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
|
|
|
let marker_evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
|
|
.await
|
|
.expect("expected turn aborted marker event")
|
|
.expect("channel open");
|
|
assert!(matches!(marker_evt.msg, EventMsg::RawResponseItem(_)));
|
|
|
|
let second = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
|
|
.await
|
|
.expect("expected turn aborted event")
|
|
.expect("channel open");
|
|
let EventMsg::TurnAborted(TurnAbortedEvent {
|
|
root_turn_id,
|
|
turn_id,
|
|
reason,
|
|
error: _,
|
|
started_at,
|
|
completed_at,
|
|
duration_ms,
|
|
}) = second.msg
|
|
else {
|
|
panic!("expected turn aborted event");
|
|
};
|
|
assert_eq!(root_turn_id, Some(tc.root_turn_id()));
|
|
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,
|
|
/*reasoning_effort_override_enabled*/ false,
|
|
/*enable_request_compression*/ false,
|
|
/*include_timing_metrics*/ false,
|
|
/*beta_features_header*/ None,
|
|
/*concurrent_reasoning_summaries_enabled*/ false,
|
|
/*output_token_replay_enabled*/ false,
|
|
/*attestation_provider*/ None,
|
|
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
|
codex_model_provider::WorkspaceRoutingContext::new(
|
|
"https://chatgpt.com/backend-api".into(),
|
|
),
|
|
Vec::new(),
|
|
)
|
|
.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(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
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());
|
|
}
|
|
|
|
#[test]
|
|
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());
|
|
}
|
|
|
|
#[test]
|
|
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());
|
|
}
|
|
|
|
#[test]
|
|
fn validated_network_policy_amendment_host_allows_normalized_match() {
|
|
let amendment = NetworkPolicyAmendment {
|
|
host: "ExAmPlE.Com.:443".to_string(),
|
|
action: NetworkPolicyRuleAction::Allow,
|
|
};
|
|
let context = NetworkApprovalContext {
|
|
host: "example.com".to_string(),
|
|
protocol: NetworkApprovalProtocol::Https,
|
|
};
|
|
|
|
let host = Session::validated_network_policy_amendment_host(&amendment, &context)
|
|
.expect("normalized hosts should match");
|
|
|
|
assert_eq!(host, "example.com");
|
|
}
|
|
|
|
#[test]
|
|
fn validated_network_policy_amendment_host_rejects_mismatch() {
|
|
let amendment = NetworkPolicyAmendment {
|
|
host: "evil.example.com".to_string(),
|
|
action: NetworkPolicyRuleAction::Deny,
|
|
};
|
|
let context = NetworkApprovalContext {
|
|
host: "api.example.com".to_string(),
|
|
protocol: NetworkApprovalProtocol::Https,
|
|
};
|
|
|
|
let err = Session::validated_network_policy_amendment_host(&amendment, &context)
|
|
.expect_err("mismatched hosts should be rejected");
|
|
|
|
let message = err.to_string();
|
|
assert!(message.contains("does not match approved host"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyhow::Result<()> {
|
|
let permission_profile = PermissionProfile::workspace_write();
|
|
let spec = crate::config::NetworkProxySpec::from_config_and_constraints(
|
|
NetworkProxyConfig::default(),
|
|
/*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(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() -> anyhow::Result<()>
|
|
{
|
|
let permission_profile = PermissionProfile::workspace_write();
|
|
let spec = crate::config::NetworkProxySpec::from_config_and_constraints(
|
|
NetworkProxyConfig::default(),
|
|
Some(NetworkConstraints {
|
|
domains: Some(NetworkDomainPermissionsToml {
|
|
entries: std::collections::BTreeMap::from([(
|
|
"managed.example.com".to_string(),
|
|
NetworkDomainPermissionToml::Allow,
|
|
)]),
|
|
}),
|
|
managed_allowed_domains_only: Some(true),
|
|
..Default::default()
|
|
}),
|
|
&permission_profile,
|
|
)?;
|
|
let mut exec_policy = Policy::empty();
|
|
exec_policy.add_network_rule(
|
|
"example.com",
|
|
NetworkRuleProtocol::Https,
|
|
Decision::Allow,
|
|
/*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(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn managed_network_proxy_decider_survives_full_access_start() -> anyhow::Result<()> {
|
|
let full_access_permission_profile = PermissionProfile::Disabled;
|
|
let spec = crate::config::NetworkProxySpec::from_config_and_constraints(
|
|
NetworkProxyConfig::default(),
|
|
Some(NetworkConstraints {
|
|
enabled: Some(true),
|
|
..Default::default()
|
|
}),
|
|
&full_access_permission_profile,
|
|
)?;
|
|
let exec_policy = Policy::empty();
|
|
let decider_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
|
let network_policy_decider: Arc<dyn codex_network_proxy::NetworkPolicyDecider> = Arc::new({
|
|
let decider_calls = Arc::clone(&decider_calls);
|
|
move |_request| {
|
|
decider_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
async { codex_network_proxy::NetworkDecision::ask("not_allowed") }
|
|
}
|
|
});
|
|
|
|
let (started_proxy, _) = Session::start_managed_network_proxy(
|
|
&spec,
|
|
&exec_policy,
|
|
&full_access_permission_profile,
|
|
SandboxType::None,
|
|
Some(network_policy_decider),
|
|
/*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(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow::Result<()> {
|
|
let (session, _turn_context) = make_session_and_context().await;
|
|
let initial_permission_profile = PermissionProfile::workspace_write();
|
|
|
|
let mut network_config = NetworkProxyConfig::default();
|
|
network_config.set_allowed_domains(vec!["evil.com".to_string()]);
|
|
let requirements = NetworkConstraints {
|
|
enabled: Some(true),
|
|
domains: Some(NetworkDomainPermissionsToml {
|
|
entries: std::collections::BTreeMap::from([(
|
|
"*.example.com".to_string(),
|
|
NetworkDomainPermissionToml::Allow,
|
|
)]),
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let spec = crate::config::NetworkProxySpec::from_config_and_constraints(
|
|
network_config,
|
|
Some(requirements),
|
|
&initial_permission_profile,
|
|
)?;
|
|
let network_policy_decider: Arc<dyn codex_network_proxy::NetworkPolicyDecider> =
|
|
Arc::new(|_request| async { codex_network_proxy::NetworkDecision::ask("not_allowed") });
|
|
let (started_proxy, _) = Session::start_managed_network_proxy(
|
|
&spec,
|
|
&Policy::empty(),
|
|
&initial_permission_profile,
|
|
SandboxType::None,
|
|
Some(Arc::clone(&network_policy_decider)),
|
|
/*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");
|
|
#[cfg(windows)]
|
|
{
|
|
state.session_configuration.windows_sandbox_type = SandboxType::WindowsMxc;
|
|
}
|
|
}
|
|
session
|
|
.services
|
|
.network_proxy
|
|
.store(Some(Arc::new(started_proxy)));
|
|
|
|
session
|
|
.new_turn_with_sub_id(
|
|
"sandbox-policy-change".to_string(),
|
|
SessionSettingsUpdate {
|
|
sandbox_policy: Some(SandboxPolicy::DangerFullAccess),
|
|
..Default::default()
|
|
},
|
|
Default::default(),
|
|
)
|
|
.await?;
|
|
|
|
let started_proxy = session
|
|
.services
|
|
.network_proxy
|
|
.load_full()
|
|
.expect("managed network proxy should be present");
|
|
assert_eq!(
|
|
started_proxy.proxy().current_cfg().await?.allowed_domains(),
|
|
Some(vec!["*.example.com".to_string()])
|
|
);
|
|
#[cfg(windows)]
|
|
{
|
|
assert_eq!(
|
|
started_proxy.proxy().managed_proxy_routing(),
|
|
ManagedProxyRouting::DedicatedListeners
|
|
);
|
|
let rebuilt_policy_decider = started_proxy
|
|
.network_policy_decider()
|
|
.expect("rebuilt managed network proxy should retain its policy decider");
|
|
assert!(Arc::ptr_eq(
|
|
&network_policy_decider,
|
|
&rebuilt_policy_decider
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_clears_disabled_managed_network_proxy() -> anyhow::Result<()> {
|
|
let permission_profile = PermissionProfile::workspace_write();
|
|
let enabled_spec = crate::config::NetworkProxySpec::from_config_and_constraints(
|
|
NetworkProxyConfig::default(),
|
|
Some(NetworkConstraints {
|
|
enabled: Some(true),
|
|
..Default::default()
|
|
}),
|
|
&permission_profile,
|
|
)?;
|
|
let disabled_spec = crate::config::NetworkProxySpec::from_config_and_constraints(
|
|
NetworkProxyConfig::default(),
|
|
Some(NetworkConstraints {
|
|
enabled: Some(false),
|
|
..Default::default()
|
|
}),
|
|
&permission_profile,
|
|
)?;
|
|
let session = make_session_with_config(move |config| {
|
|
config
|
|
.permissions
|
|
.set_permission_profile(permission_profile)
|
|
.expect("test setup should allow permission profile");
|
|
config.permissions.network = Some(enabled_spec);
|
|
})
|
|
.await?;
|
|
assert!(session.services.network_proxy.load_full().is_some());
|
|
|
|
{
|
|
let mut state = session.state.lock().await;
|
|
let mut config = (*state.session_configuration.original_config_do_not_use).clone();
|
|
config.permissions.network = Some(disabled_spec);
|
|
state.session_configuration.original_config_do_not_use = Arc::new(config);
|
|
}
|
|
|
|
session
|
|
.refresh_managed_network_proxy_for_current_permission_profile()
|
|
.await;
|
|
|
|
assert!(session.services.network_proxy.load_full().is_none());
|
|
assert!(session.new_default_turn().await.network.is_none());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn danger_full_access_turns_do_not_expose_managed_network_proxy() -> anyhow::Result<()> {
|
|
let network_spec = crate::config::NetworkProxySpec::from_config_and_constraints(
|
|
NetworkProxyConfig::default(),
|
|
Some(NetworkConstraints {
|
|
enabled: Some(true),
|
|
..Default::default()
|
|
}),
|
|
&PermissionProfile::Disabled,
|
|
)?;
|
|
|
|
let session = make_session_with_config(move |config| {
|
|
config
|
|
.permissions
|
|
.set_permission_profile(PermissionProfile::Disabled)
|
|
.expect("test setup should allow permission profile");
|
|
config.permissions.network = Some(network_spec);
|
|
})
|
|
.await?;
|
|
|
|
let turn_context = session.new_default_turn().await;
|
|
assert!(turn_context.network.is_none());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> anyhow::Result<()> {
|
|
#[derive(Default)]
|
|
struct ProbeToolRuntime {
|
|
enforce_managed_network: Vec<bool>,
|
|
}
|
|
|
|
impl crate::tools::sandboxing::Approvable<TurnEnvironment> for ProbeToolRuntime {
|
|
fn approval_action(
|
|
&self,
|
|
_req: &TurnEnvironment,
|
|
call_id: &str,
|
|
) -> std::io::Result<crate::tools::sandboxing::ApprovalAction> {
|
|
Ok(crate::tools::sandboxing::ApprovalAction::ExecCommand {
|
|
id: call_id.to_string(),
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
command: Vec::new(),
|
|
hook_command: String::new(),
|
|
cwd: PathUri::from_abs_path(&std::env::temp_dir().abs()),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: None,
|
|
tty: false,
|
|
proposed_execpolicy_amendment: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl crate::tools::sandboxing::Sandboxable for ProbeToolRuntime {
|
|
fn sandbox_preference(&self) -> codex_sandboxing::SandboxablePreference {
|
|
codex_sandboxing::SandboxablePreference::Auto
|
|
}
|
|
}
|
|
|
|
impl crate::tools::sandboxing::ToolRuntime<TurnEnvironment, ()> for ProbeToolRuntime {
|
|
fn turn_environment<'a>(&self, req: &'a TurnEnvironment) -> &'a TurnEnvironment {
|
|
req
|
|
}
|
|
|
|
async fn run(
|
|
&mut self,
|
|
_req: &TurnEnvironment,
|
|
attempt: &crate::tools::sandboxing::SandboxAttempt<'_>,
|
|
_ctx: &crate::tools::sandboxing::ToolCtx,
|
|
) -> Result<(), crate::tools::sandboxing::ToolError> {
|
|
self.enforce_managed_network
|
|
.push(attempt.enforce_managed_network);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
let network_spec = crate::config::NetworkProxySpec::from_config_and_constraints(
|
|
NetworkProxyConfig::default(),
|
|
Some(NetworkConstraints {
|
|
enabled: Some(true),
|
|
..Default::default()
|
|
}),
|
|
&PermissionProfile::Disabled,
|
|
)?;
|
|
|
|
let session = make_session_with_config(move |config| {
|
|
config
|
|
.permissions
|
|
.set_permission_profile(PermissionProfile::Disabled)
|
|
.expect("test setup should allow permission profile");
|
|
config.permissions.network = Some(network_spec);
|
|
|
|
let layers = config
|
|
.config_layer_stack
|
|
.all_layers_low_to_high()
|
|
.cloned()
|
|
.collect();
|
|
let mut requirements = config.config_layer_stack.requirements().clone();
|
|
requirements.network = Some(Sourced::new(
|
|
NetworkConstraints {
|
|
enabled: Some(true),
|
|
..Default::default()
|
|
},
|
|
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
|
));
|
|
let mut requirements_toml = config.config_layer_stack.requirements_toml().clone();
|
|
requirements_toml.network = Some(codex_config::NetworkRequirementsToml {
|
|
enabled: Some(true),
|
|
..Default::default()
|
|
});
|
|
config.config_layer_stack = ConfigLayerStack::new(layers, requirements, requirements_toml)
|
|
.expect("rebuild config layer stack with network requirements");
|
|
})
|
|
.await?;
|
|
|
|
let turn = session.new_default_turn().await;
|
|
assert!(turn.network.is_none());
|
|
|
|
let mut orchestrator = crate::tools::orchestrator::ToolOrchestrator::new();
|
|
let mut tool = ProbeToolRuntime::default();
|
|
let tool_ctx = crate::tools::sandboxing::ToolCtx {
|
|
cancellation_token: CancellationToken::new(),
|
|
session: Arc::clone(&session),
|
|
step_context: StepContext::for_test(Arc::clone(&turn)),
|
|
call_id: "probe-call".to_string(),
|
|
tool_name: codex_tools::ToolName::plain("probe"),
|
|
};
|
|
|
|
orchestrator
|
|
.run(
|
|
&mut tool,
|
|
turn.initial_environments
|
|
.primary()
|
|
.expect("turn should have a primary environment"),
|
|
&tool_ctx,
|
|
)
|
|
.await
|
|
.expect("probe runtime should succeed");
|
|
|
|
assert_eq!(tool.enforce_managed_network, vec![false]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn workspace_write_turns_continue_to_expose_managed_network_proxy() -> anyhow::Result<()> {
|
|
let permission_profile = PermissionProfile::workspace_write();
|
|
let network_spec = crate::config::NetworkProxySpec::from_config_and_constraints(
|
|
NetworkProxyConfig::default(),
|
|
Some(NetworkConstraints {
|
|
enabled: Some(true),
|
|
..Default::default()
|
|
}),
|
|
&permission_profile,
|
|
)?;
|
|
|
|
let session = make_session_with_config(move |config| {
|
|
config
|
|
.permissions
|
|
.set_permission_profile(permission_profile)
|
|
.expect("test setup should allow permission profile");
|
|
config.permissions.network = Some(network_spec);
|
|
})
|
|
.await?;
|
|
|
|
let turn_context = session.new_default_turn().await;
|
|
assert!(turn_context.network.is_some());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disabled_managed_network_does_not_start_or_expose_proxy() -> anyhow::Result<()> {
|
|
let permission_profile = PermissionProfile::workspace_write();
|
|
let network_spec = crate::config::NetworkProxySpec::from_config_and_constraints(
|
|
NetworkProxyConfig::default(),
|
|
Some(NetworkConstraints {
|
|
enabled: Some(false),
|
|
..Default::default()
|
|
}),
|
|
&permission_profile,
|
|
)?;
|
|
|
|
let (session, rx) = make_session_with_config_and_rx(move |config| {
|
|
config
|
|
.permissions
|
|
.set_permission_profile(permission_profile)
|
|
.expect("test setup should allow permission profile");
|
|
config.permissions.network = Some(network_spec);
|
|
})
|
|
.await?;
|
|
|
|
assert!(session.services.network_proxy.load_full().is_none());
|
|
assert!(session.new_default_turn().await.network.is_none());
|
|
|
|
loop {
|
|
let event = rx.recv().await.expect("channel open");
|
|
if let EventMsg::SessionConfigured(event) = event.msg {
|
|
assert!(event.network_proxy.is_none());
|
|
break;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn user_shell_commands_do_not_inherit_managed_network_proxy() -> anyhow::Result<()> {
|
|
let permission_profile = PermissionProfile::workspace_write();
|
|
let network_spec = crate::config::NetworkProxySpec::from_config_and_constraints(
|
|
NetworkProxyConfig::default(),
|
|
Some(NetworkConstraints {
|
|
enabled: Some(true),
|
|
..Default::default()
|
|
}),
|
|
&permission_profile,
|
|
)?;
|
|
|
|
let (session, rx) = make_session_with_config_and_rx(move |config| {
|
|
config
|
|
.permissions
|
|
.set_permission_profile(permission_profile)
|
|
.expect("test setup should allow permission profile");
|
|
config.permissions.network = Some(network_spec);
|
|
})
|
|
.await?;
|
|
|
|
let turn_context = session.new_default_turn().await;
|
|
assert!(turn_context.network.is_some());
|
|
|
|
#[cfg(windows)]
|
|
let command = r#"$val = $env:HTTP_PROXY; if ([string]::IsNullOrEmpty($val)) { $val = 'not-set' } ; [System.Console]::Write($val)"#.to_string();
|
|
#[cfg(not(windows))]
|
|
let command = r#"sh -c "printf '%s' \"${HTTP_PROXY:-not-set}\"""#.to_string();
|
|
|
|
execute_user_shell_command(
|
|
Arc::clone(&session),
|
|
turn_context,
|
|
command,
|
|
/*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.aggregated_output.trim(), "not-set");
|
|
break;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn user_shell_commands_remain_login_shells_when_model_login_shells_are_disabled()
|
|
-> anyhow::Result<()> {
|
|
let (session, rx) = make_session_with_config_and_rx(|config| {
|
|
config.permissions.allow_login_shell = false;
|
|
})
|
|
.await?;
|
|
let turn_context = session.new_default_turn().await;
|
|
let command = "echo managed-login-shell".to_string();
|
|
let expected_command = session
|
|
.user_shell()
|
|
.derive_exec_args(&command, /*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(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reload_user_config_layer_updates_effective_apps_config() {
|
|
let (session, _turn_context) = make_session_and_context().await;
|
|
let codex_home = session.codex_home().await;
|
|
std::fs::create_dir_all(&codex_home).expect("create codex home");
|
|
let config_toml_path = codex_home.join(CONFIG_TOML_FILE);
|
|
std::fs::write(
|
|
&config_toml_path,
|
|
"[apps.calendar]\nenabled = false\ndestructive_enabled = false\n",
|
|
)
|
|
.expect("write user config");
|
|
|
|
session.reload_user_config_layer().await;
|
|
|
|
let config = session.get_config().await;
|
|
let apps_toml = config
|
|
.config_layer_stack
|
|
.effective_config()
|
|
.as_table()
|
|
.and_then(|table| table.get("apps"))
|
|
.cloned()
|
|
.expect("apps table");
|
|
let apps = codex_config::types::AppsConfigToml::deserialize(apps_toml)
|
|
.expect("deserialize apps config");
|
|
let app = apps
|
|
.apps
|
|
.get("calendar")
|
|
.expect("calendar app config exists");
|
|
|
|
assert!(!app.enabled);
|
|
assert_eq!(app.destructive_enabled, Some(false));
|
|
}
|
|
|
|
#[test_case("[shell_environment_policy]\nexclude = [\"SECRET_*\", 17]"; "shell policy")]
|
|
#[test_case("[plugins._default]\nenabled = false\n[plugins.\"sample@openai-curated-remote\"]\nenabled = \"false\""; "plugin policy")]
|
|
#[tokio::test]
|
|
async fn reload_user_config_layer_keeps_previous_config_for_malformed_policy(
|
|
malformed_policy: &str,
|
|
) {
|
|
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[plugins._default]\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,
|
|
format!("[apps.calendar]\nenabled = true\n{malformed_policy}\n"),
|
|
)
|
|
.expect("write malformed user config");
|
|
|
|
session.reload_user_config_layer().await;
|
|
|
|
let config = session.get_config().await;
|
|
let current_config = config
|
|
.config_layer_stack
|
|
.effective_user_config()
|
|
.expect("current user config");
|
|
assert_eq!(current_config, previous_config);
|
|
assert!(!config.plugins.allows_plugin("sample@openai-curated-remote"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reload_user_config_layer_updates_base_and_selected_profile_layers() {
|
|
let (session, _turn_context) = make_session_and_context().await;
|
|
let codex_home = session.codex_home().await;
|
|
std::fs::create_dir_all(&codex_home).expect("create codex home");
|
|
let base_config_path = codex_home.join(CONFIG_TOML_FILE);
|
|
let profile_config_path = codex_home.join("work.config.toml");
|
|
std::fs::write(
|
|
&base_config_path,
|
|
"model = \"base\"\napproval_policy = \"on-request\"\n",
|
|
)
|
|
.expect("write base user config");
|
|
std::fs::write(&profile_config_path, "model = \"profile-old\"\n")
|
|
.expect("write profile user config");
|
|
let config = ConfigBuilder::without_managed_config_for_tests()
|
|
.codex_home(codex_home.to_path_buf())
|
|
.loader_overrides(LoaderOverrides {
|
|
user_config_path: Some(profile_config_path.abs()),
|
|
user_config_profile: Some("work".parse().expect("profile-v2 name")),
|
|
..LoaderOverrides::without_managed_config_for_tests()
|
|
})
|
|
.build()
|
|
.await
|
|
.expect("load profile config");
|
|
{
|
|
let mut state = session.state.lock().await;
|
|
state.session_configuration.original_config_do_not_use = Arc::new(config);
|
|
}
|
|
std::fs::write(
|
|
&base_config_path,
|
|
"model = \"base\"\napproval_policy = \"never\"\n",
|
|
)
|
|
.expect("update base user config");
|
|
std::fs::write(&profile_config_path, "model = \"profile-new\"\n")
|
|
.expect("update profile user config");
|
|
|
|
session.reload_user_config_layer().await;
|
|
|
|
let config = session.get_config().await;
|
|
assert_eq!(
|
|
config
|
|
.config_layer_stack
|
|
.get_user_config_file()
|
|
.map(codex_utils_absolute_path::AbsolutePathBuf::as_path),
|
|
Some(profile_config_path.as_path())
|
|
);
|
|
let effective_user_config = config
|
|
.config_layer_stack
|
|
.effective_user_config()
|
|
.expect("merged user config");
|
|
assert_eq!(
|
|
effective_user_config
|
|
.get("model")
|
|
.and_then(toml::Value::as_str),
|
|
Some("profile-new")
|
|
);
|
|
assert_eq!(
|
|
effective_user_config
|
|
.get("approval_policy")
|
|
.and_then(toml::Value::as_str),
|
|
Some("never")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reload_user_config_layer_refreshes_hooks() -> anyhow::Result<()> {
|
|
let session = make_session_with_config(|config| {
|
|
config
|
|
.features
|
|
.enable(Feature::CodexHooks)
|
|
.expect("enable Codex hooks");
|
|
})
|
|
.await?;
|
|
let codex_home = session.codex_home().await;
|
|
std::fs::create_dir_all(&codex_home)?;
|
|
let config_toml_path = codex_home.join(CONFIG_TOML_FILE);
|
|
let user_config: codex_config::TomlValue = serde_json::from_value(serde_json::json!({
|
|
"hooks": {
|
|
"SessionStart": [{
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": "python3 /tmp/user.py",
|
|
}],
|
|
}],
|
|
},
|
|
}))?;
|
|
|
|
let request = codex_hooks::SessionStartRequest {
|
|
session_id: session.thread_id,
|
|
cwd: session.get_config().await.cwd.clone(),
|
|
transcript_path: None,
|
|
model: "gpt-5.2".to_string(),
|
|
permission_mode: "default".to_string(),
|
|
target: codex_hooks::StartHookTarget::SessionStart {
|
|
source: codex_hooks::SessionStartSource::Startup,
|
|
},
|
|
};
|
|
assert!(session.hooks().preview_session_start(&request).is_empty());
|
|
|
|
let config = session.get_config().await;
|
|
let hook_list = codex_hooks::list_hooks(codex_hooks::HooksConfig {
|
|
feature_enabled: true,
|
|
config_layer_stack: Some(
|
|
config
|
|
.config_layer_stack
|
|
.with_user_config(&config_toml_path, user_config.clone())
|
|
.expect("hook user config should be valid"),
|
|
),
|
|
..codex_hooks::HooksConfig::default()
|
|
});
|
|
assert_eq!(hook_list.hooks.len(), 1);
|
|
assert_eq!(
|
|
hook_list.hooks[0].trust_status,
|
|
codex_protocol::protocol::HookTrustStatus::Untrusted
|
|
);
|
|
|
|
let trusted_user_config: codex_config::TomlValue = serde_json::from_value(serde_json::json!({
|
|
"hooks": {
|
|
"SessionStart": [{
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": "python3 /tmp/user.py",
|
|
}],
|
|
}],
|
|
"state": {
|
|
hook_list.hooks[0].key.clone(): {
|
|
"trusted_hash": hook_list.hooks[0].current_hash.clone(),
|
|
},
|
|
},
|
|
},
|
|
}))?;
|
|
std::fs::write(&config_toml_path, toml::to_string(&trusted_user_config)?)?;
|
|
|
|
session.reload_user_config_layer().await;
|
|
|
|
assert_eq!(session.hooks().preview_session_start(&request).len(), 1);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_runtime_config_refreshes_hooks() -> anyhow::Result<()> {
|
|
let (session, _turn_context) = make_session_and_context().await;
|
|
{
|
|
let mut state = session.state.lock().await;
|
|
let mut config = (*state.session_configuration.original_config_do_not_use).clone();
|
|
config
|
|
.features
|
|
.enable(Feature::CodexHooks)
|
|
.expect("enable Codex hooks");
|
|
state.session_configuration.original_config_do_not_use = Arc::new(config);
|
|
}
|
|
let codex_home = session.codex_home().await;
|
|
std::fs::create_dir_all(&codex_home)?;
|
|
let config_toml_path = codex_home.join(CONFIG_TOML_FILE);
|
|
#[derive(serde::Serialize)]
|
|
struct NormalizedHookIdentity {
|
|
event_name: &'static str,
|
|
#[serde(flatten)]
|
|
group: codex_config::MatcherGroup,
|
|
}
|
|
let trusted_hash = {
|
|
let identity = NormalizedHookIdentity {
|
|
event_name: "session_start",
|
|
group: codex_config::MatcherGroup {
|
|
matcher: None,
|
|
hooks: vec![codex_config::HookHandlerConfig::Command {
|
|
command: "python3 /tmp/user.py".to_string(),
|
|
command_windows: None,
|
|
timeout_sec: Some(600),
|
|
r#async: false,
|
|
status_message: None,
|
|
additional_context_limit: None,
|
|
}],
|
|
},
|
|
};
|
|
let identity = codex_config::TomlValue::try_from(identity)?;
|
|
codex_config::version_for_toml(&identity)
|
|
};
|
|
let hook_key = format!("{}:session_start:0:0", config_toml_path.display());
|
|
let trusted_user_config: codex_config::TomlValue = serde_json::from_value(serde_json::json!({
|
|
"hooks": {
|
|
"SessionStart": [{
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": "python3 /tmp/user.py",
|
|
}],
|
|
}],
|
|
"state": {
|
|
hook_key: {
|
|
"trusted_hash": trusted_hash,
|
|
},
|
|
},
|
|
},
|
|
}))?;
|
|
std::fs::write(&config_toml_path, toml::to_string(&trusted_user_config)?)?;
|
|
|
|
let request = codex_hooks::SessionStartRequest {
|
|
session_id: session.thread_id,
|
|
cwd: session.get_config().await.cwd.clone(),
|
|
transcript_path: None,
|
|
model: "gpt-5.2".to_string(),
|
|
permission_mode: "default".to_string(),
|
|
target: codex_hooks::StartHookTarget::SessionStart {
|
|
source: codex_hooks::SessionStartSource::Startup,
|
|
},
|
|
};
|
|
assert!(session.hooks().preview_session_start(&request).is_empty());
|
|
|
|
let current_config = session.get_config().await;
|
|
let next_config = load_latest_config_for_session(&session).await;
|
|
let _ = session
|
|
.refresh_runtime_config(current_config, next_config)
|
|
.await;
|
|
|
|
assert_eq!(session.hooks().preview_session_start(&request).len(), 1);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reload_user_config_layer_updates_effective_tool_suggest_config() {
|
|
let (session, _turn_context) = make_session_and_context().await;
|
|
let codex_home = session.codex_home().await;
|
|
std::fs::create_dir_all(&codex_home).expect("create codex home");
|
|
let config_toml_path = codex_home.join(CONFIG_TOML_FILE);
|
|
std::fs::write(
|
|
&config_toml_path,
|
|
r#"[tool_suggest]
|
|
disabled_tools = [
|
|
{ type = "connector", id = " calendar " },
|
|
{ type = "plugin", id = "slack@openai-curated" },
|
|
]
|
|
"#,
|
|
)
|
|
.expect("write user config");
|
|
|
|
session.reload_user_config_layer().await;
|
|
|
|
let config = session.get_config().await;
|
|
assert_eq!(
|
|
config.tool_suggest.disabled_tools,
|
|
vec![
|
|
ToolSuggestDisabledTool::connector("calendar"),
|
|
ToolSuggestDisabledTool::plugin("slack@openai-curated"),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_runtime_config_updates_runtime_refreshable_fields_and_keeps_session_static_settings()
|
|
{
|
|
let (session, _turn_context) = make_session_and_context().await;
|
|
let codex_home = session.codex_home().await;
|
|
std::fs::create_dir_all(&codex_home).expect("create codex home");
|
|
std::fs::write(
|
|
codex_home.join(CONFIG_TOML_FILE),
|
|
r#"[apps.calendar]
|
|
enabled = false
|
|
destructive_enabled = false
|
|
|
|
[tool_suggest]
|
|
disabled_tools = [
|
|
{ type = "connector", id = " calendar " },
|
|
{ type = "plugin", id = "slack@openai-curated" },
|
|
]
|
|
"#,
|
|
)
|
|
.expect("write user config");
|
|
|
|
let original = session.get_config().await;
|
|
let mut next_config = load_latest_config_for_session(&session).await;
|
|
next_config.model = Some("gpt-5.4".to_string());
|
|
next_config.notify = Some(vec!["echo".to_string()]);
|
|
let mcp_protocol_enabled = !original.features.enabled(Feature::Mcp20260728);
|
|
let apps_protocol_enabled = !original.features.enabled(Feature::CodexAppsMcp20260728);
|
|
next_config
|
|
.features
|
|
.set_enabled(Feature::Mcp20260728, mcp_protocol_enabled)
|
|
.expect("set MCP protocol rollout");
|
|
next_config
|
|
.features
|
|
.set_enabled(Feature::CodexAppsMcp20260728, apps_protocol_enabled)
|
|
.expect("set Codex Apps MCP protocol rollout");
|
|
|
|
let _ = session
|
|
.refresh_runtime_config(Arc::clone(&original), 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.features.enabled(Feature::Mcp20260728),
|
|
mcp_protocol_enabled
|
|
);
|
|
assert_eq!(
|
|
config.features.enabled(Feature::CodexAppsMcp20260728),
|
|
apps_protocol_enabled
|
|
);
|
|
assert_eq!(
|
|
config.tool_suggest.disabled_tools,
|
|
vec![
|
|
ToolSuggestDisabledTool::connector("calendar"),
|
|
ToolSuggestDisabledTool::plugin("slack@openai-curated"),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test_case::test_case(false; "published")]
|
|
#[test_case::test_case(true; "rejected")]
|
|
#[tokio::test]
|
|
async fn refresh_mcp_config_replaces_managed_server_and_plugin_requirements(reject: bool) {
|
|
let (session, _turn_context) = make_session_and_context().await;
|
|
let original = session.get_config().await;
|
|
if reject {
|
|
let mut config = original.as_ref().clone();
|
|
let mut layers: Vec<_> = config
|
|
.config_layer_stack
|
|
.all_layers_low_to_high()
|
|
.filter(|layer| !matches!(layer.name, codex_config::ConfigLayerSource::SessionFlags))
|
|
.cloned()
|
|
.collect();
|
|
layers.push(codex_config::ConfigLayerEntry::new(
|
|
codex_config::ConfigLayerSource::SessionFlags,
|
|
toml::from_str("features = 'invalid retained features'").unwrap(),
|
|
));
|
|
config.config_layer_stack = ConfigLayerStack::new(
|
|
layers,
|
|
config.config_layer_stack.requirements().clone(),
|
|
config.config_layer_stack.requirements_toml().clone(),
|
|
)
|
|
.unwrap();
|
|
session
|
|
.state
|
|
.lock()
|
|
.await
|
|
.session_configuration
|
|
.original_config_do_not_use = Arc::new(config);
|
|
}
|
|
let original = session.get_config().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.clone(),
|
|
)])),
|
|
},
|
|
)]);
|
|
|
|
let mut next_config = original.as_ref().clone();
|
|
let mcp_protocol_enabled = !original.features.enabled(Feature::Mcp20260728);
|
|
let apps_protocol_enabled = !original.features.enabled(Feature::CodexAppsMcp20260728);
|
|
next_config
|
|
.features
|
|
.set_enabled(Feature::Mcp20260728, mcp_protocol_enabled)
|
|
.expect("set MCP protocol rollout");
|
|
next_config
|
|
.features
|
|
.set_enabled(Feature::CodexAppsMcp20260728, apps_protocol_enabled)
|
|
.expect("set Codex Apps MCP protocol rollout");
|
|
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();
|
|
let mcp_requirements = BTreeMap::from([("beta".to_string(), requirement)]);
|
|
requirements.mcp_servers = Some(Sourced::new(
|
|
mcp_requirements.clone(),
|
|
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
|
));
|
|
requirements.plugins = Some(Sourced::new(
|
|
plugin_requirements.clone(),
|
|
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
|
));
|
|
let feature_requirements: codex_config::FeatureRequirementsToml =
|
|
serde_json::from_value(json!({"secret_auth_storage": false})).unwrap();
|
|
requirements.feature_requirements = Some(Sourced::new(
|
|
feature_requirements.clone(),
|
|
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
|
));
|
|
next_config.features = crate::config::ManagedFeatures::from_configured(
|
|
next_config.features.get().clone(),
|
|
requirements.feature_requirements.clone(),
|
|
)
|
|
.unwrap();
|
|
let mut requirements_toml = next_config.config_layer_stack.requirements_toml().clone();
|
|
requirements_toml.feature_requirements = Some(feature_requirements);
|
|
requirements_toml.mcp_servers = Some(mcp_requirements);
|
|
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");
|
|
|
|
let outcome = session
|
|
.refresh_mcp_config(Arc::clone(&original), next_config)
|
|
.await;
|
|
assert_eq!(
|
|
outcome,
|
|
if reject {
|
|
crate::ConfigRefreshOutcome::Rejected
|
|
} else {
|
|
crate::ConfigRefreshOutcome::Published
|
|
}
|
|
);
|
|
|
|
let config = session.get_config().await;
|
|
assert!(!config.features.enabled(Feature::SecretAuthStorage));
|
|
assert!(
|
|
config
|
|
.config_layer_stack
|
|
.requirements()
|
|
.feature_requirements
|
|
.is_some()
|
|
);
|
|
assert_eq!(
|
|
config.features.enabled(Feature::Mcp20260728),
|
|
mcp_protocol_enabled
|
|
);
|
|
assert_eq!(
|
|
config.features.enabled(Feature::CodexAppsMcp20260728),
|
|
apps_protocol_enabled
|
|
);
|
|
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!(
|
|
!managed_servers
|
|
.get()
|
|
.get("alpha")
|
|
.is_some_and(|server| server.enabled)
|
|
);
|
|
assert_eq!(managed_servers.get()["beta"], server);
|
|
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);
|
|
let reloaded = config.resolve_runtime_refresh(&config, RuntimeConfigRefresh::User);
|
|
if let Ok(reloaded) = reloaded {
|
|
assert_eq!(
|
|
reloaded.config_layer_stack.requirements().plugins,
|
|
config.config_layer_stack.requirements().plugins
|
|
);
|
|
} else {
|
|
assert!(reject);
|
|
}
|
|
}
|
|
|
|
#[test_case::test_case(RuntimeConfigRefresh::User; "user refresh")]
|
|
#[test_case::test_case(RuntimeConfigRefresh::Mcp; "MCP refresh")]
|
|
#[test_case::test_case(RuntimeConfigRefresh::UserFiles; "file refresh")]
|
|
#[tokio::test]
|
|
async fn refresh_config_rejects_stale_owner_without_effects(scope: RuntimeConfigRefresh) {
|
|
let (session, _turn_context) = make_session_and_context().await;
|
|
let stale_owner = session.get_config().await;
|
|
let mut stale_config = stale_owner.as_ref().clone();
|
|
stale_config
|
|
.features
|
|
.set_enabled(
|
|
Feature::Mcp20260728,
|
|
!stale_owner.features.enabled(Feature::Mcp20260728),
|
|
)
|
|
.expect("set MCP protocol rollout");
|
|
assert_eq!(
|
|
session
|
|
.refresh_runtime_config(Arc::clone(&stale_owner), stale_owner.as_ref().clone())
|
|
.await,
|
|
crate::ConfigRefreshOutcome::Published
|
|
);
|
|
let current_owner = session.get_config().await;
|
|
assert!(session.mcp_refresh.claim());
|
|
|
|
assert_eq!(
|
|
session
|
|
.refresh_config(stale_owner, stale_config, scope)
|
|
.await,
|
|
crate::ConfigRefreshOutcome::Stale
|
|
);
|
|
|
|
assert!(Arc::ptr_eq(¤t_owner, &session.get_config().await));
|
|
assert!(!session.mcp_refresh.is_pending());
|
|
}
|
|
|
|
#[test]
|
|
fn collect_explicit_app_ids_from_skill_items_includes_linked_mentions() {
|
|
let connectors = vec![make_connector("calendar", "Calendar")];
|
|
let skill_items = vec\n</skill>",
|
|
)];
|
|
|
|
let connector_ids =
|
|
collect_explicit_app_ids_from_skill_items(&skill_items, &connectors, &HashMap::new());
|
|
|
|
assert_eq!(connector_ids, HashSet::from(["calendar".to_string()]));
|
|
}
|
|
|
|
#[test]
|
|
fn collect_explicit_app_ids_from_skill_items_resolves_unambiguous_plain_mentions() {
|
|
let connectors = vec![make_connector("calendar", "Calendar")];
|
|
let skill_items = vec![skill_message(
|
|
"<skill>\n<name>demo</name>\n<path>/tmp/skills/demo/SKILL.md</path>\nuse $calendar\n</skill>",
|
|
)];
|
|
|
|
let connector_ids =
|
|
collect_explicit_app_ids_from_skill_items(&skill_items, &connectors, &HashMap::new());
|
|
|
|
assert_eq!(connector_ids, HashSet::from(["calendar".to_string()]));
|
|
}
|
|
|
|
#[test]
|
|
fn collect_explicit_app_ids_from_skill_items_skips_plain_mentions_with_skill_conflicts() {
|
|
let connectors = vec![make_connector("calendar", "Calendar")];
|
|
let skill_items = vec![skill_message(
|
|
"<skill>\n<name>demo</name>\n<path>/tmp/skills/demo/SKILL.md</path>\nuse $calendar\n</skill>",
|
|
)];
|
|
let skill_name_counts_lower = HashMap::from([("calendar".to_string(), 1)]);
|
|
|
|
let connector_ids = collect_explicit_app_ids_from_skill_items(
|
|
&skill_items,
|
|
&connectors,
|
|
&skill_name_counts_lower,
|
|
);
|
|
|
|
assert_eq!(connector_ids, HashSet::<String>::new());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reconstruct_history_matches_live_compactions() {
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
let (rollout_items, expected) = sample_rollout(&session, &turn_context).await;
|
|
|
|
let reconstruction_turn = session.new_default_turn().await;
|
|
let reconstructed = session
|
|
.reconstruct_history_from_rollout(reconstruction_turn.as_ref(), &rollout_items)
|
|
.await;
|
|
|
|
assert_eq!(expected, raw_envelopes(&reconstructed.history));
|
|
assert_eq!(2, reconstructed.window_number);
|
|
assert_eq!(
|
|
reconstructed
|
|
.window_id
|
|
.map(|window_id| window_id.get_version_num()),
|
|
Some(7)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reconstruct_history_uses_replacement_history_verbatim() {
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
let summary_item = ResponseItem::Message {
|
|
status: None,
|
|
encrypted_content: None,
|
|
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 {
|
|
status: None,
|
|
encrypted_content: None,
|
|
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(Box::new(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,
|
|
resume_metadata: None,
|
|
}))];
|
|
|
|
let reconstructed = session
|
|
.reconstruct_history_from_rollout(&turn_context, &rollout_items)
|
|
.await;
|
|
|
|
assert_eq!(reconstructed.history, replacement_history);
|
|
assert_eq!(42, reconstructed.window_number);
|
|
assert_eq!(Some(first_window_id), reconstructed.first_window_id);
|
|
assert_eq!(Some(previous_window_id), reconstructed.previous_window_id);
|
|
assert_eq!(Some(window_id), reconstructed.window_id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_initial_history_reconstructs_resumed_transcript() {
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
let (rollout_items, expected) = sample_rollout(&session, &turn_context).await;
|
|
|
|
session
|
|
.record_initial_history(InitialHistory::Resumed(ResumedHistory {
|
|
history_revision: None,
|
|
conversation_id: ThreadId::default(),
|
|
history: Arc::new(rollout_items),
|
|
rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")),
|
|
}))
|
|
.await;
|
|
|
|
let history = session.state.lock().await.clone_history();
|
|
assert_eq!(expected, raw_history_items(&history));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_conversation_items_stamps_missing_turn_id_and_preserves_existing_turn_id() {
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
let mut fresh_item = user_message("fresh");
|
|
fresh_item.set_id(Some(ResponseItemId::with_suffix("msg", "fresh")));
|
|
let mut existing_item = assistant_message("existing");
|
|
existing_item.set_id(Some(ResponseItemId::with_suffix("msg", "existing")));
|
|
existing_item.set_turn_id_if_missing("older-turn");
|
|
|
|
session
|
|
.record_conversation_items(
|
|
&turn_context,
|
|
turn_context.model_info(),
|
|
&[fresh_item.clone(), existing_item.clone()],
|
|
)
|
|
.await;
|
|
|
|
let history = session.clone_history().await;
|
|
let recorded_items = raw_history_items(&history);
|
|
let fresh_create_time = recorded_items[0]
|
|
.executed_tool_call_metadata()
|
|
.and_then(|metadata| metadata.create_time.clone())
|
|
.expect("harness-authored items should receive creation timestamps");
|
|
assert!(
|
|
fresh_create_time
|
|
.as_f64()
|
|
.is_some_and(|seconds| seconds > 0.0)
|
|
);
|
|
|
|
let mut expected_fresh_item = fresh_item;
|
|
expected_fresh_item.set_turn_id_if_missing(&turn_context.sub_id);
|
|
expected_fresh_item.set_create_time_if_missing(fresh_create_time);
|
|
let expected_items = vec![expected_fresh_item, existing_item];
|
|
assert_eq!(recorded_items, expected_items);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_response_item_and_emit_turn_item_emits_hook_prompt_lifecycle() {
|
|
let (session, turn_context, rx) = make_session_and_context_with_rx().await;
|
|
let response_item = build_hook_prompt_message(&[HookPromptFragment::from_single_hook(
|
|
"Retry with tests.",
|
|
"hook-run-1",
|
|
)])
|
|
.expect("hook prompt message");
|
|
let response_item_id = response_item.id().expect("hook prompt id").to_string();
|
|
|
|
session
|
|
.record_response_item_and_emit_turn_item(
|
|
&turn_context,
|
|
turn_context.model_info(),
|
|
response_item,
|
|
)
|
|
.await;
|
|
|
|
let raw_response = rx.recv().await.expect("raw response item event");
|
|
assert!(matches!(raw_response.msg, EventMsg::RawResponseItem(_)));
|
|
|
|
let started = rx.recv().await.expect("started hook prompt event");
|
|
assert!(matches!(
|
|
started.msg,
|
|
EventMsg::ItemStarted(ItemStartedEvent {
|
|
item: TurnItem::HookPrompt(item),
|
|
..
|
|
}) if item.id == response_item_id
|
|
));
|
|
|
|
let completed = rx.recv().await.expect("completed hook prompt event");
|
|
assert!(matches!(
|
|
completed.msg,
|
|
EventMsg::ItemCompleted(ItemCompletedEvent {
|
|
item: TurnItem::HookPrompt(item),
|
|
..
|
|
}) if item.id == response_item_id
|
|
));
|
|
|
|
assert!(rx.try_recv().is_err(), "no extra events expected");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn item_completion_without_a_start_uses_completion_timestamp() {
|
|
let (session, turn_context, rx) = make_session_and_context_with_rx().await;
|
|
let item = TurnItem::UserMessage(UserMessageItem {
|
|
id: "missing-start".to_string(),
|
|
client_id: None,
|
|
content: Vec::new(),
|
|
});
|
|
|
|
session.emit_turn_item_completed(&turn_context, item).await;
|
|
|
|
let completed = rx.recv().await.expect("completed item event");
|
|
let EventMsg::ItemCompleted(event) = completed.msg else {
|
|
panic!("expected completed item event");
|
|
};
|
|
assert_eq!(event.started_at_ms, Some(event.completed_at_ms));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn subagent_activity_emits_matching_start_and_completion() {
|
|
let (session, turn_context, rx) = make_session_and_context_with_rx().await;
|
|
let item = codex_protocol::items::SubAgentActivityItem {
|
|
model: Some("requested-model".to_string()),
|
|
reasoning_effort: Some(codex_protocol::openai_models::ReasoningEffort::High),
|
|
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.clone(),
|
|
)
|
|
.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");
|
|
};
|
|
let TurnItem::SubAgentActivity(started_item) = started.item else {
|
|
panic!("expected started sub-agent activity");
|
|
};
|
|
let TurnItem::SubAgentActivity(completed_item) = completed.item else {
|
|
panic!("expected completed sub-agent activity");
|
|
};
|
|
assert_eq!(started_item, item);
|
|
assert_eq!(completed_item, item);
|
|
assert_eq!(completed.started_at_ms, Some(started.started_at_ms));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn inter_agent_communication_waits_for_confirmed_delivery_persistence() {
|
|
let (mut session, turn_context) = make_session_and_context().await;
|
|
let turn_context = Arc::new(turn_context);
|
|
let rollout_path = attach_thread_persistence(&mut session).await;
|
|
let session = Arc::new(session);
|
|
let checkpoint = thread_settings::acquire_persistence_lock(&session).await;
|
|
let message = codex_history::RetainedUserMessage {
|
|
origin: codex_history::UserInputOrigin::User,
|
|
turn_id: turn_context.sub_id.clone(),
|
|
message_id: Some("send".to_owned()),
|
|
text: "May I publish?".to_owned(),
|
|
complete: true,
|
|
phase: None,
|
|
};
|
|
let (recording, _) = session.record_delivered_assistant_message(message.clone());
|
|
let communication = InterAgentCommunication::new(
|
|
AgentPath::root().join("worker").expect("worker path"),
|
|
AgentPath::root(),
|
|
Vec::new(),
|
|
"child done".to_owned(),
|
|
/*trigger_turn*/ false,
|
|
);
|
|
let communication_session = Arc::clone(&session);
|
|
let replay_turn = Arc::clone(&turn_context);
|
|
let mut communication_task = tokio::spawn(async move {
|
|
communication_session
|
|
.record_inter_agent_communication(
|
|
&turn_context,
|
|
turn_context.model_info(),
|
|
communication,
|
|
)
|
|
.await;
|
|
});
|
|
assert!(
|
|
tokio::time::timeout(
|
|
std::time::Duration::from_millis(/*millis*/ 100),
|
|
&mut communication_task,
|
|
)
|
|
.await
|
|
.is_err()
|
|
);
|
|
// Confirm another send while communication waits for the earlier delivery.
|
|
let (later, _) =
|
|
session.record_delivered_assistant_message(codex_history::RetainedUserMessage {
|
|
message_id: Some("later".to_owned()),
|
|
text: "Actually, wait.".to_owned(),
|
|
..message.clone()
|
|
});
|
|
drop(checkpoint);
|
|
recording.await.expect("confirmed delivery persisted");
|
|
communication_task
|
|
.await
|
|
.expect("communication persisted after delivery");
|
|
later.await.expect("later delivery persisted");
|
|
session.flush_rollout().await.expect("rollout flushed");
|
|
let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path)
|
|
.await
|
|
.expect("read rollout history")
|
|
else {
|
|
panic!("expected resumed rollout history");
|
|
};
|
|
let boundaries = resumed
|
|
.history
|
|
.iter()
|
|
.filter_map(|item| match item {
|
|
RolloutItem::RetainedContext(
|
|
codex_history::RetainedContextEvent::DeliveredAssistantMessage {
|
|
acceptance_order,
|
|
..
|
|
},
|
|
) => Some(("delivery", Some(*acceptance_order))),
|
|
RolloutItem::ResponseItem(ResponseItemEnvelope {
|
|
item: ResponseItem::AgentMessage { .. },
|
|
metadata: Some(metadata),
|
|
}) => Some(("communication", metadata.user_input_order)),
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
boundaries,
|
|
vec![
|
|
("delivery", Some(0)),
|
|
("communication", Some(1)),
|
|
("delivery", Some(2)),
|
|
]
|
|
);
|
|
let live = session.clone_history().await;
|
|
let communication = live
|
|
.annotated_items()
|
|
.iter()
|
|
.find(|item| matches!(item.item, ResponseItem::AgentMessage { .. }))
|
|
.expect("live communication");
|
|
assert_eq!(
|
|
communication.metadata.as_ref().unwrap().user_input_order,
|
|
Some(1)
|
|
);
|
|
let mut compacted: CompactedItem =
|
|
serde_json::from_value(json!({"message": "compacted"})).unwrap();
|
|
compacted.replacement_history = Some(live.annotated_items().to_vec());
|
|
compacted.retained_context = Some(live.retained_context().clone());
|
|
let mut history = resumed.history.as_ref().clone();
|
|
history.extend([
|
|
RolloutItem::Compacted(Box::new(compacted)),
|
|
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
|
|
codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 },
|
|
)),
|
|
]);
|
|
let replayed = session
|
|
.reconstruct_history_from_rollout(&replay_turn, &history)
|
|
.await;
|
|
let messages = replayed
|
|
.retained_context
|
|
.ordered_entries()
|
|
.filter_map(|(_, entry)| {
|
|
if let codex_history::RetainedContextEntry::AssistantMessage(message) = entry {
|
|
Some(message.clone())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(messages, vec![message]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_inter_agent_communication_sets_turn_id_in_rollout_and_resume() {
|
|
let (mut session, turn_context) = make_session_and_context().await;
|
|
let rollout_path = attach_thread_persistence(&mut session).await;
|
|
let communication = InterAgentCommunication::new(
|
|
AgentPath::root().join("worker").expect("worker path"),
|
|
AgentPath::root(),
|
|
Vec::new(),
|
|
"child done".to_string(),
|
|
/*trigger_turn*/ false,
|
|
);
|
|
let mut expected_item = communication.to_model_input_item();
|
|
let mut expected = serde_json::to_value(&expected_item).unwrap();
|
|
expected["internal_chat_message_metadata_passthrough"] = json!({
|
|
"content_item_metadata": [{"harness_injected": true, "source_tool_namespace": "collaboration"}],
|
|
});
|
|
expected_item = serde_json::from_value(expected).unwrap();
|
|
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(ResponseItemEnvelope {
|
|
item: expected_item.clone(),
|
|
metadata: Some(CodexHarnessMetadata {
|
|
user_input_order: Some(0),
|
|
..Default::default()
|
|
}),
|
|
}),
|
|
];
|
|
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))
|
|
);
|
|
assert_eq!(resumed_session.reserve_user_input_order().await, 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_inter_agent_communication_preserves_item_id_in_rollout_and_resume() {
|
|
let (mut session, turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx(
|
|
CodexAuth::from_api_key("Test API Key"),
|
|
Vec::new(),
|
|
|_| {},
|
|
)
|
|
.await;
|
|
let rollout_path =
|
|
attach_thread_persistence(Arc::get_mut(&mut session).expect("unique session")).await;
|
|
let communication = InterAgentCommunication::new(
|
|
AgentPath::root().join("worker").expect("worker path"),
|
|
AgentPath::root(),
|
|
Vec::new(),
|
|
"child done".to_string(),
|
|
/*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())
|
|
);
|
|
}
|
|
|
|
#[test_case::test_case(false, None, false, 5; "plain items")]
|
|
#[test_case::test_case(true, None, false, 5; "annotated items")]
|
|
#[test_case::test_case(true, Some(100), false, 100; "existing tool budget")]
|
|
#[test_case::test_case(false, None, true, 31; "byte policy includes allowance before conversion")]
|
|
#[tokio::test]
|
|
async fn annotated_history_uses_explicit_model_without_a_step(
|
|
retain_metadata: bool,
|
|
saved_budget: Option<usize>,
|
|
byte_policy: bool,
|
|
expected_budget: usize,
|
|
) {
|
|
let (session, turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx(
|
|
CodexAuth::from_api_key("Test API Key"),
|
|
Vec::new(),
|
|
|config| {
|
|
config
|
|
.features
|
|
.enable(Feature::RetainClientDeveloperMessages)
|
|
.unwrap();
|
|
},
|
|
)
|
|
.await;
|
|
let mut model_info = turn_context.model_info().as_ref().clone();
|
|
model_info.truncation_policy = if byte_policy {
|
|
codex_protocol::openai_models::TruncationPolicyConfig::bytes(/*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;
|
|
expected[0].metadata.get_or_insert_default().mcp_attribution = Some(
|
|
session
|
|
.services
|
|
.executed_tool_calls
|
|
.mcp_attribution_snapshot(),
|
|
);
|
|
for envelope in &mut expected {
|
|
envelope
|
|
.metadata
|
|
.get_or_insert_default()
|
|
.history_truncation_token_limit = Some(expected_budget);
|
|
let (ResponseItem::FunctionCallOutput { output, .. }
|
|
| ResponseItem::CustomToolCallOutput { output, .. }) = &mut envelope.item
|
|
else {
|
|
unreachable!("fixture is a tool output");
|
|
};
|
|
output.body = FunctionCallOutputBody::Text(codex_utils_output_truncation::truncate_text(
|
|
&text,
|
|
codex_utils_output_truncation::TruncationPolicy::Tokens(expected_budget),
|
|
));
|
|
}
|
|
assert_eq!(session.clone_history().await.annotated_items(), &expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prepares_image_failures_before_history_insertion() {
|
|
let (session, turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx(
|
|
CodexAuth::from_api_key("Test API Key"),
|
|
Vec::new(),
|
|
|_| {},
|
|
)
|
|
.await;
|
|
let item = ResponseItem::FunctionCallOutput {
|
|
id: None,
|
|
call_id: Some("call-1".to_string()),
|
|
name: None,
|
|
namespace: None,
|
|
output: FunctionCallOutputPayload {
|
|
body: FunctionCallOutputBody::ContentItems(vec![
|
|
FunctionCallOutputContentItem::InputText {
|
|
text: "before".to_string(),
|
|
},
|
|
FunctionCallOutputContentItem::InputImage {
|
|
image: ImageReference::Inline {
|
|
image_url: "data:image/png;base64,%%%".to_string(),
|
|
},
|
|
detail: Some(ImageDetail::High),
|
|
},
|
|
FunctionCallOutputContentItem::InputImage {
|
|
image: ImageReference::Inline {
|
|
image_url: "https://example.com/image.png".to_string(),
|
|
},
|
|
detail: Some(ImageDetail::High),
|
|
},
|
|
]),
|
|
success: Some(true),
|
|
},
|
|
internal_chat_message_metadata_passthrough: None,
|
|
};
|
|
|
|
session
|
|
.record_conversation_items(
|
|
turn_context.as_ref(),
|
|
turn_context.model_info(),
|
|
std::slice::from_ref(&item),
|
|
)
|
|
.await;
|
|
|
|
let history = session.state.lock().await.clone_history();
|
|
let id = history
|
|
.raw_items()
|
|
.next()
|
|
.expect("history should contain one item")
|
|
.id()
|
|
.expect("history item should have an ID");
|
|
let uuid = id
|
|
.strip_prefix("fco_")
|
|
.expect("function call output ID should have the Responses API prefix");
|
|
let parsed_id = Uuid::parse_str(uuid).expect("history item should have a UUID ID");
|
|
assert_eq!(parsed_id.get_version(), Some(uuid::Version::SortRand));
|
|
let expected = vec![ResponseItem::FunctionCallOutput {
|
|
id: Some(id.clone()),
|
|
call_id: Some("call-1".to_string()),
|
|
name: None,
|
|
namespace: None,
|
|
output: FunctionCallOutputPayload {
|
|
body: FunctionCallOutputBody::ContentItems(vec![
|
|
FunctionCallOutputContentItem::InputText {
|
|
text: "before".to_string(),
|
|
},
|
|
FunctionCallOutputContentItem::InputText {
|
|
text: "image content omitted because it could not be processed".to_string(),
|
|
},
|
|
FunctionCallOutputContentItem::InputText {
|
|
text: "image content omitted because remote image URLs are not supported"
|
|
.to_string(),
|
|
},
|
|
]),
|
|
success: Some(true),
|
|
},
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}];
|
|
assert_eq!(
|
|
strip_metadata_from_items(&raw_history_items(&history)),
|
|
expected
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prepares_resumed_history_before_installing_it() {
|
|
let (session, _turn_context) = make_session_and_context().await;
|
|
let resumed_item = ResponseItem::Message {
|
|
status: None,
|
|
encrypted_content: None,
|
|
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 {
|
|
history_revision: None,
|
|
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 {
|
|
encrypted_content: None,
|
|
status: None,
|
|
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()),
|
|
]),
|
|
content_item_metadata: Some(vec![
|
|
ContentItemMetadata::harness(),
|
|
ContentItemMetadata::harness(),
|
|
ContentItemMetadata::default(),
|
|
]),
|
|
..Default::default()
|
|
},
|
|
),
|
|
}]
|
|
);
|
|
assert_eq!(
|
|
history.annotated_items()[0].metadata,
|
|
Some(CodexHarnessMetadata::default())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_multi_agent_version_handles_unset_and_legacy_history() {
|
|
let thread_id = ThreadId::default();
|
|
|
|
assert_eq!(
|
|
resolve_multi_agent_version(
|
|
&InitialHistory::New,
|
|
/*inherited_multi_agent_version*/ None
|
|
),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
resolve_multi_agent_version(
|
|
&InitialHistory::Resumed(ResumedHistory {
|
|
history_revision: None,
|
|
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 {
|
|
history_revision: None,
|
|
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 {
|
|
history_revision: None,
|
|
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)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_initial_history_new_defers_initial_context_until_first_turn() {
|
|
let (session, _turn_context) = make_session_and_context().await;
|
|
|
|
session.record_initial_history(InitialHistory::New).await;
|
|
|
|
let history = session.clone_history().await;
|
|
assert_eq!(raw_history_items(&history), Vec::<ResponseItem>::new());
|
|
assert!(session.reference_context_item().await.is_none());
|
|
assert_eq!(session.previous_turn_settings().await, None);
|
|
}
|
|
|
|
fn session_meta_item(
|
|
thread_id: ThreadId,
|
|
multi_agent_version: Option<MultiAgentVersion>,
|
|
) -> RolloutItem {
|
|
RolloutItem::SessionMeta(Box::new(SessionMetaLine {
|
|
meta: SessionMeta {
|
|
session_id: thread_id.into(),
|
|
id: thread_id,
|
|
multi_agent_version,
|
|
..SessionMeta::default()
|
|
},
|
|
git: None,
|
|
}))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resumed_history_injects_initial_context_on_first_context_update_only() {
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
let turn_context = Arc::new(turn_context);
|
|
let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await;
|
|
|
|
session
|
|
.record_initial_history(InitialHistory::Resumed(ResumedHistory {
|
|
history_revision: None,
|
|
conversation_id: ThreadId::default(),
|
|
history: Arc::new(rollout_items),
|
|
rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")),
|
|
}))
|
|
.await;
|
|
|
|
let history_before_seed = session.state.lock().await.clone_history();
|
|
assert_eq!(expected, raw_history_items(&history_before_seed));
|
|
|
|
let step_context = StepContext::for_test(Arc::clone(&turn_context));
|
|
session
|
|
.record_context_updates_and_set_reference_context_item(&step_context)
|
|
.await
|
|
.expect("world state should build");
|
|
let initial_context = build_initial_context(&session, &turn_context).await;
|
|
expected.extend(initial_context);
|
|
let history_after_seed = session.clone_history().await;
|
|
assert_eq!(
|
|
strip_response_item_ids(&strip_metadata_from_items(&expected)),
|
|
strip_response_item_ids(&strip_metadata_from_items(&raw_history_items(
|
|
&history_after_seed
|
|
)))
|
|
);
|
|
|
|
session
|
|
.record_context_updates_and_set_reference_context_item(&step_context)
|
|
.await
|
|
.expect("world state should build");
|
|
let history_after_second_seed = session.clone_history().await;
|
|
assert_eq!(
|
|
raw_history_items(&history_after_seed),
|
|
raw_history_items(&history_after_second_seed)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_initial_history_seeds_token_info_from_rollout() {
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
let (mut rollout_items, _expected) = sample_rollout(&session, &turn_context).await;
|
|
|
|
let info1 = TokenUsageInfo {
|
|
total_token_usage: TokenUsage {
|
|
input_tokens: 10,
|
|
cached_input_tokens: 0,
|
|
cache_write_input_tokens: 0,
|
|
output_tokens: 20,
|
|
reasoning_output_tokens: 0,
|
|
total_tokens: 30,
|
|
codex_rollout_budget_units: None,
|
|
},
|
|
last_token_usage: TokenUsage {
|
|
input_tokens: 3,
|
|
cached_input_tokens: 0,
|
|
cache_write_input_tokens: 0,
|
|
output_tokens: 4,
|
|
reasoning_output_tokens: 0,
|
|
total_tokens: 7,
|
|
codex_rollout_budget_units: None,
|
|
},
|
|
model_context_window: Some(1_000),
|
|
};
|
|
let info2 = TokenUsageInfo {
|
|
total_token_usage: TokenUsage {
|
|
input_tokens: 100,
|
|
cached_input_tokens: 50,
|
|
cache_write_input_tokens: 0,
|
|
output_tokens: 200,
|
|
reasoning_output_tokens: 25,
|
|
total_tokens: 375,
|
|
codex_rollout_budget_units: None,
|
|
},
|
|
last_token_usage: TokenUsage {
|
|
input_tokens: 10,
|
|
cached_input_tokens: 0,
|
|
cache_write_input_tokens: 0,
|
|
output_tokens: 20,
|
|
reasoning_output_tokens: 5,
|
|
total_tokens: 35,
|
|
codex_rollout_budget_units: None,
|
|
},
|
|
model_context_window: Some(2_000),
|
|
};
|
|
|
|
rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount(
|
|
TokenCountEvent {
|
|
info: Some(info1),
|
|
rate_limits: None,
|
|
},
|
|
)));
|
|
rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount(
|
|
TokenCountEvent {
|
|
info: None,
|
|
rate_limits: None,
|
|
},
|
|
)));
|
|
rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount(
|
|
TokenCountEvent {
|
|
info: Some(info2.clone()),
|
|
rate_limits: None,
|
|
},
|
|
)));
|
|
rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount(
|
|
TokenCountEvent {
|
|
info: None,
|
|
rate_limits: None,
|
|
},
|
|
)));
|
|
|
|
session
|
|
.record_initial_history(InitialHistory::Resumed(ResumedHistory {
|
|
history_revision: None,
|
|
conversation_id: ThreadId::default(),
|
|
history: Arc::new(rollout_items),
|
|
rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")),
|
|
}))
|
|
.await;
|
|
|
|
let actual = session.state.lock().await.token_info();
|
|
assert_eq!(actual, Some(info2));
|
|
}
|
|
|
|
#[test]
|
|
fn latest_token_usage_record_stops_at_compaction_checkpoint() {
|
|
let thread_id = ThreadId::new();
|
|
let checkpoint_record = TokenUsageRecord {
|
|
thread_id,
|
|
turn_id: "turn-1".to_string(),
|
|
session_id: SessionId::from(thread_id),
|
|
root_turn_id: "turn-1".to_string(),
|
|
response_id: "response-1".to_string(),
|
|
usage: TokenUsage::default(),
|
|
turn_token_usage: TokenUsage::default(),
|
|
thread_token_usage: TokenUsage::default(),
|
|
};
|
|
let checkpoint = |latest_token_usage_record| {
|
|
RolloutItem::Compacted(Box::new(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,
|
|
resume_metadata: None,
|
|
}))
|
|
};
|
|
|
|
assert_eq!(
|
|
Session::last_token_usage_record_from_rollout(&[
|
|
RolloutItem::TokenUsageRecord(checkpoint_record.clone()),
|
|
checkpoint(Some(checkpoint_record.clone())),
|
|
]),
|
|
Some(checkpoint_record.clone())
|
|
);
|
|
assert_eq!(
|
|
Session::last_token_usage_record_from_rollout(&[
|
|
RolloutItem::TokenUsageRecord(checkpoint_record),
|
|
checkpoint(None),
|
|
]),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn recompute_token_usage_uses_session_base_instructions() {
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
|
|
let override_instructions = "SESSION_OVERRIDE_INSTRUCTIONS_ONLY".repeat(120);
|
|
{
|
|
let mut state = session.state.lock().await;
|
|
state.session_configuration.base_instructions = override_instructions.clone();
|
|
}
|
|
|
|
let item = user_message("hello");
|
|
session
|
|
.record_conversation_items(
|
|
&turn_context,
|
|
turn_context.model_info(),
|
|
std::slice::from_ref(&item),
|
|
)
|
|
.await;
|
|
|
|
let history = session.clone_history().await;
|
|
let session_base_instructions = BaseInstructions {
|
|
text: override_instructions,
|
|
provenance: None,
|
|
};
|
|
let expected_tokens = history
|
|
.estimate_token_count_with_base_instructions(&session_base_instructions)
|
|
.expect("estimate with session base instructions");
|
|
let model_estimated_tokens = history
|
|
.estimate_token_count(&turn_context)
|
|
.expect("estimate with model instructions");
|
|
assert_ne!(expected_tokens, model_estimated_tokens);
|
|
|
|
session.recompute_token_usage(&turn_context).await;
|
|
|
|
let actual_tokens = session
|
|
.state
|
|
.lock()
|
|
.await
|
|
.token_info()
|
|
.expect("token info")
|
|
.last_token_usage
|
|
.total_tokens;
|
|
assert_eq!(actual_tokens, expected_tokens.max(0));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn recompute_token_usage_updates_model_context_window() {
|
|
let (session, mut turn_context) = make_session_and_context().await;
|
|
|
|
{
|
|
let mut state = session.state.lock().await;
|
|
state.set_token_info(Some(TokenUsageInfo {
|
|
total_token_usage: TokenUsage::default(),
|
|
last_token_usage: TokenUsage::default(),
|
|
model_context_window: Some(258_400),
|
|
}));
|
|
}
|
|
|
|
update_turn_settings_for_test(&mut turn_context, |settings| {
|
|
Arc::make_mut(&mut settings.model_info).context_window = Some(128_000);
|
|
Arc::make_mut(&mut settings.model_info).effective_context_window_percent = 100;
|
|
});
|
|
|
|
session.recompute_token_usage(&turn_context).await;
|
|
|
|
let actual = session.state.lock().await.token_info().expect("token info");
|
|
assert_eq!(actual.model_context_window, Some(128_000));
|
|
}
|
|
|
|
#[test_case(crate::session::TokenUsageContext::Sampling; "sampling")]
|
|
#[test_case(crate::session::TokenUsageContext::SpendingOnly; "spending_only")]
|
|
#[tokio::test]
|
|
async fn record_token_usage_info_notifies_extension_contributors(
|
|
context: crate::session::TokenUsageContext,
|
|
) {
|
|
struct SessionTokenUsageMarker;
|
|
struct ThreadTokenUsageMarker;
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
struct RecordedTokenUsage {
|
|
session_level_id: String,
|
|
thread_level_id: String,
|
|
turn_level_id: String,
|
|
token_usage: TokenUsageInfo,
|
|
saw_session_store: bool,
|
|
saw_thread_store: bool,
|
|
}
|
|
|
|
struct TokenUsageRecorder {
|
|
records: Arc<std::sync::Mutex<Vec<RecordedTokenUsage>>>,
|
|
}
|
|
|
|
impl codex_extension_api::TokenUsageContributor for TokenUsageRecorder {
|
|
fn on_token_usage<'a>(
|
|
&'a self,
|
|
session_store: &'a codex_extension_api::ExtensionData,
|
|
thread_store: &'a codex_extension_api::ExtensionData,
|
|
turn_store: &'a codex_extension_api::ExtensionData,
|
|
token_usage: &'a TokenUsageInfo,
|
|
) -> codex_extension_api::ExtensionFuture<'a, ()> {
|
|
Box::pin(async move {
|
|
self.records
|
|
.lock()
|
|
.expect("token usage records lock")
|
|
.push(RecordedTokenUsage {
|
|
session_level_id: session_store.level_id().to_string(),
|
|
thread_level_id: thread_store.level_id().to_string(),
|
|
turn_level_id: turn_store.level_id().to_string(),
|
|
token_usage: token_usage.clone(),
|
|
saw_session_store: session_store.get::<SessionTokenUsageMarker>().is_some(),
|
|
saw_thread_store: thread_store.get::<ThreadTokenUsageMarker>().is_some(),
|
|
});
|
|
})
|
|
}
|
|
}
|
|
|
|
let (mut session, turn_context) = make_session_and_context().await;
|
|
let records = Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new();
|
|
builder.token_usage_contributor(Arc::new(TokenUsageRecorder {
|
|
records: Arc::clone(&records),
|
|
}));
|
|
session.services.extensions = Arc::new(builder.build());
|
|
session
|
|
.services
|
|
.session_extension_data
|
|
.insert(SessionTokenUsageMarker);
|
|
session
|
|
.services
|
|
.thread_extension_data
|
|
.insert(ThreadTokenUsageMarker);
|
|
|
|
let first_usage = TokenUsage {
|
|
input_tokens: 10,
|
|
cached_input_tokens: 2,
|
|
cache_write_input_tokens: 0,
|
|
output_tokens: 20,
|
|
reasoning_output_tokens: 3,
|
|
total_tokens: 33,
|
|
codex_rollout_budget_units: None,
|
|
};
|
|
let second_usage = TokenUsage {
|
|
input_tokens: 7,
|
|
cached_input_tokens: 1,
|
|
cache_write_input_tokens: 0,
|
|
output_tokens: 8,
|
|
reasoning_output_tokens: 5,
|
|
total_tokens: 20,
|
|
codex_rollout_budget_units: None,
|
|
};
|
|
|
|
session
|
|
.record_token_usage_info(
|
|
&turn_context,
|
|
&turn_context.initial_settings,
|
|
Some(&first_usage),
|
|
crate::session::TokenUsageContext::Sampling,
|
|
)
|
|
.await
|
|
.expect("first usage should be recorded");
|
|
session
|
|
.record_token_usage_info(
|
|
&turn_context,
|
|
&turn_context.initial_settings,
|
|
Some(&second_usage),
|
|
context,
|
|
)
|
|
.await
|
|
.expect("second usage should be recorded");
|
|
|
|
let mut expected_total_usage = first_usage.clone();
|
|
expected_total_usage.add_assign(&second_usage);
|
|
let expected_session_info = TokenUsageInfo {
|
|
total_token_usage: expected_total_usage.clone(),
|
|
last_token_usage: match context {
|
|
crate::session::TokenUsageContext::Sampling => second_usage.clone(),
|
|
crate::session::TokenUsageContext::SpendingOnly => first_usage.clone(),
|
|
},
|
|
model_context_window: turn_context.model_context_window(),
|
|
};
|
|
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);
|
|
assert_eq!(
|
|
session.token_usage_info().await,
|
|
Some(expected_session_info)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn turn_start_lifecycle_exposes_turn_metadata_and_token_baseline() {
|
|
struct SessionTurnStartMarker;
|
|
struct ThreadTurnStartMarker;
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
struct RecordedTurnStart {
|
|
session_level_id: String,
|
|
thread_level_id: String,
|
|
turn_level_id: String,
|
|
turn_id: String,
|
|
collaboration_mode: CollaborationMode,
|
|
token_usage_at_turn_start: Option<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.cloned(),
|
|
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: Some(token_usage_at_turn_start),
|
|
saw_session_store: true,
|
|
saw_thread_store: true,
|
|
};
|
|
|
|
let sess = Arc::new(session);
|
|
sess.spawn_task(
|
|
Arc::new(turn_context),
|
|
Vec::new(),
|
|
NeverEndingTask {
|
|
kind: TaskKind::Regular,
|
|
listen_to_cancellation_token: true,
|
|
},
|
|
)
|
|
.await;
|
|
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
|
|
|
let actual = records
|
|
.lock()
|
|
.expect("turn start records lock")
|
|
.drain(..)
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(vec![expected], actual);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn turn_error_lifecycle_exposes_error_and_stores() {
|
|
use codex_protocol::error::CodexErrKind;
|
|
|
|
struct SessionTurnErrorMarker;
|
|
struct ThreadTurnErrorMarker;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct RecordedTurnError {
|
|
session_level_id: String,
|
|
thread_level_id: String,
|
|
turn_level_id: String,
|
|
turn_id: String,
|
|
error: CodexErrorInfo,
|
|
error_kind: CodexErrKind,
|
|
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,
|
|
error_kind: input.error_details.into(),
|
|
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,
|
|
error_kind: CodexErrKind::QuotaExceeded,
|
|
saw_session_store: true,
|
|
saw_thread_store: true,
|
|
};
|
|
|
|
session
|
|
.emit_turn_error_lifecycle(
|
|
&turn_context,
|
|
CodexErrorInfo::UsageLimitExceeded,
|
|
&CodexErrorDetails::QuotaExceeded,
|
|
)
|
|
.await;
|
|
|
|
let expected_override = RecordedTurnError {
|
|
error: CodexErrorInfo::BadRequest,
|
|
error_kind: CodexErrKind::InvalidImageRequest,
|
|
..expected.clone()
|
|
};
|
|
session
|
|
.emit_turn_error_lifecycle(
|
|
&turn_context,
|
|
CodexErrorInfo::BadRequest,
|
|
&CodexErrorDetails::InvalidImageRequest(),
|
|
)
|
|
.await;
|
|
|
|
let actual = records
|
|
.lock()
|
|
.expect("turn error records lock")
|
|
.drain(..)
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(vec![expected, expected_override], actual);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn config_change_contributor_observes_effective_config_changes() {
|
|
struct SessionConfigMarker;
|
|
struct ThreadConfigMarker;
|
|
|
|
#[derive(Debug, PartialEq)]
|
|
struct RecordedConfigChange {
|
|
previous_model: Option<String>,
|
|
new_model: Option<String>,
|
|
previous_disabled_tools: Vec<ToolSuggestDisabledTool>,
|
|
new_disabled_tools: Vec<ToolSuggestDisabledTool>,
|
|
saw_session_store: bool,
|
|
saw_thread_store: bool,
|
|
}
|
|
|
|
struct ConfigRecorder {
|
|
records: Arc<std::sync::Mutex<Vec<RecordedConfigChange>>>,
|
|
}
|
|
|
|
impl codex_extension_api::ConfigContributor<crate::config::Config> for ConfigRecorder {
|
|
fn on_config_changed(
|
|
&self,
|
|
session_store: &codex_extension_api::ExtensionData,
|
|
thread_store: &codex_extension_api::ExtensionData,
|
|
previous_config: &crate::config::Config,
|
|
new_config: &crate::config::Config,
|
|
) {
|
|
self.records
|
|
.lock()
|
|
.expect("config change records lock")
|
|
.push(RecordedConfigChange {
|
|
previous_model: previous_config.model.clone(),
|
|
new_model: new_config.model.clone(),
|
|
previous_disabled_tools: previous_config.tool_suggest.disabled_tools.clone(),
|
|
new_disabled_tools: new_config.tool_suggest.disabled_tools.clone(),
|
|
saw_session_store: session_store.get::<SessionConfigMarker>().is_some(),
|
|
saw_thread_store: thread_store.get::<ThreadConfigMarker>().is_some(),
|
|
});
|
|
}
|
|
}
|
|
|
|
let (mut session, _turn_context) = make_session_and_context().await;
|
|
let records = Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new();
|
|
builder.config_contributor(Arc::new(ConfigRecorder {
|
|
records: Arc::clone(&records),
|
|
}));
|
|
session.services.extensions = Arc::new(builder.build());
|
|
session
|
|
.services
|
|
.session_extension_data
|
|
.insert(SessionConfigMarker);
|
|
session
|
|
.services
|
|
.thread_extension_data
|
|
.insert(ThreadConfigMarker);
|
|
|
|
let original_model = session.collaboration_mode().await.model().to_string();
|
|
let original_disabled_tools = session
|
|
.get_config()
|
|
.await
|
|
.tool_suggest
|
|
.disabled_tools
|
|
.clone();
|
|
let next_model = if original_model == "gpt-5.4" {
|
|
"gpt-5.2"
|
|
} else {
|
|
"gpt-5.4"
|
|
};
|
|
let collaboration_mode = session.collaboration_mode().await.with_updates(
|
|
Some(next_model.to_string()),
|
|
/*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 current_config = session.get_config().await;
|
|
let next_config = load_latest_config_for_session(&session).await;
|
|
let _ = session
|
|
.refresh_runtime_config(current_config, next_config)
|
|
.await;
|
|
|
|
let expected_disabled_tools = vec![
|
|
ToolSuggestDisabledTool::connector("calendar"),
|
|
ToolSuggestDisabledTool::plugin("slack@openai-curated"),
|
|
];
|
|
let expected = vec![
|
|
RecordedConfigChange {
|
|
previous_model: Some(original_model),
|
|
new_model: Some(next_model.to_string()),
|
|
previous_disabled_tools: original_disabled_tools.clone(),
|
|
new_disabled_tools: original_disabled_tools.clone(),
|
|
saw_session_store: true,
|
|
saw_thread_store: true,
|
|
},
|
|
RecordedConfigChange {
|
|
previous_model: Some(next_model.to_string()),
|
|
new_model: Some(next_model.to_string()),
|
|
previous_disabled_tools: original_disabled_tools,
|
|
new_disabled_tools: expected_disabled_tools,
|
|
saw_session_store: true,
|
|
saw_thread_store: true,
|
|
},
|
|
];
|
|
let actual = records
|
|
.lock()
|
|
.expect("config change records lock")
|
|
.drain(..)
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(expected, actual);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_initial_history_reconstructs_forked_transcript() {
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
let (rollout_items, expected) = sample_rollout(&session, &turn_context).await;
|
|
|
|
session
|
|
.record_initial_history(InitialHistory::Forked(rollout_items))
|
|
.await;
|
|
|
|
let history = session.state.lock().await.clone_history();
|
|
assert_eq!(
|
|
strip_response_item_ids(&expected),
|
|
strip_response_item_ids(&raw_history_items(&history))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn start_new_context_window_persists_checkpoint_state() {
|
|
let (mut session, turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx(
|
|
CodexAuth::from_api_key("Test API Key"),
|
|
Vec::new(),
|
|
|_| {},
|
|
)
|
|
.await;
|
|
let rollout_path =
|
|
attach_thread_persistence(Arc::get_mut(&mut session).expect("unique session")).await;
|
|
let thread_id = ThreadId::new();
|
|
let token_usage_record = TokenUsageRecord {
|
|
thread_id,
|
|
turn_id: "turn-1".to_string(),
|
|
session_id: SessionId::from(thread_id),
|
|
root_turn_id: "turn-1".to_string(),
|
|
response_id: "response-1".to_string(),
|
|
usage: TokenUsage::default(),
|
|
turn_token_usage: TokenUsage::default(),
|
|
thread_token_usage: TokenUsage::default(),
|
|
};
|
|
session.state.lock().await.latest_token_usage_record = Some(token_usage_record.clone());
|
|
let step_context = session
|
|
.capture_step_context(Arc::clone(&turn_context), &CancellationToken::new())
|
|
.await
|
|
.expect("a fresh cancellation token cannot be cancelled");
|
|
let world_state = Arc::new(
|
|
session
|
|
.build_world_state_for_step(&step_context, /*new_window*/ true)
|
|
.await
|
|
.expect("world state should build"),
|
|
);
|
|
let expected_snapshot = world_state.render_full().0.into_object();
|
|
session
|
|
.start_new_context_window(&step_context, world_state)
|
|
.await;
|
|
|
|
let live_history = session.clone_history().await;
|
|
assert_eq!(
|
|
live_history.world_state_checkpoint().unwrap().state,
|
|
expected_snapshot
|
|
);
|
|
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_world_state = resumed
|
|
.history
|
|
.iter()
|
|
.rev()
|
|
.find_map(|item| match item {
|
|
RolloutItem::WorldState(world_state) => Some(world_state),
|
|
_ => None,
|
|
})
|
|
.expect("new window should persist a world state");
|
|
assert!(persisted_world_state.full);
|
|
assert_eq!(persisted_world_state.state, expected_snapshot);
|
|
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,
|
|
});
|
|
let mut expected_history = live_history.annotated_items().to_vec();
|
|
// Compaction's parallel metadata vector represents absent entries as default metadata.
|
|
for envelope in &mut expected_history {
|
|
envelope.metadata.get_or_insert_default();
|
|
}
|
|
assert_eq!(
|
|
persisted_compacted.and_then(|compacted| compacted.replacement_history.clone()),
|
|
Some(expected_history)
|
|
);
|
|
assert_eq!(
|
|
persisted_compacted.map(|compacted| {
|
|
(
|
|
compacted.compaction_response_id.as_deref(),
|
|
compacted.latest_token_usage_record.as_ref(),
|
|
)
|
|
}),
|
|
Some((None, Some(&token_usage_record)))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_initial_history_assigns_and_persists_id_for_forked_response_item() {
|
|
let (mut session, _turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx(
|
|
CodexAuth::from_api_key("Test API Key"),
|
|
Vec::new(),
|
|
|_| {},
|
|
)
|
|
.await;
|
|
let rollout_path =
|
|
attach_thread_persistence(Arc::get_mut(&mut session).expect("unique session")).await;
|
|
let response_item =
|
|
ContextualUserFragment::into(DeveloperInstructions::new("Subagent guidance."));
|
|
let mut expected_item = ResponseItem::Message {
|
|
status: None,
|
|
encrypted_content: None,
|
|
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(),
|
|
)]),
|
|
content_item_metadata: Some(vec![ContentItemMetadata::developer_instructions(
|
|
/*from_additional_requirements*/ false,
|
|
)]),
|
|
..Default::default()
|
|
}),
|
|
};
|
|
let response_item = ResponseItemEnvelope {
|
|
item: response_item,
|
|
metadata: Some(CodexHarnessMetadata::default()),
|
|
};
|
|
|
|
session
|
|
.record_initial_history(InitialHistory::Forked(vec![RolloutItem::ResponseItem(
|
|
response_item,
|
|
)]))
|
|
.await;
|
|
|
|
let live_history = session.clone_history().await;
|
|
let live_items = raw_history_items(&live_history);
|
|
let [live_item] = live_items.as_slice() else {
|
|
panic!("expected one forked response item");
|
|
};
|
|
let live_item_id = live_item
|
|
.id()
|
|
.expect("forked response item should have an id")
|
|
.to_string();
|
|
assert!(live_item_id.starts_with("msg_"));
|
|
expected_item.set_id(live_item.id().cloned());
|
|
assert_eq!(raw_history_items(&live_history), vec![expected_item]);
|
|
assert_eq!(
|
|
live_history.annotated_items()[0].metadata,
|
|
Some(CodexHarnessMetadata::default())
|
|
);
|
|
|
|
session.flush_rollout().await.expect("rollout should flush");
|
|
let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path)
|
|
.await
|
|
.expect("read rollout history")
|
|
else {
|
|
panic!("expected resumed rollout history");
|
|
};
|
|
let persisted_item = resumed.history.iter().find_map(|item| match item {
|
|
RolloutItem::ResponseItem(response_item) => Some(response_item),
|
|
RolloutItem::SessionMeta(_)
|
|
| RolloutItem::InterAgentCommunication(_)
|
|
| RolloutItem::InterAgentCommunicationMetadata { .. }
|
|
| RolloutItem::Compacted(_)
|
|
| RolloutItem::TurnContext(_)
|
|
| RolloutItem::WorldState(_)
|
|
| RolloutItem::RetainedContext(_)
|
|
| RolloutItem::SecurityRiskScore(_)
|
|
| RolloutItem::TokenUsageRecord(_)
|
|
| RolloutItem::RealtimeItem(_)
|
|
| RolloutItem::EventMsg(_) => None,
|
|
});
|
|
let persisted_item = persisted_item.expect("forked response item should be persisted");
|
|
assert_eq!(
|
|
persisted_item.id().map(ResponseItemId::as_str),
|
|
Some(live_item_id.as_str())
|
|
);
|
|
assert_eq!(
|
|
persisted_item.metadata,
|
|
Some(CodexHarnessMetadata::default())
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn session_configured_reports_permission_profile_for_external_sandbox() -> anyhow::Result<()>
|
|
{
|
|
let server = start_mock_server().await;
|
|
let sandbox_policy = SandboxPolicy::ExternalSandbox {
|
|
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
|
|
};
|
|
let permission_profile = PermissionProfile::External {
|
|
network: NetworkSandboxPolicy::Restricted,
|
|
};
|
|
let expected_permission_profile = permission_profile.clone();
|
|
let mut builder = test_codex().with_config(move |config| {
|
|
config
|
|
.permissions
|
|
.set_permission_profile(permission_profile.clone())
|
|
.expect("set permission profile");
|
|
config
|
|
.set_legacy_sandbox_policy(sandbox_policy)
|
|
.expect("set sandbox policy");
|
|
});
|
|
|
|
let test = builder.build(&server).await?;
|
|
|
|
assert_eq!(
|
|
test.session_configured.permission_profile, expected_permission_profile,
|
|
"ExternalSandbox is represented explicitly instead of as a lossy root-write profile"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<()> {
|
|
let server = start_mock_server().await;
|
|
mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
|
)
|
|
.await;
|
|
let first_forked_request = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex().with_config(|config| {
|
|
config.update_plan_enabled = true;
|
|
config.permissions.approval_policy =
|
|
codex_config::Constrained::allow_any(AskForApproval::OnRequest);
|
|
});
|
|
let initial = builder.build(&server).await?;
|
|
let rollout_path = initial
|
|
.session_configured
|
|
.rollout_path
|
|
.clone()
|
|
.expect("rollout path");
|
|
|
|
initial
|
|
.codex
|
|
.start_or_steer_turn(ExternalTurnInputRequest::user_input(vec![
|
|
UserInput::Text {
|
|
text: "fork seed".into(),
|
|
text_elements: Vec::new(),
|
|
},
|
|
]))
|
|
.await?;
|
|
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
|
// 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_legacy_thread(
|
|
usize::MAX,
|
|
core_test_support::test_codex::StartThreadOptions::new(fork_config.clone()),
|
|
rollout_path,
|
|
)
|
|
.await?;
|
|
|
|
let collaboration_mode = CollaborationMode {
|
|
mode: ModeKind::Plan,
|
|
settings: Settings {
|
|
model: forked.session_configured.model.clone(),
|
|
reasoning_effort: None,
|
|
developer_instructions: Some("Fork turn collaboration instructions.".to_string()),
|
|
},
|
|
};
|
|
forked
|
|
.thread
|
|
.start_or_steer_turn(
|
|
ExternalTurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "after fork".into(),
|
|
text_elements: Vec::new(),
|
|
}])
|
|
.with_thread_settings(ThreadSettingsOverrides {
|
|
approval_policy: Some(AskForApproval::Never),
|
|
collaboration_mode: Some(collaboration_mode),
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await?;
|
|
wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
|
|
|
let request = first_forked_request.single_request();
|
|
let snapshot = context_snapshot::format_labeled_requests_snapshot(
|
|
"First request after fork when startup preserves the parent baseline, the fork changes approval policy, and the first forked turn enters plan mode.",
|
|
&[("First Forked Turn Request", &request)],
|
|
&ContextSnapshotOptions::default().rewrite_known_segments(),
|
|
);
|
|
|
|
let mut settings = insta::Settings::clone_current();
|
|
settings.set_snapshot_path("snapshots");
|
|
settings.set_prepend_module_to_snapshot(false);
|
|
settings.bind(|| {
|
|
insta::assert_snapshot!(
|
|
"codex_core__codex_tests__fork_startup_context_then_first_turn_diff",
|
|
snapshot
|
|
);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_initial_history_forked_hydrates_previous_turn_settings() {
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
let previous_model = "forked-rollout-model";
|
|
let previous_context_item = TurnContextItem {
|
|
turn_id: Some(turn_context.sub_id.clone()),
|
|
root_turn_id: None,
|
|
disabled_plugin_ids: None,
|
|
#[allow(deprecated)]
|
|
cwd: turn_context.cwd.clone(),
|
|
approval_policy: turn_context.approval_policy(),
|
|
approvals_reviewer: None,
|
|
sandbox_policy: turn_context.sandbox_policy(),
|
|
permission_profile: None,
|
|
active_permission_profile: None,
|
|
file_system_sandbox_policy: None,
|
|
model: previous_model.to_string(),
|
|
comp_hash: None,
|
|
collaboration_mode: Some(turn_context.collaboration_mode()),
|
|
multi_agent_version: None,
|
|
realtime_active: Some(turn_context.realtime_active),
|
|
cyber_access_program: None,
|
|
effort: turn_context.reasoning_effort().cloned(),
|
|
summary: Some(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_attribution: None,
|
|
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 {
|
|
root_turn_id: None,
|
|
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,
|
|
cyber_access_program: None,
|
|
realtime_active: Some(turn_context.realtime_active),
|
|
})
|
|
);
|
|
assert_eq!(raw_history_items(&history), Vec::<ResponseItem>::new());
|
|
assert_eq!(
|
|
serde_json::to_value(session.reference_context_item().await)
|
|
.expect("serialize fork reference context item"),
|
|
serde_json::to_value(Some(previous_context_item))
|
|
.expect("serialize expected reference context item")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn set_rate_limits_retains_previous_credits() {
|
|
let codex_home = tempfile::tempdir().expect("create temp dir");
|
|
let config = build_test_config(codex_home.path()).await;
|
|
let config = Arc::new(config);
|
|
let model = get_model_offline_for_tests(config.model.as_deref());
|
|
let model_info =
|
|
construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config());
|
|
let reasoning_effort = config.model_reasoning_effort.clone();
|
|
let collaboration_mode = CollaborationMode {
|
|
mode: ModeKind::Default,
|
|
settings: Settings {
|
|
model,
|
|
reasoning_effort,
|
|
developer_instructions: None,
|
|
},
|
|
};
|
|
let session_configuration = SessionConfiguration {
|
|
turn_extension_init: Default::default(),
|
|
provider: create_model_provider(config.model_provider.clone(), /*auth_manager*/ None),
|
|
environments: Vec::new(),
|
|
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(|| render_model_instructions(&model_info)),
|
|
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,
|
|
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: Arc::default(),
|
|
user_shell_override: None,
|
|
};
|
|
|
|
let mut state = SessionState::new(session_configuration);
|
|
let initial = RateLimitSnapshot {
|
|
limit_id: None,
|
|
limit_name: None,
|
|
normal_model_slug: None,
|
|
primary: Some(RateLimitWindow {
|
|
used_percent: 10.0,
|
|
window_minutes: Some(15),
|
|
resets_at: Some(1_700),
|
|
}),
|
|
secondary: None,
|
|
credits: Some(CreditsSnapshot {
|
|
has_credits: true,
|
|
unlimited: false,
|
|
balance: Some("10.00".to_string()),
|
|
}),
|
|
individual_limit: None,
|
|
spend_control_reached: None,
|
|
plan_type: Some(codex_protocol::account::PlanType::Plus),
|
|
rate_limit_reached_type: None,
|
|
};
|
|
state.set_rate_limits(initial.clone());
|
|
|
|
let update = RateLimitSnapshot {
|
|
limit_id: Some("codex_other".to_string()),
|
|
limit_name: Some("codex_other".to_string()),
|
|
normal_model_slug: None,
|
|
primary: Some(RateLimitWindow {
|
|
used_percent: 40.0,
|
|
window_minutes: Some(30),
|
|
resets_at: Some(1_800),
|
|
}),
|
|
secondary: Some(RateLimitWindow {
|
|
used_percent: 5.0,
|
|
window_minutes: Some(60),
|
|
resets_at: Some(1_900),
|
|
}),
|
|
credits: None,
|
|
individual_limit: None,
|
|
spend_control_reached: None,
|
|
plan_type: None,
|
|
rate_limit_reached_type: None,
|
|
};
|
|
state.set_rate_limits(update.clone());
|
|
|
|
assert_eq!(
|
|
state.latest_rate_limits,
|
|
Some(RateLimitSnapshot {
|
|
limit_id: Some("codex_other".to_string()),
|
|
limit_name: Some("codex_other".to_string()),
|
|
normal_model_slug: None,
|
|
primary: update.primary.clone(),
|
|
secondary: update.secondary,
|
|
credits: initial.credits,
|
|
individual_limit: initial.individual_limit,
|
|
spend_control_reached: initial.spend_control_reached,
|
|
plan_type: initial.plan_type,
|
|
rate_limit_reached_type: None,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn set_rate_limits_updates_plan_type_when_present() {
|
|
let codex_home = tempfile::tempdir().expect("create temp dir");
|
|
let config = build_test_config(codex_home.path()).await;
|
|
let config = Arc::new(config);
|
|
let model = get_model_offline_for_tests(config.model.as_deref());
|
|
let model_info =
|
|
construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config());
|
|
let reasoning_effort = config.model_reasoning_effort.clone();
|
|
let collaboration_mode = CollaborationMode {
|
|
mode: ModeKind::Default,
|
|
settings: Settings {
|
|
model,
|
|
reasoning_effort,
|
|
developer_instructions: None,
|
|
},
|
|
};
|
|
let session_configuration = SessionConfiguration {
|
|
turn_extension_init: Default::default(),
|
|
provider: create_model_provider(config.model_provider.clone(), /*auth_manager*/ None),
|
|
environments: Vec::new(),
|
|
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(|| render_model_instructions(&model_info)),
|
|
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,
|
|
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: Arc::default(),
|
|
user_shell_override: None,
|
|
};
|
|
|
|
let mut state = SessionState::new(session_configuration);
|
|
let initial = RateLimitSnapshot {
|
|
limit_id: None,
|
|
limit_name: None,
|
|
normal_model_slug: None,
|
|
primary: Some(RateLimitWindow {
|
|
used_percent: 15.0,
|
|
window_minutes: Some(20),
|
|
resets_at: Some(1_600),
|
|
}),
|
|
secondary: Some(RateLimitWindow {
|
|
used_percent: 5.0,
|
|
window_minutes: Some(45),
|
|
resets_at: Some(1_650),
|
|
}),
|
|
credits: Some(CreditsSnapshot {
|
|
has_credits: true,
|
|
unlimited: false,
|
|
balance: Some("15.00".to_string()),
|
|
}),
|
|
individual_limit: None,
|
|
spend_control_reached: None,
|
|
plan_type: Some(codex_protocol::account::PlanType::Plus),
|
|
rate_limit_reached_type: None,
|
|
};
|
|
state.set_rate_limits(initial.clone());
|
|
|
|
let update = RateLimitSnapshot {
|
|
limit_id: None,
|
|
limit_name: None,
|
|
normal_model_slug: None,
|
|
primary: Some(RateLimitWindow {
|
|
used_percent: 35.0,
|
|
window_minutes: Some(25),
|
|
resets_at: Some(1_700),
|
|
}),
|
|
secondary: None,
|
|
credits: None,
|
|
individual_limit: None,
|
|
spend_control_reached: None,
|
|
plan_type: Some(codex_protocol::account::PlanType::Pro),
|
|
rate_limit_reached_type: None,
|
|
};
|
|
state.set_rate_limits(update.clone());
|
|
|
|
assert_eq!(
|
|
state.latest_rate_limits,
|
|
Some(RateLimitSnapshot {
|
|
limit_id: Some("codex".to_string()),
|
|
limit_name: None,
|
|
normal_model_slug: None,
|
|
primary: update.primary,
|
|
secondary: update.secondary,
|
|
credits: initial.credits,
|
|
individual_limit: initial.individual_limit,
|
|
spend_control_reached: initial.spend_control_reached,
|
|
plan_type: update.plan_type,
|
|
rate_limit_reached_type: None,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn prefers_structured_content_when_present() {
|
|
let ctr = McpCallToolResult {
|
|
// Content 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);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn includes_timed_out_message() {
|
|
let exec = ExecToolCallOutput {
|
|
exit_code: 0,
|
|
stdout: StreamOutput::new(String::new()),
|
|
stderr: StreamOutput::new(String::new()),
|
|
aggregated_output: StreamOutput::new("Command output".to_string()),
|
|
duration: StdDuration::from_secs(1),
|
|
timed_out: true,
|
|
};
|
|
let (_, turn_context) = make_session_and_context().await;
|
|
|
|
let out = format_exec_output_str(&exec, turn_context.model_info().truncation_policy.into());
|
|
|
|
assert_eq!(
|
|
out,
|
|
"command timed out after 1000 milliseconds\nCommand output"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn turn_context_with_model_updates_model_fields() {
|
|
let (session, mut turn_context) = make_session_and_context().await;
|
|
let config = Arc::make_mut(&mut turn_context.config);
|
|
config.features.enable(Feature::FastMode).unwrap();
|
|
config.model_reasoning_effort = Some(ReasoningEffortConfig::Minimal);
|
|
config.model_reasoning_summary = Some(ReasoningSummaryConfig::Detailed);
|
|
update_turn_settings_for_test(&mut turn_context, |settings| {
|
|
let mut selected = settings.selected().clone();
|
|
selected.collaboration_mode.settings.reasoning_effort =
|
|
Some(ReasoningEffortConfig::Minimal);
|
|
selected.reasoning_summary = Some(ReasoningSummaryConfig::Detailed);
|
|
selected.service_tier = Some(ServiceTier::Fast.request_value().to_string());
|
|
*settings = ResolvedStepSettings::new(
|
|
Arc::new(selected),
|
|
Arc::clone(&settings.model_info),
|
|
&codex_features::Features::with_defaults(),
|
|
);
|
|
});
|
|
Arc::make_mut(&mut turn_context.config).service_tier =
|
|
turn_context.initial_settings.service_tier.clone();
|
|
let captured = turn_context.next_step_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()),
|
|
&codex_features::Features::with_defaults(),
|
|
));
|
|
turn_context.next_step_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.next_step_settings.load_full()
|
|
));
|
|
assert!(!Arc::ptr_eq(&captured, &updated.initial_settings));
|
|
assert!(Arc::ptr_eq(
|
|
&updated.initial_settings,
|
|
&updated.next_step_settings.load_full()
|
|
));
|
|
assert!(!Arc::ptr_eq(
|
|
&updated.next_step_settings.load_full(),
|
|
&turn_context.next_step_settings.load_full()
|
|
));
|
|
assert_eq!(updated.config.model.as_deref(), Some("gpt-5.5"));
|
|
assert_eq!(updated.collaboration_mode().model(), "gpt-5.5");
|
|
assert_eq!(updated.model_info().as_ref(), &expected_model_info);
|
|
assert_eq!(
|
|
updated.reasoning_effort(),
|
|
Some(&ReasoningEffortConfig::Medium)
|
|
);
|
|
assert_eq!(
|
|
updated.collaboration_mode().reasoning_effort(),
|
|
Some(ReasoningEffortConfig::Medium)
|
|
);
|
|
assert_eq!(
|
|
updated.config.model_reasoning_effort,
|
|
Some(ReasoningEffortConfig::Medium)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn falls_back_to_content_when_structured_is_null() {
|
|
let ctr = McpCallToolResult {
|
|
content: vec![text_block("hello"), text_block("world")],
|
|
is_error: None,
|
|
structured_content: Some(serde_json::Value::Null),
|
|
meta: None,
|
|
};
|
|
|
|
let got = ctr.into_function_call_output_payload();
|
|
let expected = FunctionCallOutputPayload {
|
|
body: FunctionCallOutputBody::ContentItems(vec![
|
|
FunctionCallOutputContentItem::InputText {
|
|
text: "hello".to_string(),
|
|
},
|
|
FunctionCallOutputContentItem::InputText {
|
|
text: "world".to_string(),
|
|
},
|
|
]),
|
|
success: Some(true),
|
|
};
|
|
|
|
assert_eq!(expected, got);
|
|
}
|
|
|
|
#[test]
|
|
fn success_flag_reflects_is_error_true() {
|
|
let ctr = McpCallToolResult {
|
|
content: vec![text_block("unused")],
|
|
is_error: Some(true),
|
|
structured_content: Some(json!({ "message": "bad" })),
|
|
meta: None,
|
|
};
|
|
|
|
let got = ctr.into_function_call_output_payload();
|
|
let expected = FunctionCallOutputPayload {
|
|
body: FunctionCallOutputBody::Text(
|
|
serde_json::to_string(&json!({ "message": "bad" })).unwrap(),
|
|
),
|
|
success: Some(false),
|
|
};
|
|
|
|
assert_eq!(expected, got);
|
|
}
|
|
|
|
#[test]
|
|
fn success_flag_true_with_no_error_and_content_used() {
|
|
let ctr = McpCallToolResult {
|
|
content: vec![text_block("alpha")],
|
|
is_error: Some(false),
|
|
structured_content: None,
|
|
meta: None,
|
|
};
|
|
|
|
let got = ctr.into_function_call_output_payload();
|
|
let expected = FunctionCallOutputPayload {
|
|
body: FunctionCallOutputBody::ContentItems(vec![
|
|
FunctionCallOutputContentItem::InputText {
|
|
text: "alpha".to_string(),
|
|
},
|
|
]),
|
|
success: Some(true),
|
|
};
|
|
|
|
assert_eq!(expected, got);
|
|
}
|
|
|
|
async fn open_thread_persistence(session: &mut Session) -> PathBuf {
|
|
let config = session.get_config().await;
|
|
let live_thread = LiveThread::create(
|
|
Arc::clone(&session.services.thread_store),
|
|
CreateThreadParams {
|
|
attached_thread: false,
|
|
creator_user_id: None,
|
|
creator_account_id: None,
|
|
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
|
|
}
|
|
|
|
#[test]
|
|
fn get_service_tier_does_not_use_model_default_when_absent_and_fast_mode_enabled() {
|
|
let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value()));
|
|
|
|
assert_eq!(
|
|
get_service_tier(
|
|
/*configured_service_tier*/ None,
|
|
&codex_features::Features::with_defaults(),
|
|
&model_info,
|
|
),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn get_service_tier_does_not_use_model_default_when_fast_mode_disabled() {
|
|
let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value()));
|
|
|
|
assert_eq!(
|
|
get_service_tier(
|
|
/*configured_service_tier*/ None,
|
|
&codex_features::Features::default(),
|
|
&model_info,
|
|
),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn get_service_tier_keeps_supported_explicit_tier() {
|
|
let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value()));
|
|
|
|
assert_eq!(
|
|
get_service_tier(
|
|
Some(ServiceTier::Fast.request_value().to_string()),
|
|
&codex_features::Features::with_defaults(),
|
|
&model_info,
|
|
),
|
|
Some(ServiceTier::Fast.request_value().to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn get_service_tier_does_not_default_when_model_has_no_default() {
|
|
let model_info = model_with_default_service_tier(/*default_service_tier*/ None);
|
|
|
|
assert_eq!(
|
|
get_service_tier(
|
|
/*configured_service_tier*/ None,
|
|
&codex_features::Features::with_defaults(),
|
|
&model_info,
|
|
),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn get_service_tier_drops_unsupported_configured_tier_when_fast_mode_enabled() {
|
|
let model_info = model_with_default_service_tier(Some(ServiceTier::Fast.request_value()));
|
|
|
|
assert_eq!(
|
|
get_service_tier(
|
|
Some("unsupported".to_string()),
|
|
&codex_features::Features::with_defaults(),
|
|
&model_info,
|
|
),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
get_service_tier(
|
|
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()),
|
|
&codex_features::Features::with_defaults(),
|
|
&model_info,
|
|
),
|
|
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn get_service_tier_preserves_flex_without_catalog_support_or_fast_mode() {
|
|
let model_info = model_with_default_service_tier(/*default_service_tier*/ None);
|
|
for fast_mode_enabled in [false, true] {
|
|
let mut features = codex_features::Features::with_defaults();
|
|
features.set_enabled(Feature::FastMode, fast_mode_enabled);
|
|
assert_eq!(
|
|
get_service_tier(
|
|
Some(ServiceTier::Flex.request_value().to_string()),
|
|
&features,
|
|
&model_info,
|
|
),
|
|
Some(ServiceTier::Flex.request_value().to_string())
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn get_service_tier_ignores_non_flex_tiers_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()),
|
|
&codex_features::Features::default(),
|
|
&model_info,
|
|
),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
get_service_tier(
|
|
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()),
|
|
&codex_features::Features::default(),
|
|
&model_info,
|
|
),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
get_service_tier(
|
|
Some("unsupported".to_string()),
|
|
&codex_features::Features::default(),
|
|
&model_info,
|
|
),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
get_service_tier(
|
|
/*configured_service_tier*/ None,
|
|
&codex_features::Features::default(),
|
|
&model_info,
|
|
),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_settings_null_service_tier_update_uses_default_service_tier() {
|
|
let session_configuration = make_session_configuration_for_tests().await;
|
|
|
|
let updated = session_configuration
|
|
.apply(
|
|
&SessionSettingsUpdate {
|
|
step_settings: StepSettingsUpdate {
|
|
service_tier: Some(None),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
},
|
|
&[],
|
|
)
|
|
.expect("null service tier update should apply");
|
|
|
|
assert_eq!(
|
|
updated.step_settings.service_tier,
|
|
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_settings_legacy_fast_service_tier_update_uses_priority_request_value() {
|
|
let session_configuration = make_session_configuration_for_tests().await;
|
|
|
|
let updated = session_configuration
|
|
.apply(
|
|
&SessionSettingsUpdate {
|
|
step_settings: StepSettingsUpdate {
|
|
service_tier: Some(Some("fast".to_string())),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
},
|
|
&[],
|
|
)
|
|
.expect("legacy fast service tier update should apply");
|
|
|
|
assert_eq!(
|
|
updated.step_settings.service_tier,
|
|
Some(ServiceTier::Fast.request_value().to_string())
|
|
);
|
|
}
|
|
|
|
pub(crate) async fn make_session_configuration_for_tests() -> SessionConfiguration {
|
|
let codex_home = tempfile::tempdir().expect("create temp dir");
|
|
let config = build_test_config(codex_home.path()).await;
|
|
let config = Arc::new(config);
|
|
let model = get_model_offline_for_tests(config.model.as_deref());
|
|
let model_info =
|
|
construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config());
|
|
let reasoning_effort = config.model_reasoning_effort.clone();
|
|
let collaboration_mode = CollaborationMode {
|
|
mode: ModeKind::Default,
|
|
settings: Settings {
|
|
model,
|
|
reasoning_effort,
|
|
developer_instructions: None,
|
|
},
|
|
};
|
|
|
|
SessionConfiguration {
|
|
turn_extension_init: Default::default(),
|
|
provider: create_model_provider(config.model_provider.clone(), /*auth_manager*/ None),
|
|
environments: Vec::new(),
|
|
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(|| render_model_instructions(&model_info)),
|
|
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,
|
|
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: Arc::default(),
|
|
user_shell_override: None,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn emit_subagent_session_started_includes_fork_lineage_and_originator() {
|
|
use codex_app_server_protocol::ServerNotification;
|
|
use codex_app_server_protocol::ThreadArchivedNotification;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/codex/analytics-events/events"))
|
|
.respond_with(ResponseTemplate::new(200))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let analytics_events_client = AnalyticsEventsClient::new(
|
|
auth_manager,
|
|
server.uri(),
|
|
/*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()),
|
|
/*resumed_created_at*/ None,
|
|
);
|
|
|
|
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"]["initialization_mode"], "new");
|
|
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()),
|
|
/*resumed_created_at*/ None,
|
|
);
|
|
// 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(),
|
|
ThreadEnvironmentDefaults::new(
|
|
session_configuration.inferred_environment_config(),
|
|
session_configuration.windows_sandbox_type,
|
|
),
|
|
ShellSnapshot::disabled(),
|
|
TurnEnvironmentSnapshot::default(),
|
|
/*non_blocking_snapshots*/ false,
|
|
);
|
|
turn_environments.update_selections(environment_selections);
|
|
(environment_manager, turn_environments.snapshot().await)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_configuration_apply_client_metadata_preserves_permissions() {
|
|
let mut configuration = make_session_configuration_for_tests().await;
|
|
let workspace = tempfile::tempdir().expect("create workspace");
|
|
let cwd = workspace.path().abs();
|
|
configuration.legacy_fallback_cwd = cwd.clone();
|
|
let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement(
|
|
SandboxEnforcement::Managed,
|
|
&FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry::new(
|
|
FileSystemPath::Path {
|
|
path: cwd.join("writable").into(),
|
|
},
|
|
FileSystemAccessMode::Write,
|
|
),
|
|
FileSystemSandboxEntry::new(
|
|
FileSystemPath::Path {
|
|
path: cwd.join("writable/private").into(),
|
|
},
|
|
FileSystemAccessMode::Deny,
|
|
),
|
|
]),
|
|
NetworkSandboxPolicy::Restricted,
|
|
);
|
|
configuration
|
|
.set_permission_profile_for_tests(permission_profile)
|
|
.expect("set custom permission profile");
|
|
let expected = configuration.thread_settings_snapshot(&[]);
|
|
let updated = configuration
|
|
.apply(
|
|
&SessionSettingsUpdate {
|
|
app_server_client_name: Some("codex-tui".to_string()),
|
|
app_server_client_version: Some("1.0.0".to_string()),
|
|
..Default::default()
|
|
},
|
|
&[],
|
|
)
|
|
.expect("update client metadata");
|
|
|
|
assert_eq!(updated.thread_settings_snapshot(&[]), expected);
|
|
assert_eq!(
|
|
(
|
|
updated.app_server_client_name,
|
|
updated.app_server_client_version
|
|
),
|
|
(Some("codex-tui".to_string()), Some("1.0.0".to_string())),
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd_only_update() {
|
|
let mut session_configuration = make_session_configuration_for_tests().await;
|
|
let workspace = tempfile::tempdir().expect("create temp dir");
|
|
let project_root = workspace.path().join("project");
|
|
let original_cwd = project_root.join("subdir");
|
|
let docs_dir = original_cwd.join("docs");
|
|
std::fs::create_dir_all(&docs_dir).expect("create docs dir");
|
|
let project_root = project_root.abs();
|
|
let docs_dir = docs_dir.abs();
|
|
|
|
session_configuration.legacy_fallback_cwd = original_cwd.abs();
|
|
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: Vec::new(),
|
|
network_access: false,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
};
|
|
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*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
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_configuration_apply_permission_profile_preserves_existing_deny_read_entries() {
|
|
let mut session_configuration = make_session_configuration_for_tests().await;
|
|
let cwd = tempfile::tempdir().expect("create temp dir");
|
|
session_configuration.legacy_fallback_cwd = cwd.path().abs();
|
|
|
|
let workspace_policy = SandboxPolicy::new_workspace_write_policy();
|
|
let deny_entry = FileSystemSandboxEntry {
|
|
path: FileSystemPath::GlobPattern {
|
|
pattern: "**/*.env".to_string(),
|
|
},
|
|
access: FileSystemAccessMode::Deny,
|
|
missing_path_behavior: None,
|
|
};
|
|
let mut existing_file_system_policy =
|
|
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(
|
|
&workspace_policy,
|
|
session_configuration.cwd().as_path(),
|
|
);
|
|
existing_file_system_policy.glob_scan_max_depth = Some(2);
|
|
existing_file_system_policy.entries.push(deny_entry.clone());
|
|
session_configuration
|
|
.set_permission_profile_for_tests(
|
|
PermissionProfile::from_runtime_permissions_with_enforcement(
|
|
SandboxEnforcement::from_legacy_sandbox_policy(&workspace_policy),
|
|
&existing_file_system_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
),
|
|
)
|
|
.expect("set permission profile");
|
|
|
|
let requested_file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(
|
|
&workspace_policy,
|
|
session_configuration.cwd().as_path(),
|
|
);
|
|
let permission_profile = codex_protocol::models::PermissionProfile::from_runtime_permissions(
|
|
&requested_file_system_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
);
|
|
let updated = session_configuration
|
|
.apply(
|
|
&SessionSettingsUpdate {
|
|
permission_profile: Some(permission_profile),
|
|
..Default::default()
|
|
},
|
|
&[],
|
|
)
|
|
.expect("permission profile update should succeed");
|
|
|
|
let mut expected_file_system_policy =
|
|
requested_file_system_policy.materialize_project_roots_with_workspace_roots(&[]);
|
|
expected_file_system_policy.glob_scan_max_depth = Some(2);
|
|
expected_file_system_policy.entries.push(deny_entry);
|
|
assert_eq!(
|
|
updated.file_system_sandbox_policy(&[]),
|
|
expected_file_system_policy
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_configuration_apply_permission_profile_accepts_direct_write_roots() {
|
|
let mut session_configuration = make_session_configuration_for_tests().await;
|
|
let cwd = tempfile::tempdir().expect("create cwd");
|
|
session_configuration.legacy_fallback_cwd = cwd.path().abs();
|
|
let external_write_dir = tempfile::tempdir().expect("create external write root");
|
|
let external_write_path = AbsolutePathBuf::from_absolute_path(
|
|
codex_utils_absolute_path::canonicalize_preserving_symlinks(external_write_dir.path())
|
|
.expect("canonical temp dir"),
|
|
)
|
|
.expect("canonical temp dir should be absolute");
|
|
let file_system_sandbox_policy =
|
|
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path {
|
|
path: external_write_path.clone().into(),
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
missing_path_behavior: None,
|
|
}]);
|
|
let permission_profile = PermissionProfile::from_runtime_permissions(
|
|
&file_system_sandbox_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
);
|
|
|
|
let updated = session_configuration
|
|
.apply(
|
|
&SessionSettingsUpdate {
|
|
permission_profile: Some(permission_profile.clone()),
|
|
..Default::default()
|
|
},
|
|
&[],
|
|
)
|
|
.expect("permission profile update should accept direct runtime permissions");
|
|
|
|
assert_eq!(updated.permission_profile(), permission_profile);
|
|
assert_eq!(
|
|
updated.file_system_sandbox_policy(&[]),
|
|
file_system_sandbox_policy
|
|
);
|
|
assert_eq!(
|
|
updated.sandbox_policy(&[]),
|
|
SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: vec![external_write_path],
|
|
network_access: false,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test_case::test_case(false; "ordinary_proxy")]
|
|
#[test_case::test_case(true; "credential_broker")]
|
|
#[tokio::test]
|
|
async fn active_profile_update_rebuilds_network_proxy_config(
|
|
credential_broker: bool,
|
|
) -> std::io::Result<()> {
|
|
let codex_home = tempfile::tempdir().expect("create codex home");
|
|
let cwd = tempfile::tempdir().expect("create cwd");
|
|
let permissions = PermissionsToml {
|
|
entries: std::collections::BTreeMap::from([
|
|
(
|
|
"locked-down".to_string(),
|
|
PermissionProfileToml {
|
|
description: None,
|
|
extends: None,
|
|
workspace_roots: None,
|
|
filesystem: Some(FilesystemPermissionsToml {
|
|
glob_scan_max_depth: None,
|
|
entries: std::collections::BTreeMap::from([(
|
|
":minimal".to_string(),
|
|
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
|
|
)]),
|
|
}),
|
|
network: None,
|
|
},
|
|
),
|
|
(
|
|
"web-enabled".to_string(),
|
|
PermissionProfileToml {
|
|
description: None,
|
|
extends: None,
|
|
workspace_roots: None,
|
|
filesystem: Some(FilesystemPermissionsToml {
|
|
glob_scan_max_depth: None,
|
|
entries: std::collections::BTreeMap::from([(
|
|
":minimal".to_string(),
|
|
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
|
|
)]),
|
|
}),
|
|
network: Some(NetworkToml {
|
|
enabled: Some(true),
|
|
proxy_url: Some("http://127.0.0.1:43128".to_string()),
|
|
enable_socks5: Some(false),
|
|
..Default::default()
|
|
}),
|
|
},
|
|
),
|
|
]),
|
|
};
|
|
let base_config = ConfigToml {
|
|
features: Some(
|
|
toml::from_str(if credential_broker {
|
|
"network_proxy = { enabled = true, credential_broker = true }"
|
|
} else {
|
|
"network_proxy = true"
|
|
})
|
|
.expect("valid features"),
|
|
),
|
|
default_permissions: Some("locked-down".to_string()),
|
|
permissions: Some(permissions),
|
|
..Default::default()
|
|
};
|
|
std::fs::write(
|
|
codex_home.path().join(codex_config::CONFIG_TOML_FILE),
|
|
toml::to_string(&base_config).expect("serialize config"),
|
|
)?;
|
|
let locked_config = Arc::new(
|
|
ConfigBuilder::default()
|
|
.codex_home(codex_home.path().to_path_buf())
|
|
.harness_overrides(ConfigOverrides {
|
|
cwd: Some(cwd.path().to_path_buf()),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.await?,
|
|
);
|
|
assert_ne!(
|
|
locked_config
|
|
.permissions
|
|
.network
|
|
.as_ref()
|
|
.map(crate::config::NetworkProxySpec::proxy_host_and_port)
|
|
.as_deref(),
|
|
Some("127.0.0.1:43128")
|
|
);
|
|
let selected_config = ConfigBuilder::default()
|
|
.codex_home(codex_home.path().to_path_buf())
|
|
.harness_overrides(ConfigOverrides {
|
|
cwd: Some(cwd.path().to_path_buf()),
|
|
default_permissions: Some("web-enabled".to_string()),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.await?;
|
|
|
|
let mut session_configuration = make_session_configuration_for_tests().await;
|
|
session_configuration.permission_profile_state =
|
|
locked_config.permissions.permission_profile_state().clone();
|
|
session_configuration.original_config_do_not_use = Arc::clone(&locked_config);
|
|
|
|
let updated = session_configuration
|
|
.apply(
|
|
&SessionSettingsUpdate {
|
|
permission_profile: Some(selected_config.permissions.permission_profile().clone()),
|
|
active_permission_profile: selected_config.permissions.active_permission_profile(),
|
|
..Default::default()
|
|
},
|
|
&[],
|
|
)
|
|
.expect("active profile update should apply");
|
|
|
|
let network = updated
|
|
.original_config_do_not_use
|
|
.permissions
|
|
.network
|
|
.as_ref()
|
|
.expect("selected profile proxy should become the session proxy config");
|
|
assert_eq!(network.proxy_host_and_port(), "127.0.0.1:43128");
|
|
assert!(!network.socks_enabled());
|
|
assert_eq!(network.credential_broker_enabled(), credential_broker);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg_attr(windows, ignore)]
|
|
#[tokio::test]
|
|
async fn new_default_turn_uses_config_aware_skills_for_role_overrides() {
|
|
let (session, _turn_context) = make_session_and_context().await;
|
|
let parent_config = session.get_config().await;
|
|
let codex_home = parent_config.codex_home.clone();
|
|
let skill_dir = codex_home.join("skills").join("demo");
|
|
std::fs::create_dir_all(&skill_dir).expect("create skill dir");
|
|
let skill_path = skill_dir.join("SKILL.md");
|
|
std::fs::write(
|
|
&skill_path,
|
|
"---\nname: demo-skill\ndescription: demo description\n---\n\n# Body\n",
|
|
)
|
|
.expect("write skill");
|
|
|
|
let skill_fs = session
|
|
.services
|
|
.turn_environments
|
|
.environment_manager()
|
|
.default_environment()
|
|
.map(|environment| environment.get_filesystem())
|
|
.unwrap_or_else(|| std::sync::Arc::clone(&codex_exec_server::LOCAL_FS));
|
|
let parent_snapshot = session
|
|
.services
|
|
.skills_service
|
|
.for_request()
|
|
.snapshot_for_cwd(
|
|
&crate::skills_load_input_from_config(&parent_config, Vec::new()),
|
|
/*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
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_update() {
|
|
let mut session_configuration = make_session_configuration_for_tests().await;
|
|
let workspace = tempfile::tempdir().expect("create temp dir");
|
|
let original_cwd = workspace.path().join("repo-a");
|
|
let next_cwd = workspace.path().join("repo-b");
|
|
std::fs::create_dir_all(&original_cwd).expect("create original cwd");
|
|
std::fs::create_dir_all(&next_cwd).expect("create next cwd");
|
|
let original_cwd = original_cwd.abs();
|
|
let next_cwd = next_cwd.abs();
|
|
|
|
session_configuration.legacy_fallback_cwd = original_cwd.clone();
|
|
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
missing_path_behavior: None,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path {
|
|
path: original_cwd.clone().into(),
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
missing_path_behavior: None,
|
|
},
|
|
]);
|
|
session_configuration
|
|
.set_permission_profile_for_tests(
|
|
PermissionProfile::from_runtime_permissions_with_enforcement(
|
|
SandboxEnforcement::Managed,
|
|
&file_system_sandbox_policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
),
|
|
)
|
|
.expect("set permission profile");
|
|
|
|
let updated = session_configuration
|
|
.apply(
|
|
&SessionSettingsUpdate {
|
|
environments: Some(TurnEnvironmentSelections::new(next_cwd.clone(), Vec::new())),
|
|
..Default::default()
|
|
},
|
|
&[],
|
|
)
|
|
.expect("cwd-only update should succeed");
|
|
|
|
assert_eq!(
|
|
updated.file_system_sandbox_policy(&[]),
|
|
file_system_sandbox_policy
|
|
);
|
|
assert!(
|
|
updated
|
|
.file_system_sandbox_policy(&[])
|
|
.can_write_local_path_with_cwd(original_cwd.as_path(), updated.cwd().as_path()),
|
|
"absolute grant to the old cwd must remain writable"
|
|
);
|
|
assert!(
|
|
!updated
|
|
.file_system_sandbox_policy(&[])
|
|
.can_write_local_path_with_cwd(next_cwd.as_path(), updated.cwd().as_path()),
|
|
"cwd-only update must not reinterpret an absolute old-cwd grant as :workspace_roots"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn settings_checkpoint_waits_for_accepted_settings_persistence() {
|
|
let (mut session, turn_context, rx) = make_session_and_context_with_auth_and_config_and_rx(
|
|
CodexAuth::from_api_key("Test API Key"),
|
|
Vec::new(),
|
|
|config| {
|
|
config
|
|
.permissions
|
|
.set_permission_profile(PermissionProfile::workspace_write())
|
|
.expect("set initial permission profile");
|
|
},
|
|
)
|
|
.await;
|
|
let rollout_path =
|
|
attach_thread_persistence(Arc::get_mut(&mut session).expect("unique session")).await;
|
|
let refresh_guard = session
|
|
.managed_network_proxy_refresh_lock
|
|
.acquire()
|
|
.await
|
|
.expect("network refresh lock");
|
|
let mut update = Box::pin(tokio::task::unconstrained(thread_settings::apply_update(
|
|
&session,
|
|
"settings".to_string(),
|
|
SessionSettingsUpdate {
|
|
step_settings: StepSettingsUpdate {
|
|
service_tier: Some(Some(ServiceTier::Fast.request_value().to_string())),
|
|
..Default::default()
|
|
},
|
|
permission_profile: Some(PermissionProfile::read_only()),
|
|
..Default::default()
|
|
},
|
|
)));
|
|
// 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"))],
|
|
turn_context.to_turn_context_item(),
|
|
WorldStateSnapshot::default(),
|
|
CompactedHistoryMetadata {
|
|
input_goal_ids: Default::default(),
|
|
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),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_attribution_checkpoints_cover_batch_prefixes_compaction_and_restore() {
|
|
let (mut session, turn_context) = make_session_and_context().await;
|
|
session.services.executed_tool_calls =
|
|
crate::state::ExecutedToolCalls::new(&turn_context.config.features, &InitialHistory::New);
|
|
let rollout_path = attach_thread_persistence(&mut session).await;
|
|
let source = McpAttributionSource {
|
|
connector_id: None,
|
|
plugin_id: None,
|
|
server_name: "example".to_string(),
|
|
tool_name: "search".to_string(),
|
|
first_turn_id: "turn_1".to_string(),
|
|
};
|
|
session
|
|
.services
|
|
.executed_tool_calls
|
|
.record_mcp_source(source.clone());
|
|
let expected = McpAttribution {
|
|
status: McpAttributionStatus::Complete,
|
|
error_reason: None,
|
|
sources: vec![source],
|
|
};
|
|
session
|
|
.record_annotated_conversation_items(
|
|
&turn_context,
|
|
turn_context.model_info(),
|
|
vec![
|
|
ResponseItemEnvelope::new(ResponseItem::FunctionCallOutput {
|
|
id: None,
|
|
call_id: Some("call_1".to_string()),
|
|
name: None,
|
|
namespace: None,
|
|
output: FunctionCallOutputPayload::from_text("result".to_string()),
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}),
|
|
ResponseItemEnvelope::new(user_message("next turn")),
|
|
],
|
|
)
|
|
.await;
|
|
let checkpoints = session
|
|
.clone_history()
|
|
.await
|
|
.annotated_items()
|
|
.iter()
|
|
.map(|envelope| {
|
|
envelope
|
|
.metadata
|
|
.as_ref()
|
|
.and_then(|metadata| metadata.mcp_attribution.clone())
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
checkpoints,
|
|
vec![Some(expected.clone()), Some(expected.clone())]
|
|
);
|
|
|
|
let (window_number, window_ids) = session.advance_auto_compact_window().await;
|
|
session
|
|
.replace_compacted_history(
|
|
vec![ResponseItemEnvelope::new(user_message("compacted history"))],
|
|
turn_context.to_turn_context_item(),
|
|
WorldStateSnapshot::default(),
|
|
CompactedHistoryMetadata {
|
|
input_goal_ids: Default::default(),
|
|
message: "summary".to_string(),
|
|
window_number,
|
|
window_ids,
|
|
compaction_response_id: None,
|
|
compaction_model_hash: None,
|
|
reviewer_compaction_hash: None,
|
|
},
|
|
)
|
|
.await;
|
|
session
|
|
.flush_rollout()
|
|
.await
|
|
.expect("flush compacted history");
|
|
|
|
let (items, _, _) = RolloutRecorder::load_rollout_items(&rollout_path)
|
|
.await
|
|
.expect("read compacted history");
|
|
let checkpoint = items.iter().rev().find_map(|item| match item {
|
|
RolloutItem::Compacted(compacted) => compacted
|
|
.replacement_history
|
|
.as_ref()?
|
|
.iter()
|
|
.rev()
|
|
.find_map(|envelope| envelope.metadata.as_ref()?.mcp_attribution.as_ref()),
|
|
_ => None,
|
|
});
|
|
assert_eq!(checkpoint, Some(&expected));
|
|
let restored = crate::state::ExecutedToolCalls::new(
|
|
&turn_context.config.features,
|
|
&InitialHistory::Forked(items),
|
|
);
|
|
assert_eq!(restored.mcp_attribution_snapshot(), expected);
|
|
}
|
|
|
|
#[test_case(None; "idle")]
|
|
#[test_case(Some(ActiveTurn::default()); "reserved turn")]
|
|
#[test_case(Some(ActiveTurn {
|
|
finishing: Some(tokio_util::sync::CancellationToken::new()),
|
|
..Default::default()
|
|
}); "finishing turn")]
|
|
#[tokio::test]
|
|
async fn standalone_settings_invalidate_continuation_before_delivering_acceptance(
|
|
active_turn: Option<ActiveTurn>,
|
|
) {
|
|
let (mut session, turn_context) = make_session_and_context().await;
|
|
let (tx, rx) = async_channel::bounded(1);
|
|
session.tx_event = tx;
|
|
session.state.lock().await.last_started_turn_id = Some("superseded-turn".into());
|
|
*session.active_turn.lock().await = active_turn;
|
|
session
|
|
.tx_event
|
|
.send(Event {
|
|
id: "occupied".into(),
|
|
msg: EventMsg::ThreadSettingsApplied(
|
|
codex_protocol::protocol::ThreadSettingsAppliedEvent {
|
|
thread_id: Some(session.thread_id()),
|
|
thread_settings: session.thread_settings_snapshot().await,
|
|
},
|
|
),
|
|
})
|
|
.await
|
|
.expect("fill event channel");
|
|
let session = Arc::new(session);
|
|
let (reply, mut accepted) = tokio::sync::oneshot::channel();
|
|
let (tx_sub, rx_sub) = async_channel::bounded(1);
|
|
tx_sub
|
|
.send(Submission {
|
|
id: "settings".into(),
|
|
turn_extension_init: None,
|
|
op: Op::ThreadSettings {
|
|
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides::default(),
|
|
reply: Some(reply),
|
|
},
|
|
trace: None,
|
|
parent_turn_id: None,
|
|
root_turn_id: None,
|
|
residency_guard: None,
|
|
message_delivery: None,
|
|
})
|
|
.await
|
|
.expect("submit settings");
|
|
let mut submissions = Box::pin(tokio::task::unconstrained(submission_loop(
|
|
Arc::clone(&session),
|
|
turn_context.config,
|
|
rx_sub,
|
|
)));
|
|
assert!(futures::poll!(submissions.as_mut()).is_pending());
|
|
assert_eq!(session.state.lock().await.last_started_turn_id, None);
|
|
accepted
|
|
.try_recv()
|
|
.expect("receive acceptance before the event is delivered")
|
|
.expect("settings accepted");
|
|
let mut checkpoint = Box::pin(session.checkpoint_thread_settings());
|
|
assert!(futures::poll!(checkpoint.as_mut()).is_pending());
|
|
rx.recv().await.expect("release event delivery");
|
|
assert!(futures::poll!(submissions.as_mut()).is_pending());
|
|
checkpoint.await.expect("checkpoint after settings update");
|
|
assert!(matches!(
|
|
rx.recv().await.expect("receive settings event").msg,
|
|
EventMsg::ThreadSettingsApplied(_)
|
|
));
|
|
drop(tx_sub);
|
|
submissions.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn compaction_persists_resume_metadata_and_companion_records() {
|
|
let (mut session, turn_context) = make_session_and_context().await;
|
|
let rollout_path = attach_thread_persistence(&mut session).await;
|
|
let turn_context = Arc::new(turn_context);
|
|
turn_context
|
|
.turn_metadata_state
|
|
.set_turn_trigger("automation".to_string());
|
|
turn_context
|
|
.turn_metadata_state
|
|
.set_parent_turn_id("parent-turn".to_string());
|
|
turn_context
|
|
.turn_metadata_state
|
|
.set_root_turn_id("root-turn".to_string());
|
|
turn_context.turn_metadata_state.set_initiating_agent_path(
|
|
codex_protocol::AgentPath::try_from("/root/requester").expect("requester path"),
|
|
);
|
|
let turn_context_baseline = turn_context.to_turn_context_item();
|
|
let world_state = Arc::new(build_world_state_from_turn_context(&session, &turn_context).await);
|
|
let previous_turn_settings = PreviousTurnSettings {
|
|
model: "previous-model".to_string(),
|
|
comp_hash: Some("comp-hash".to_string()),
|
|
cyber_access_program: None,
|
|
realtime_active: Some(true),
|
|
};
|
|
session
|
|
.set_previous_turn_settings(Some(previous_turn_settings.clone()))
|
|
.await;
|
|
|
|
session.multi_agent_version = std::sync::OnceLock::from(MultiAgentVersion::V2);
|
|
let expected = CompactionResumeMetadata {
|
|
multi_agent_version: Some(MultiAgentVersion::V2),
|
|
last_started_turn_id: Some(turn_context.sub_id.clone()),
|
|
turn_attribution: Some(codex_history::TurnAttribution {
|
|
turn_id: turn_context.sub_id.clone(),
|
|
turn_trigger: Some("automation".to_string()),
|
|
parent_turn_id: Some("parent-turn".to_string()),
|
|
initiating_agent_path: Some(
|
|
codex_protocol::AgentPath::try_from("/root/requester").expect("requester path"),
|
|
),
|
|
root_turn_id: turn_context_baseline.root_turn_id.clone(),
|
|
}),
|
|
previous_turn_settings: Some(previous_turn_settings),
|
|
};
|
|
let expected_settings = codex_protocol::protocol::ThreadSettingsAppliedEvent {
|
|
thread_id: Some(session.thread_id()),
|
|
thread_settings: session.thread_settings_snapshot().await,
|
|
};
|
|
|
|
session
|
|
.record_started_turn(&turn_context.sub_id, Some(turn_context.attribution()))
|
|
.await;
|
|
let compact_context = session.new_default_turn().await;
|
|
session
|
|
.record_started_turn(&compact_context.sub_id, /*attribution*/ None)
|
|
.await;
|
|
assert_eq!(
|
|
session.state.lock().await.turn_attribution,
|
|
expected.turn_attribution
|
|
);
|
|
|
|
session
|
|
.record_started_turn(&turn_context.sub_id, Some(turn_context.attribution()))
|
|
.await;
|
|
let input_goal_ids =
|
|
crate::context::UserGoalUpdate::message_ids(session.clone_history().await.raw_items());
|
|
// The goal edit is accepted after the compaction input was captured.
|
|
session
|
|
.record_user_goal_update(crate::context::UserGoalUpdate::Clear)
|
|
.await
|
|
.expect("record concurrent clear");
|
|
let accepted_goal = session
|
|
.clone_history()
|
|
.await
|
|
.annotated_items()
|
|
.last()
|
|
.cloned()
|
|
.expect("accepted goal");
|
|
let (window_number, window_ids) = session.advance_auto_compact_window().await;
|
|
session
|
|
.replace_compacted_history(
|
|
vec![ResponseItemEnvelope::new(user_message("compacted context"))],
|
|
turn_context_baseline.clone(),
|
|
world_state.render_full().0,
|
|
CompactedHistoryMetadata {
|
|
input_goal_ids,
|
|
message: String::new(),
|
|
window_number,
|
|
window_ids,
|
|
compaction_response_id: None,
|
|
compaction_model_hash: None,
|
|
reviewer_compaction_hash: None,
|
|
},
|
|
)
|
|
.await;
|
|
let live_history = session.clone_history().await.annotated_items().to_vec();
|
|
assert_eq!(&live_history[1..], &[accepted_goal]);
|
|
|
|
session.flush_rollout().await.expect("flush checkpoints");
|
|
let (items, _, _) = RolloutRecorder::load_rollout_items(&rollout_path)
|
|
.await
|
|
.expect("read checkpoints");
|
|
let compaction_items = items
|
|
.into_iter()
|
|
.skip_while(|item| !matches!(item, RolloutItem::Compacted(_)))
|
|
.collect::<Vec<_>>();
|
|
let [
|
|
RolloutItem::Compacted(compacted),
|
|
RolloutItem::WorldState(saved_world_state),
|
|
RolloutItem::TurnContext(saved_turn_context),
|
|
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(saved_settings)),
|
|
] = compaction_items.as_slice()
|
|
else {
|
|
panic!("unexpected compaction records: {compaction_items:#?}");
|
|
};
|
|
assert_eq!(compacted.replacement_history, Some(live_history));
|
|
assert_eq!(compacted.resume_metadata.as_ref(), Some(&expected));
|
|
assert_eq!(
|
|
saved_world_state,
|
|
&WorldStateItem::full(world_state.render_full().0.into_object())
|
|
);
|
|
assert_eq!(saved_turn_context, &turn_context_baseline);
|
|
assert_eq!(saved_settings, &expected_settings);
|
|
for same_turn_input in [false, true] {
|
|
let mut rollback_items = vec![RolloutItem::Compacted(compacted.clone())];
|
|
if same_turn_input {
|
|
rollback_items.push(RolloutItem::ResponseItem(
|
|
user_message("steered work").into(),
|
|
));
|
|
}
|
|
rollback_items.push(RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
|
|
codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 },
|
|
)));
|
|
let rolled_back = session
|
|
.reconstruct_history_from_rollout(&turn_context, &rollback_items)
|
|
.await;
|
|
assert_eq!(rolled_back.turn_attribution, None);
|
|
}
|
|
|
|
// Settings invalidate continuation eligibility; standalone compaction replaces its ID.
|
|
// Neither changes the regular work whose attribution the checkpoint preserves.
|
|
for last_started_turn_id in [None, Some("compact-turn".to_string())] {
|
|
let mut checkpoint = compacted.clone();
|
|
checkpoint
|
|
.resume_metadata
|
|
.as_mut()
|
|
.expect("resume metadata")
|
|
.last_started_turn_id = last_started_turn_id.clone();
|
|
let mut items = vec![RolloutItem::Compacted(checkpoint)];
|
|
if let Some(turn_id) = last_started_turn_id {
|
|
items.push(RolloutItem::EventMsg(EventMsg::TurnComplete(
|
|
TurnCompleteEvent {
|
|
root_turn_id: None,
|
|
turn_id,
|
|
last_agent_message: None,
|
|
error: None,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
time_to_first_token_ms: None,
|
|
},
|
|
)));
|
|
}
|
|
let reconstructed = session
|
|
.reconstruct_history_from_rollout(&turn_context, &items)
|
|
.await;
|
|
assert_eq!(reconstructed.turn_attribution, expected.turn_attribution);
|
|
assert_eq!(reconstructed.reference_context_item, None);
|
|
items.push(RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
|
|
codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 },
|
|
)));
|
|
let rolled_back = session
|
|
.reconstruct_history_from_rollout(&turn_context, &items)
|
|
.await;
|
|
assert_eq!(rolled_back.turn_attribution, None);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_settings_commit_keeps_snapshot_across_postcommit_wait() {
|
|
let (session, _turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx(
|
|
CodexAuth::from_api_key("Test API Key"),
|
|
Vec::new(),
|
|
|config| {
|
|
config
|
|
.permissions
|
|
.set_permission_profile(PermissionProfile::workspace_write())
|
|
.expect("set initial permission profile");
|
|
},
|
|
)
|
|
.await;
|
|
let refresh_guard = session
|
|
.managed_network_proxy_refresh_lock
|
|
.acquire()
|
|
.await
|
|
.expect("network refresh lock");
|
|
let mut first_update = Box::pin(tokio::task::unconstrained(session.update_settings(
|
|
SessionSettingsUpdate {
|
|
step_settings: StepSettingsUpdate {
|
|
service_tier: Some(Some(ServiceTier::Fast.request_value().to_string())),
|
|
..Default::default()
|
|
},
|
|
permission_profile: Some(PermissionProfile::read_only()),
|
|
..Default::default()
|
|
},
|
|
)));
|
|
|
|
// 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);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_runtime_keeps_local_backend_without_a_selected_local_environment() {
|
|
let (session, turn) = make_session_and_context().await;
|
|
let mut desired = session
|
|
.latest_mcp_desired_state(/*auth*/ None, turn.initial_environments.clone())
|
|
.await;
|
|
Arc::make_mut(&mut desired.config).prefer_mxc = true;
|
|
desired.environments.environments.clear();
|
|
let runtime = session.build_mcp_runtime_input(
|
|
&desired,
|
|
crate::mcp::McpRuntimeProjection {
|
|
config: (*mcp_config_for_test(&desired.config)).clone(),
|
|
plugins_available: false,
|
|
selected_plugins: Default::default(),
|
|
},
|
|
&[],
|
|
/*elicitation_reviewer*/ None,
|
|
);
|
|
assert_eq!(
|
|
runtime.config.environment_use_mxc,
|
|
HashMap::from([(DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), cfg!(windows))])
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_update_settings_does_not_rewrite_sticky_environment_cwds() {
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
#[allow(deprecated)]
|
|
let updated_cwd = turn_context.cwd.join("project");
|
|
let current_environments = session.services.turn_environments.selections();
|
|
let expected_environments = current_environments.clone();
|
|
std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir");
|
|
|
|
session
|
|
.update_settings(SessionSettingsUpdate {
|
|
environments: Some(TurnEnvironmentSelections::new(
|
|
updated_cwd.clone(),
|
|
current_environments,
|
|
)),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.expect("cwd update should succeed");
|
|
|
|
let session_cwd = {
|
|
let state = session.state.lock().await;
|
|
state.session_configuration.cwd().clone()
|
|
};
|
|
let stored_environments = session.services.turn_environments.selections();
|
|
let config = session.get_config().await;
|
|
let next_turn = session.new_default_turn().await;
|
|
|
|
assert_eq!(session_cwd, updated_cwd);
|
|
assert_eq!(stored_environments, expected_environments);
|
|
#[allow(deprecated)]
|
|
let turn_cwd = turn_context.cwd.clone();
|
|
#[allow(deprecated)]
|
|
let next_turn_cwd = next_turn.cwd.clone();
|
|
assert_eq!(config.cwd, turn_cwd);
|
|
assert_eq!(next_turn_cwd, turn_cwd);
|
|
assert_eq!(next_turn.config.cwd, turn_cwd);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn permission_profile_updates_apply_to_next_turn_environment() {
|
|
for apply_on_turn_start in [false, true] {
|
|
let (session, active_turn) = make_session_and_context().await;
|
|
let active_environment_config = active_turn
|
|
.initial_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");
|
|
assert_eq!(
|
|
session
|
|
.services
|
|
.turn_environments
|
|
.snapshot()
|
|
.await
|
|
.primary()
|
|
.expect("current environment")
|
|
.config(),
|
|
&active_environment_config,
|
|
);
|
|
session
|
|
.new_turn_with_default_settings(
|
|
"permission-profile-update".to_string(),
|
|
Default::default(),
|
|
)
|
|
.await
|
|
};
|
|
let next_environment = next_turn
|
|
.initial_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
|
|
.initial_environments
|
|
.primary()
|
|
.expect("active turn environment")
|
|
.config(),
|
|
&active_environment_config
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn relative_cwd_update_without_environments_resolves_under_session_cwd() {
|
|
let (session, _turn_context) = make_session_and_context().await;
|
|
let original_cwd = session
|
|
.state
|
|
.lock()
|
|
.await
|
|
.session_configuration
|
|
.cwd()
|
|
.clone();
|
|
let updated_cwd = original_cwd.join("project");
|
|
std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir");
|
|
|
|
session
|
|
.update_settings(SessionSettingsUpdate {
|
|
environments: Some(TurnEnvironmentSelections::new(
|
|
updated_cwd.clone(),
|
|
Vec::new(),
|
|
)),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.expect("cwd update should succeed");
|
|
|
|
let state = session.state.lock().await;
|
|
assert_eq!(state.session_configuration.cwd(), &updated_cwd);
|
|
assert!(state.session_configuration.environments.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn environment_settings_preserve_explicit_primary_cwd() {
|
|
let (session, _turn_context) = make_session_and_context().await;
|
|
let (original_cwd, environment_cwd, environments) = {
|
|
let state = session.state.lock().await;
|
|
let original_cwd = state.session_configuration.cwd().clone();
|
|
let environment_cwd = original_cwd.join("environment");
|
|
let environments = vec![local(environment_cwd.clone())];
|
|
(original_cwd, environment_cwd, environments)
|
|
};
|
|
let updated_cwd = original_cwd.join("project");
|
|
std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir");
|
|
|
|
session
|
|
.update_settings(SessionSettingsUpdate {
|
|
environments: Some(TurnEnvironmentSelections::new(
|
|
updated_cwd.clone(),
|
|
environments,
|
|
)),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.expect("cwd update should succeed");
|
|
|
|
let state = session.state.lock().await;
|
|
assert_eq!(state.session_configuration.cwd(), &updated_cwd);
|
|
assert_eq!(
|
|
state.session_configuration.environments[0].cwd,
|
|
PathUri::from_abs_path(&environment_cwd)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn absolute_cwd_update_with_turn_environment_is_allowed() {
|
|
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
|
|
let absolute_cwd = {
|
|
let state = session.state.lock().await;
|
|
state.session_configuration.cwd().join("absolute-turn")
|
|
};
|
|
std::fs::create_dir_all(absolute_cwd.as_path()).expect("create absolute turn dir");
|
|
|
|
let (turn_context, _) = session
|
|
.new_turn_with_sub_id(
|
|
"sub-1".to_string(),
|
|
SessionSettingsUpdate {
|
|
environments: Some(TurnEnvironmentSelections::new(
|
|
absolute_cwd.clone(),
|
|
vec![local(absolute_cwd.clone())],
|
|
)),
|
|
..Default::default()
|
|
},
|
|
Default::default(),
|
|
)
|
|
.await
|
|
.expect("absolute cwd with explicit environments should succeed");
|
|
|
|
#[allow(deprecated)]
|
|
let turn_cwd = turn_context.cwd.clone();
|
|
assert_eq!(turn_cwd, absolute_cwd);
|
|
assert_eq!(turn_context.config.cwd, absolute_cwd);
|
|
assert_eq!(
|
|
turn_context
|
|
.initial_environments
|
|
.turn_environments()
|
|
.count(),
|
|
1
|
|
);
|
|
}
|
|
|
|
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, /*new_window*/ true)
|
|
.await
|
|
.expect("world state should build");
|
|
let (updates, _) = session
|
|
.build_initial_context_with_world_state(&step_context, &world_state)
|
|
.await;
|
|
crate::context_manager::updates::merge_world_state_updates(updates)
|
|
}
|
|
|
|
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, /*new_window*/ false)
|
|
.await
|
|
.expect("world state should build")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn response_metadata_builders_capture_fresh_mcp_attribution() {
|
|
let (mut session, turn_context) = make_session_and_context().await;
|
|
session.services.executed_tool_calls =
|
|
crate::state::ExecutedToolCalls::new(&turn_context.config.features, &InitialHistory::New);
|
|
let turn_context = Arc::new(turn_context);
|
|
let step_context = StepContext::for_test(Arc::clone(&turn_context));
|
|
let before = session
|
|
.responses_metadata(&step_context, CodexResponsesRequestKind::Turn)
|
|
.await;
|
|
let source = McpAttributionSource {
|
|
connector_id: None,
|
|
plugin_id: None,
|
|
server_name: "example".to_string(),
|
|
tool_name: "search".to_string(),
|
|
first_turn_id: turn_context.sub_id.clone(),
|
|
};
|
|
session
|
|
.services
|
|
.executed_tool_calls
|
|
.record_mcp_source(source.clone());
|
|
let after = session
|
|
.responses_metadata(&step_context, CodexResponsesRequestKind::Turn)
|
|
.await;
|
|
let compaction = session
|
|
.compaction_responses_metadata(
|
|
&turn_context,
|
|
CompactionTurnMetadata::new(
|
|
CompactionTrigger::Auto,
|
|
CompactionReason::ContextLimit,
|
|
CompactionImplementation::Responses,
|
|
CompactionPhase::MidTurn,
|
|
),
|
|
)
|
|
.await;
|
|
let expected = Some(McpAttribution {
|
|
status: McpAttributionStatus::Complete,
|
|
error_reason: None,
|
|
sources: vec![source],
|
|
});
|
|
assert_eq!(before.mcp_attribution, Some(McpAttribution::default()));
|
|
assert_eq!(after.mcp_attribution, expected);
|
|
assert_eq!(compaction.mcp_attribution, expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn responses_metadata_uses_selected_harness_analytics_client() {
|
|
for enabled in [true, false] {
|
|
let (mut session, mut turn_context) = make_session_and_context().await;
|
|
session.services.analytics_events_client = AnalyticsEventsClient::new(
|
|
Arc::clone(&session.services.auth_manager),
|
|
turn_context.config.chatgpt_base_url.clone(),
|
|
Some(enabled),
|
|
);
|
|
Arc::make_mut(&mut turn_context.config).analytics_enabled = Some(!enabled);
|
|
let step_context = StepContext::for_test(Arc::new(turn_context));
|
|
let metadata = session
|
|
.responses_metadata(&step_context, CodexResponsesRequestKind::Turn)
|
|
.await;
|
|
assert_eq!(metadata.analytics_enabled, Some(enabled));
|
|
}
|
|
}
|
|
|
|
// 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 = LocalAgentControl::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 {
|
|
turn_extension_init: Default::default(),
|
|
environments: default_environments.clone(),
|
|
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(|| render_model_instructions(&model_info)),
|
|
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,
|
|
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: Arc::default(),
|
|
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::for_session(&session_configuration.session_source, &config.features);
|
|
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(),
|
|
ThreadEnvironmentDefaults::new(
|
|
session_configuration.inferred_environment_config(),
|
|
session_configuration.windows_sandbox_type,
|
|
),
|
|
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(),
|
|
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()),
|
|
granted_permissions_by_environment_id: Arc::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(),
|
|
local_agent_runtime: agent_control.runtime.clone(),
|
|
agent_control: Arc::new(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::ReasoningEffortOverride),
|
|
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),
|
|
/*output_token_replay_enabled*/ false,
|
|
/*attestation_provider*/ None,
|
|
config.http_client_factory(),
|
|
config.workspace_routing_context(),
|
|
Vec::new(),
|
|
),
|
|
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,
|
|
parent_observer_event_tx: OnceLock::new(),
|
|
agent_status: agent_status_tx,
|
|
state: Mutex::new(state),
|
|
thread_settings_persistence: Semaphore::new(/*permits*/ 1),
|
|
code_mode_message_tasks: Default::default(),
|
|
managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1),
|
|
features: config.features.clone(),
|
|
|
|
isolation: codex_extension_api::SessionIsolation::Inherit,
|
|
tool_policy: Arc::default(),
|
|
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,
|
|
history_initialization: HistoryInitialization::New,
|
|
forked_from_ordinal_exclusive: None,
|
|
next_internal_sub_id: AtomicU64::new(0),
|
|
};
|
|
let per_turn_config = session.build_per_turn_config(
|
|
&session_configuration,
|
|
resolved_turn_environments.local_selection(),
|
|
);
|
|
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),
|
|
Arc::clone(&session.services.granted_permissions_by_environment_id),
|
|
Some(Arc::clone(&auth_manager)),
|
|
&session_telemetry,
|
|
session_configuration.provider.clone(),
|
|
&session_configuration,
|
|
config.multi_agent_version_from_features(),
|
|
per_turn_config,
|
|
Arc::new(super::step_settings::ResolvedStepSettings::new(
|
|
Arc::clone(&session_configuration.step_settings),
|
|
Arc::new(model_info),
|
|
&config.features,
|
|
)),
|
|
&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 {
|
|
turn_extension_init: Default::default(),
|
|
environments: default_environments.clone(),
|
|
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(|| render_model_instructions(&model_info)),
|
|
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,
|
|
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: Arc::default(),
|
|
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,
|
|
/*attached_thread*/ false,
|
|
PromptCacheKey::default(),
|
|
&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,
|
|
HistoryInitialization::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(),
|
|
LocalAgentControl::default().into(),
|
|
/*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,
|
|
)),
|
|
/*resume_preparation*/ 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: LocalAgentControl,
|
|
) -> 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 {
|
|
turn_extension_init: Default::default(),
|
|
environments: default_environments.clone(),
|
|
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(|| render_model_instructions(&model_info)),
|
|
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,
|
|
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: Arc::default(),
|
|
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 history_initialization = HistoryInitialization::from_history(&initial_history);
|
|
let session = Session::new(
|
|
/*startup*/ None,
|
|
session_configuration,
|
|
/*attached_thread*/ false,
|
|
PromptCacheKey::default(),
|
|
&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,
|
|
history_initialization,
|
|
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.into(),
|
|
/*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"),
|
|
),
|
|
)),
|
|
/*resume_preparation*/ 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))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resumed_root_session_uses_thread_id_as_session_id() {
|
|
let thread_id = ThreadId::new();
|
|
let (session, rx_event) = make_session_with_history_source_and_agent_control_and_rx(
|
|
InitialHistory::Resumed(ResumedHistory {
|
|
history_revision: None,
|
|
conversation_id: thread_id,
|
|
history: Arc::new(Vec::new()),
|
|
rollout_path: None,
|
|
}),
|
|
SessionSource::Exec,
|
|
LocalAgentControl::default(),
|
|
)
|
|
.await
|
|
.expect("resume should succeed");
|
|
|
|
assert_eq!(session.thread_id(), thread_id);
|
|
assert_eq!(session.session_id(), SessionId::from(thread_id));
|
|
|
|
let event = rx_event.recv().await.expect("session configured event");
|
|
let EventMsg::SessionConfigured(event) = event.msg else {
|
|
panic!("expected session configured event");
|
|
};
|
|
assert_eq!(event.session_id, SessionId::from(thread_id));
|
|
assert_eq!(event.thread_id, thread_id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resumed_subagent_session_restores_persisted_session_id() {
|
|
let parent_thread_id = ThreadId::new();
|
|
let parent_session_id = SessionId::from(parent_thread_id);
|
|
let thread_id = ThreadId::new();
|
|
let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
|
parent_thread_id,
|
|
depth: 1,
|
|
agent_path: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
});
|
|
let (session, rx_event) = make_session_with_history_source_and_agent_control_and_rx(
|
|
InitialHistory::Resumed(ResumedHistory {
|
|
history_revision: None,
|
|
conversation_id: thread_id,
|
|
history: Arc::new(vec![RolloutItem::SessionMeta(Box::new(SessionMetaLine {
|
|
meta: SessionMeta {
|
|
session_id: parent_session_id,
|
|
id: thread_id,
|
|
source: session_source.clone(),
|
|
..SessionMeta::default()
|
|
},
|
|
git: None,
|
|
}))]),
|
|
rollout_path: None,
|
|
}),
|
|
session_source,
|
|
LocalAgentControl::default(),
|
|
)
|
|
.await
|
|
.expect("resume should succeed");
|
|
|
|
assert_eq!(session.thread_id(), thread_id);
|
|
assert_eq!(session.session_id(), parent_session_id);
|
|
|
|
let event = rx_event.recv().await.expect("session configured event");
|
|
let EventMsg::SessionConfigured(event) = event.msg else {
|
|
panic!("expected session configured event");
|
|
};
|
|
assert_eq!(event.session_id, parent_session_id);
|
|
assert_eq!(event.thread_id, thread_id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resumed_copied_fork_ignores_source_history_base() {
|
|
let ancestor_thread_id = ThreadId::new();
|
|
let parent_thread_id = ThreadId::new();
|
|
let thread_id = ThreadId::new();
|
|
let history = vec![
|
|
RolloutItem::SessionMeta(Box::new(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(Box::new(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 {
|
|
history_revision: None,
|
|
conversation_id: thread_id,
|
|
history: Arc::new(history),
|
|
rollout_path: None,
|
|
}),
|
|
SessionSource::Exec,
|
|
LocalAgentControl::default(),
|
|
)
|
|
.await
|
|
.expect("resume should succeed");
|
|
|
|
assert_eq!(session.thread_id(), thread_id);
|
|
assert_eq!(session.forked_from_ordinal_exclusive, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn notify_request_permissions_response_ignores_unmatched_call_id() {
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
let active_turn = ActiveTurn::default();
|
|
*session.active_turn.lock().await = Some(active_turn);
|
|
|
|
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!(
|
|
turn_context.granted_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_granted_request_permissions_for_turn_uses_originating_turn() {
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
|
|
let (_other_session, current_turn_context) = make_session_and_context().await;
|
|
|
|
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,
|
|
&turn_context,
|
|
);
|
|
|
|
assert_eq!(
|
|
turn_context.granted_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID),
|
|
Some(requested_permissions.into())
|
|
);
|
|
assert_eq!(
|
|
current_turn_context.granted_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn request_permission_grants_are_environment_keyed() {
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
|
|
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",
|
|
&turn_context,
|
|
);
|
|
|
|
assert_eq!(
|
|
turn_context.granted_permissions("remote"),
|
|
Some(requested_permissions.clone().into())
|
|
);
|
|
assert_eq!(turn_context.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",
|
|
&turn_context,
|
|
);
|
|
|
|
assert_eq!(
|
|
session
|
|
.services
|
|
.granted_permissions_by_environment_id
|
|
.lock()
|
|
.expect("session permission grants lock poisoned")
|
|
.get("remote")
|
|
.cloned(),
|
|
Some(requested_permissions.into())
|
|
);
|
|
assert_eq!(
|
|
session
|
|
.services
|
|
.granted_permissions_by_environment_id
|
|
.lock()
|
|
.expect("session permission grants lock poisoned")
|
|
.get("local")
|
|
.cloned(),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn enable_strict_auto_review_for_turn_uses_originating_turn() {
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
|
|
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,
|
|
scope: PermissionGrantScope::Turn,
|
|
strict_auto_review: true,
|
|
},
|
|
codex_exec_server::LOCAL_ENVIRONMENT_ID,
|
|
&turn_context,
|
|
);
|
|
|
|
assert!(turn_context.strict_auto_review_enabled());
|
|
}
|
|
|
|
#[test]
|
|
fn strict_auto_review_session_scope_grants_no_permissions() {
|
|
let requested_permissions = RequestPermissionProfile {
|
|
network: Some(codex_protocol::models::NetworkPermissions {
|
|
enabled: Some(true),
|
|
}),
|
|
..RequestPermissionProfile::default()
|
|
};
|
|
|
|
let cwd = PathUri::parse("file:///tmp").expect("test cwd should be valid");
|
|
let context = FileSystemSandboxPolicyContext {
|
|
cwd: &cwd,
|
|
workspace_roots: &[],
|
|
user_home_dir: None,
|
|
temporary_directories: None,
|
|
};
|
|
let response = Session::normalize_request_permissions_response(
|
|
requested_permissions.clone(),
|
|
codex_protocol::request_permissions::RequestPermissionsResponse {
|
|
permissions: requested_permissions,
|
|
scope: PermissionGrantScope::Session,
|
|
strict_auto_review: true,
|
|
},
|
|
&context,
|
|
);
|
|
|
|
assert_eq!(
|
|
response,
|
|
codex_protocol::request_permissions::RequestPermissionsResponse {
|
|
permissions: RequestPermissionProfile::default(),
|
|
scope: PermissionGrantScope::Turn,
|
|
strict_auto_review: false,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn request_permissions_emits_event_when_granular_policy_allows_requests() {
|
|
let (session, mut turn_context, rx) = make_session_and_context_with_rx().await;
|
|
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
|
let turn_context_mut = Arc::get_mut(&mut turn_context).expect("single thread settings ref");
|
|
Arc::make_mut(&mut turn_context_mut.config)
|
|
.permissions
|
|
.approval_policy
|
|
.set(AskForApproval::Granular(GranularApprovalConfig {
|
|
sandbox_approval: true,
|
|
rules: true,
|
|
skill_approval: true,
|
|
request_permissions: true,
|
|
mcp_elicitations: true,
|
|
}))
|
|
.expect("test setup should allow updating approval policy");
|
|
|
|
let session = Arc::new(session);
|
|
let turn_context = Arc::new(turn_context);
|
|
let call_id = "call-1".to_string();
|
|
let expected_response = codex_protocol::request_permissions::RequestPermissionsResponse {
|
|
permissions: RequestPermissionProfile {
|
|
network: Some(codex_protocol::models::NetworkPermissions {
|
|
enabled: Some(true),
|
|
}),
|
|
..RequestPermissionProfile::default()
|
|
},
|
|
scope: PermissionGrantScope::Turn,
|
|
strict_auto_review: false,
|
|
};
|
|
|
|
let handle = tokio::spawn({
|
|
let session = Arc::clone(&session);
|
|
let turn_context = Arc::clone(&turn_context);
|
|
let call_id = call_id.clone();
|
|
async move {
|
|
let environment = turn_context
|
|
.initial_environments
|
|
.primary()
|
|
.expect("primary environment")
|
|
.selection();
|
|
session
|
|
.request_permissions_for_environment(
|
|
&StepContext::for_test(Arc::clone(turn_context.as_ref())),
|
|
call_id,
|
|
codex_protocol::request_permissions::RequestPermissionsArgs {
|
|
environment_id: None,
|
|
reason: Some("need network".to_string()),
|
|
permissions: RequestPermissionProfile {
|
|
network: Some(codex_protocol::models::NetworkPermissions {
|
|
enabled: Some(true),
|
|
}),
|
|
..RequestPermissionProfile::default()
|
|
},
|
|
},
|
|
environment,
|
|
CancellationToken::new(),
|
|
)
|
|
.await
|
|
}
|
|
});
|
|
|
|
let request_event = tokio::time::timeout(StdDuration::from_secs(1), rx.recv())
|
|
.await
|
|
.expect("request_permissions event timed out")
|
|
.expect("request_permissions event missing");
|
|
let EventMsg::RequestPermissions(request) = request_event.msg else {
|
|
panic!("expected request_permissions event");
|
|
};
|
|
assert_eq!(request.call_id, call_id);
|
|
assert_eq!(
|
|
request.environment_id.as_deref(),
|
|
Some(codex_exec_server::LOCAL_ENVIRONMENT_ID)
|
|
);
|
|
#[allow(deprecated)]
|
|
let turn_cwd = turn_context.cwd.clone();
|
|
assert_eq!(request.cwd, Some(turn_cwd.into()));
|
|
|
|
session
|
|
.notify_request_permissions_response(&request.call_id, expected_response.clone())
|
|
.await;
|
|
|
|
let response = tokio::time::timeout(StdDuration::from_secs(1), handle)
|
|
.await
|
|
.expect("request_permissions future timed out")
|
|
.expect("request_permissions join error");
|
|
|
|
assert_eq!(response, Some(expected_response));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn request_permissions_tool_resolves_legacy_paths_against_selected_environment() {
|
|
let (session, mut turn_context, rx) = make_session_and_context_with_rx().await;
|
|
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
|
let environment_cwd = {
|
|
#[allow(deprecated)]
|
|
let legacy_cwd = turn_context.cwd.clone();
|
|
legacy_cwd.join("request-permissions-environment")
|
|
};
|
|
let environment_home = environment_cwd.join("home");
|
|
std::fs::create_dir_all(environment_cwd.as_path()).expect("create environment cwd");
|
|
let turn_context_mut = Arc::get_mut(&mut turn_context).expect("single thread settings ref");
|
|
Arc::make_mut(&mut turn_context_mut.config)
|
|
.permissions
|
|
.approval_policy
|
|
.set(AskForApproval::Granular(GranularApprovalConfig {
|
|
sandbox_approval: true,
|
|
rules: true,
|
|
skill_approval: true,
|
|
request_permissions: true,
|
|
mcp_elicitations: true,
|
|
}))
|
|
.expect("test setup should allow updating approval policy");
|
|
let current_environment = turn_context_mut
|
|
.initial_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 {
|
|
selected_capability_roots: Default::default(),
|
|
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
|
|
.initial_environments
|
|
.environments
|
|
.push(TurnEnvironmentState::Ready(environment));
|
|
|
|
let call_id = "call-1".to_string();
|
|
let registry = ToolRegistry::with_handler_for_test(Arc::new(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 {
|
|
let invocation = 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,
|
|
environments: Default::default(),
|
|
payload: ToolPayload::Function {
|
|
arguments: json!({
|
|
"environment_id": "remote",
|
|
"reason": "need write",
|
|
"permissions": {
|
|
"file_system": {
|
|
"read": null,
|
|
"write": ["relative.txt", "~/home-relative.txt"],
|
|
},
|
|
},
|
|
})
|
|
.to_string(),
|
|
},
|
|
};
|
|
registry
|
|
.dispatch_any_with_state(invocation, /*call_state*/ None)
|
|
.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");
|
|
}
|
|
|
|
#[test_case("missing", "unknown turn environment id `missing`"; "unknown environment")]
|
|
#[test_case("local", "permission path cannot be represented losslessly"; "lossy path")]
|
|
#[tokio::test]
|
|
async fn request_permissions_tool_rejects_invalid_requests(
|
|
environment_id: &str,
|
|
expected_error: &str,
|
|
) {
|
|
let (session, mut turn_context) = make_session_and_context().await;
|
|
Arc::make_mut(&mut turn_context.config)
|
|
.permissions
|
|
.approval_policy = codex_config::Constrained::allow_any(AskForApproval::Never);
|
|
let TurnEnvironmentState::Ready(environment) =
|
|
&mut turn_context.initial_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 registry = ToolRegistry::with_handler_for_test(Arc::new(RequestPermissionsHandler));
|
|
let invocation = 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,
|
|
environments: Default::default(),
|
|
payload: ToolPayload::Function {
|
|
arguments: json!({
|
|
"environment_id": environment_id,
|
|
"permissions": {
|
|
"file_system": {
|
|
"write": ["relative"],
|
|
},
|
|
},
|
|
})
|
|
.to_string(),
|
|
},
|
|
};
|
|
let result = registry
|
|
.dispatch_any_with_state(invocation, /*call_state*/ None)
|
|
.await;
|
|
|
|
let Err(FunctionCallError::RespondToModel(output)) = result else {
|
|
panic!("expected invalid request to be rejected");
|
|
};
|
|
assert_eq!(output, expected_error);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn request_permissions_response_materializes_session_cwd_grants_before_recording() {
|
|
let (session, mut turn_context, rx) = make_session_and_context_with_rx().await;
|
|
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
|
let turn_context_mut = Arc::get_mut(&mut turn_context).expect("single thread settings ref");
|
|
Arc::make_mut(&mut turn_context_mut.config)
|
|
.permissions
|
|
.approval_policy
|
|
.set(AskForApproval::Granular(GranularApprovalConfig {
|
|
sandbox_approval: true,
|
|
rules: true,
|
|
skill_approval: true,
|
|
request_permissions: true,
|
|
mcp_elicitations: true,
|
|
}))
|
|
.expect("test setup should allow updating approval policy");
|
|
|
|
let session = Arc::new(session);
|
|
let turn_context = Arc::new(turn_context);
|
|
let call_id = "call-1".to_string();
|
|
let requested_permissions = RequestPermissionProfile {
|
|
file_system: Some(FileSystemPermissions {
|
|
entries: vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*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
|
|
.initial_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
|
|
.services
|
|
.granted_permissions_by_environment_id
|
|
.lock()
|
|
.expect("session permission grants lock poisoned")
|
|
.get(codex_exec_server::LOCAL_ENVIRONMENT_ID)
|
|
.cloned(),
|
|
Some(expected_permissions.into())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_requests() {
|
|
let (session, mut turn_context, rx) = make_session_and_context_with_rx().await;
|
|
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
|
let turn_context_mut = Arc::get_mut(&mut turn_context).expect("single thread settings ref");
|
|
Arc::make_mut(&mut turn_context_mut.config)
|
|
.permissions
|
|
.approval_policy
|
|
.set(AskForApproval::Granular(GranularApprovalConfig {
|
|
sandbox_approval: true,
|
|
rules: true,
|
|
skill_approval: true,
|
|
request_permissions: false,
|
|
mcp_elicitations: true,
|
|
}))
|
|
.expect("test setup should allow updating approval policy");
|
|
|
|
let session = Arc::new(session);
|
|
let turn_context = Arc::new(turn_context);
|
|
let call_id = "call-1".to_string();
|
|
let environment = turn_context
|
|
.initial_environments
|
|
.primary()
|
|
.expect("primary environment")
|
|
.selection();
|
|
let response = session
|
|
.request_permissions_for_environment(
|
|
&StepContext::for_test(Arc::clone(turn_context.as_ref())),
|
|
call_id,
|
|
codex_protocol::request_permissions::RequestPermissionsArgs {
|
|
environment_id: None,
|
|
reason: Some("need network".to_string()),
|
|
permissions: RequestPermissionProfile {
|
|
network: Some(codex_protocol::models::NetworkPermissions {
|
|
enabled: Some(true),
|
|
}),
|
|
..RequestPermissionProfile::default()
|
|
},
|
|
},
|
|
environment,
|
|
CancellationToken::new(),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
response,
|
|
Some(
|
|
codex_protocol::request_permissions::RequestPermissionsResponse {
|
|
permissions: RequestPermissionProfile::default(),
|
|
scope: PermissionGrantScope::Turn,
|
|
strict_auto_review: false,
|
|
}
|
|
)
|
|
);
|
|
assert!(
|
|
tokio::time::timeout(StdDuration::from_millis(100), rx.recv())
|
|
.await
|
|
.is_err(),
|
|
"request_permissions should not emit an event when granular.request_permissions is false"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn submit_with_trace_captures_current_span_trace_context() {
|
|
let (_session, _turn_context) = make_session_and_context().await;
|
|
let (tx_sub, rx_sub) = async_channel::bounded(1);
|
|
let (_tx_event, rx_event) = async_channel::unbounded();
|
|
let io = SessionIo {
|
|
tx_sub,
|
|
rx_event,
|
|
agent_status: watch::channel(AgentStatus::PendingInit).1,
|
|
session_loop_termination: completed_session_loop_termination(),
|
|
};
|
|
|
|
let _trace_test_context = install_test_tracing("codex-core-tests");
|
|
|
|
let request_parent = W3cTraceContext {
|
|
traceparent: Some("00-00000000000000000000000000000011-0000000000000022-01".into()),
|
|
tracestate: Some("vendor=value".into()),
|
|
};
|
|
let request_span = info_span!("app_server.request");
|
|
assert!(set_parent_from_w3c_trace_context(
|
|
&request_span,
|
|
&request_parent
|
|
));
|
|
|
|
let expected_trace = async {
|
|
let expected_trace =
|
|
current_span_w3c_trace_context().expect("current span should have trace context");
|
|
io.submit_with_trace(
|
|
Op::Interrupt,
|
|
/*trace*/ None,
|
|
/*parent_turn_id*/ None,
|
|
/*root_turn_id*/ None,
|
|
/*residency_guard*/ None,
|
|
/*message_delivery*/ None,
|
|
)
|
|
.await
|
|
.expect("submit should succeed");
|
|
expected_trace
|
|
}
|
|
.instrument(request_span)
|
|
.await;
|
|
|
|
let submitted = rx_sub.recv().await.expect("submission");
|
|
assert_eq!(submitted.trace, Some(expected_trace));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn new_default_turn_captures_current_span_trace_id() {
|
|
let (session, _turn_context) = make_session_and_context().await;
|
|
|
|
let _trace_test_context = install_test_tracing("codex-core-tests");
|
|
|
|
let request_parent = W3cTraceContext {
|
|
traceparent: Some("00-00000000000000000000000000000011-0000000000000022-01".into()),
|
|
tracestate: Some("vendor=value".into()),
|
|
};
|
|
let request_span = info_span!("app_server.request");
|
|
assert!(set_parent_from_w3c_trace_context(
|
|
&request_span,
|
|
&request_parent
|
|
));
|
|
|
|
let turn_trace_id = async {
|
|
let expected_trace_id = Span::current()
|
|
.context()
|
|
.span()
|
|
.span_context()
|
|
.trace_id()
|
|
.to_string();
|
|
let turn_context = session.new_default_turn().await;
|
|
assert_eq!(turn_context.trace_id, Some(expected_trace_id));
|
|
turn_context.trace_id.clone()
|
|
}
|
|
.instrument(request_span)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
turn_trace_id.as_deref(),
|
|
Some("00000000000000000000000000000011")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn submission_dispatch_span_prefers_submission_trace_context() {
|
|
let _trace_test_context = install_test_tracing("codex-core-tests");
|
|
|
|
let ambient_parent = W3cTraceContext {
|
|
traceparent: Some("00-00000000000000000000000000000033-0000000000000044-01".into()),
|
|
tracestate: None,
|
|
};
|
|
let ambient_span = info_span!("ambient");
|
|
assert!(set_parent_from_w3c_trace_context(
|
|
&ambient_span,
|
|
&ambient_parent
|
|
));
|
|
|
|
let submission_trace = W3cTraceContext {
|
|
traceparent: Some("00-00000000000000000000000000000055-0000000000000066-01".into()),
|
|
tracestate: Some("vendor=value".into()),
|
|
};
|
|
let dispatch_span = ambient_span.in_scope(|| {
|
|
submission_dispatch_span(&Submission {
|
|
turn_extension_init: None,
|
|
id: "sub-1".into(),
|
|
op: Op::Interrupt,
|
|
parent_turn_id: None,
|
|
root_turn_id: None,
|
|
residency_guard: None,
|
|
message_delivery: None,
|
|
trace: Some(submission_trace),
|
|
})
|
|
});
|
|
|
|
let trace_id = dispatch_span.context().span().span_context().trace_id();
|
|
assert_eq!(
|
|
trace_id,
|
|
TraceId::from_hex("00000000000000000000000000000055").expect("trace id")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn submission_dispatch_span_uses_debug_for_realtime_audio() {
|
|
let _trace_test_context = install_test_tracing("codex-core-tests");
|
|
|
|
let dispatch_span = submission_dispatch_span(&Submission {
|
|
turn_extension_init: None,
|
|
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,
|
|
residency_guard: None,
|
|
message_delivery: None,
|
|
trace: None,
|
|
});
|
|
|
|
assert_eq!(
|
|
dispatch_span.metadata().expect("span metadata").level(),
|
|
&tracing::Level::DEBUG
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn turn_environments_set_primary_environment() {
|
|
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
|
|
let selected_cwd =
|
|
AbsolutePathBuf::try_from(session.get_config().await.cwd.as_path().join("selected"))
|
|
.expect("absolute path");
|
|
|
|
let (turn_context, _) = session
|
|
.new_turn_with_sub_id(
|
|
"sub-1".to_string(),
|
|
SessionSettingsUpdate {
|
|
environments: Some(TurnEnvironmentSelections::new(
|
|
selected_cwd.clone(),
|
|
vec![local(selected_cwd.clone())],
|
|
)),
|
|
..Default::default()
|
|
},
|
|
Default::default(),
|
|
)
|
|
.await
|
|
.expect("turn should start");
|
|
|
|
let turn_environments = &turn_context.initial_environments;
|
|
assert_eq!(turn_environments.turn_environments().count(), 1);
|
|
let turn_environment = turn_context
|
|
.initial_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
|
|
.initial_environments
|
|
.turn_environments()
|
|
.next()
|
|
.is_some()
|
|
);
|
|
#[allow(deprecated)]
|
|
let turn_cwd = turn_context.cwd.clone();
|
|
assert_eq!(turn_cwd.as_path(), selected_cwd.as_path());
|
|
assert_eq!(turn_context.config.cwd.as_path(), selected_cwd.as_path());
|
|
|
|
let stored_environment = {
|
|
session
|
|
.services
|
|
.turn_environments
|
|
.snapshot()
|
|
.await
|
|
.primary_environment()
|
|
.expect("stored primary environment")
|
|
};
|
|
assert!(Arc::ptr_eq(
|
|
&stored_environment,
|
|
&turn_environment.environment
|
|
));
|
|
|
|
let default_turn = session.new_default_turn().await;
|
|
assert!(Arc::ptr_eq(
|
|
&stored_environment,
|
|
&default_turn
|
|
.initial_environments
|
|
.primary()
|
|
.expect("default turn primary environment")
|
|
.environment
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn new_task_does_not_change_an_already_running_tasks_environments() {
|
|
let (session, running_turn, events) = make_session_and_context_with_rx().await;
|
|
let current_cwd = running_turn.config.cwd.clone();
|
|
let next_workspace = tempfile::tempdir().expect("next workspace");
|
|
let next_cwd = next_workspace.path().abs();
|
|
let finish = Arc::new(Notify::new());
|
|
|
|
session
|
|
.update_settings(SessionSettingsUpdate {
|
|
environments: Some(TurnEnvironmentSelections::new(
|
|
next_cwd.clone(),
|
|
vec![local(next_cwd.clone())],
|
|
)),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.expect("save next task's settings");
|
|
session
|
|
.spawn_task(
|
|
running_turn,
|
|
Vec::new(),
|
|
HeldStepTask {
|
|
kind: TaskKind::Regular,
|
|
finish: Arc::clone(&finish),
|
|
},
|
|
)
|
|
.await;
|
|
|
|
let candidate = session
|
|
.new_turn_with_default_settings("candidate".into(), Default::default())
|
|
.await;
|
|
assert_eq!(candidate.config.cwd, current_cwd);
|
|
assert_eq!(candidate.config.workspace_roots, vec![current_cwd.clone()]);
|
|
assert_eq!(
|
|
session.services.turn_environments.selections(),
|
|
vec![local(current_cwd)]
|
|
);
|
|
|
|
finish.notify_one();
|
|
recv_terminal_event(&events, TerminalEventKind::TurnComplete).await;
|
|
let next = session
|
|
.new_turn_with_default_settings("next".into(), Default::default())
|
|
.await;
|
|
assert_eq!(next.config.cwd, next_cwd);
|
|
assert_eq!(next.config.workspace_roots, vec![next_cwd]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn task_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_turn_with_default_settings("task".into(), Default::default())
|
|
.await;
|
|
|
|
let turn_environments = &turn_context.initial_environments;
|
|
assert_eq!(turn_environments.turn_environments().count(), 1);
|
|
let turn_environment = turn_context
|
|
.initial_environments
|
|
.primary()
|
|
.expect("primary environment should be set");
|
|
assert!(std::sync::Arc::ptr_eq(
|
|
&turn_environment.environment,
|
|
&turn_environments
|
|
.primary()
|
|
.expect("primary environment")
|
|
.environment
|
|
));
|
|
#[allow(deprecated)]
|
|
let turn_cwd = turn_context.cwd.clone();
|
|
assert_eq!(turn_cwd, selected_cwd);
|
|
assert_eq!(turn_context.config.cwd, selected_cwd);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn task_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_turn_with_default_settings("task".into(), Default::default())
|
|
.await;
|
|
|
|
assert!(turn_context.initial_environments.primary().is_none());
|
|
assert!(
|
|
turn_context
|
|
.initial_environments
|
|
.turn_environments()
|
|
.next()
|
|
.is_none()
|
|
);
|
|
#[allow(deprecated)]
|
|
let turn_cwd = turn_context.cwd.clone();
|
|
assert_eq!(turn_cwd, session_cwd);
|
|
assert_eq!(turn_context.config.cwd, session_cwd);
|
|
assert_eq!(
|
|
turn_context
|
|
.initial_environments
|
|
.turn_environments()
|
|
.count(),
|
|
0
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn primary_environment_uses_first_turn_environment() {
|
|
let (_session, mut turn_context) = make_session_and_context().await;
|
|
let first_environment = turn_context
|
|
.initial_environments
|
|
.primary()
|
|
.expect("primary environment")
|
|
.clone();
|
|
#[allow(deprecated)]
|
|
let second_cwd = turn_context.cwd.join("second");
|
|
let second_cwd_uri = codex_utils_path_uri::PathUri::from_abs_path(&second_cwd);
|
|
let first_environment_config = first_environment.config().clone();
|
|
turn_context
|
|
.initial_environments
|
|
.environments
|
|
.push(TurnEnvironmentState::Ready(TurnEnvironment::new(
|
|
TurnEnvironmentSelection {
|
|
selected_capability_roots: Default::default(),
|
|
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
|
|
.initial_environments
|
|
.primary()
|
|
.expect("primary environment")
|
|
.selection
|
|
.environment_id,
|
|
first_environment.selection.environment_id
|
|
);
|
|
assert_eq!(
|
|
turn_context
|
|
.initial_environments
|
|
.turn_environments()
|
|
.find(|environment| environment.selection.environment_id == "second")
|
|
.expect("second environment")
|
|
.cwd(),
|
|
&second_cwd_uri
|
|
);
|
|
assert_eq!(
|
|
turn_context
|
|
.initial_environments
|
|
.turn_environments()
|
|
.count(),
|
|
2
|
|
);
|
|
assert_eq!(
|
|
turn_context
|
|
.initial_environments
|
|
.turn_environments()
|
|
.nth(1)
|
|
.expect("second environment")
|
|
.cwd(),
|
|
&second_cwd_uri
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_turn_environments_clear_primary_environment() {
|
|
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
|
|
|
|
let (turn_context, _) = session
|
|
.new_turn_with_sub_id(
|
|
"sub-1".to_string(),
|
|
SessionSettingsUpdate {
|
|
environments: Some(TurnEnvironmentSelections::new(
|
|
session.get_config().await.cwd.clone(),
|
|
vec![],
|
|
)),
|
|
..Default::default()
|
|
},
|
|
Default::default(),
|
|
)
|
|
.await
|
|
.expect("turn should start");
|
|
|
|
assert!(turn_context.initial_environments.primary().is_none());
|
|
assert!(
|
|
turn_context
|
|
.initial_environments
|
|
.turn_environments()
|
|
.next()
|
|
.is_none()
|
|
);
|
|
#[allow(deprecated)]
|
|
let turn_cwd = turn_context.cwd.clone();
|
|
assert_eq!(turn_cwd, session.get_config().await.cwd);
|
|
assert_eq!(turn_context.config.cwd, session.get_config().await.cwd);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn spawn_task_turn_span_inherits_dispatch_trace_context() {
|
|
struct TraceCaptureTask {
|
|
captured_trace: Arc<std::sync::Mutex<Option<W3cTraceContext>>>,
|
|
}
|
|
|
|
impl SessionTask for TraceCaptureTask {
|
|
fn kind(&self) -> TaskKind {
|
|
TaskKind::Regular
|
|
}
|
|
|
|
fn span_name(&self) -> &'static str {
|
|
"session_task.trace_capture"
|
|
}
|
|
|
|
async fn run(
|
|
self: Arc<Self>,
|
|
_session: Arc<Session>,
|
|
_ctx: Arc<TurnContext>,
|
|
_input: Vec<TurnInput>,
|
|
_cancellation_token: CancellationToken,
|
|
) -> SessionTaskResult {
|
|
let mut trace = self
|
|
.captured_trace
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
*trace = current_span_w3c_trace_context();
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
let _trace_test_context = install_test_tracing("codex-core-tests");
|
|
|
|
let request_parent = W3cTraceContext {
|
|
traceparent: Some("00-00000000000000000000000000000011-0000000000000022-01".into()),
|
|
tracestate: Some("vendor=value".into()),
|
|
};
|
|
let request_span = tracing::info_span!("app_server.request");
|
|
assert!(set_parent_from_w3c_trace_context(
|
|
&request_span,
|
|
&request_parent
|
|
));
|
|
|
|
let submission_trace =
|
|
async { current_span_w3c_trace_context().expect("request span should have trace context") }
|
|
.instrument(request_span)
|
|
.await;
|
|
|
|
let dispatch_span = submission_dispatch_span(&Submission {
|
|
turn_extension_init: None,
|
|
id: "sub-1".into(),
|
|
op: Op::Interrupt,
|
|
parent_turn_id: None,
|
|
root_turn_id: None,
|
|
residency_guard: None,
|
|
message_delivery: 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 {
|
|
metadata: Default::default(),
|
|
content: vec![UserInput::Text {
|
|
text: "hello".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
client_id: None,
|
|
}],
|
|
TraceCaptureTask {
|
|
captured_trace: Arc::clone(&captured_trace),
|
|
},
|
|
)
|
|
.await;
|
|
}
|
|
.instrument(dispatch_span)
|
|
.await;
|
|
|
|
let evt = tokio::time::timeout(StdDuration::from_secs(2), rx.recv())
|
|
.await
|
|
.expect("timeout waiting for turn completion")
|
|
.expect("event");
|
|
assert!(matches!(evt.msg, EventMsg::TurnComplete(_)));
|
|
|
|
let task_trace = captured_trace
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.clone()
|
|
.expect("turn task should capture the current span trace context");
|
|
let submission_context =
|
|
codex_otel::context_from_w3c_trace_context(&submission_trace).expect("submission");
|
|
let task_context = codex_otel::context_from_w3c_trace_context(&task_trace).expect("task trace");
|
|
|
|
assert_eq!(
|
|
task_context.span().span_context().trace_id(),
|
|
submission_context.span().span_context().trace_id()
|
|
);
|
|
assert_ne!(
|
|
task_context.span().span_context().span_id(),
|
|
dispatch_span_id
|
|
);
|
|
}
|
|
|
|
#[cfg(debug_assertions)]
|
|
#[tokio::test]
|
|
async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() {
|
|
let (mut session, _turn_context) = make_session_and_context().await;
|
|
let store = Arc::new(codex_thread_store::InMemoryThreadStore::default());
|
|
let thread_store: Arc<dyn codex_thread_store::ThreadStore> = store.clone();
|
|
let config = session.get_config().await;
|
|
let live_thread = LiveThread::create(
|
|
Arc::clone(&thread_store),
|
|
CreateThreadParams {
|
|
attached_thread: false,
|
|
creator_user_id: None,
|
|
creator_account_id: None,
|
|
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!(session.services.model_client.responses_websocket_enabled());
|
|
session
|
|
.schedule_startup_prewarm(super::startup_prewarm::PrewarmInput::Base)
|
|
.await;
|
|
assert!(session.state.lock().await.startup_prewarm.is_none());
|
|
|
|
assert_eq!(
|
|
codex_thread_store::InMemoryThreadStoreCalls {
|
|
create_thread: 1,
|
|
shutdown_thread: 1,
|
|
..Default::default()
|
|
},
|
|
store.calls().await
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn submission_loop_channel_close_runs_full_thread_teardown() {
|
|
struct SessionStopMarker;
|
|
struct ThreadStopMarker;
|
|
|
|
struct ThreadStopRecorder {
|
|
calls: Arc<std::sync::atomic::AtomicUsize>,
|
|
expected_thread_id: ThreadId,
|
|
}
|
|
|
|
impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for ThreadStopRecorder {
|
|
fn on_thread_stop<'a>(
|
|
&'a self,
|
|
input: codex_extension_api::ThreadStopInput<'a>,
|
|
) -> codex_extension_api::ExtensionFuture<'a, ()> {
|
|
Box::pin(async move {
|
|
assert_eq!(
|
|
self.expected_thread_id.to_string(),
|
|
input.thread_store.level_id()
|
|
);
|
|
assert!(input.session_store.get::<SessionStopMarker>().is_some());
|
|
assert!(input.thread_store.get::<ThreadStopMarker>().is_some());
|
|
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
})
|
|
}
|
|
}
|
|
|
|
let (mut session, turn_context) = make_session_and_context().await;
|
|
let store = Arc::new(codex_thread_store::InMemoryThreadStore::default());
|
|
let thread_store: Arc<dyn codex_thread_store::ThreadStore> = store.clone();
|
|
let config = session.get_config().await;
|
|
let live_thread = LiveThread::create(
|
|
Arc::clone(&thread_store),
|
|
CreateThreadParams {
|
|
attached_thread: false,
|
|
creator_user_id: None,
|
|
creator_account_id: None,
|
|
session_id: session.session_id(),
|
|
thread_id: session.thread_id,
|
|
extra_config: None,
|
|
forked_from_id: None,
|
|
parent_thread_id: None,
|
|
source: SessionSource::Exec,
|
|
thread_source: None,
|
|
originator: "test_originator".to_string(),
|
|
base_instructions: BaseInstructions::default(),
|
|
dynamic_tools: Vec::new(),
|
|
selected_capability_roots: Vec::new(),
|
|
multi_agent_version: None,
|
|
history_mode: Default::default(),
|
|
subagent_history_start_ordinal: None,
|
|
history_base: None,
|
|
initial_window_id: Uuid::now_v7().to_string(),
|
|
runtime_workspace_roots: None,
|
|
metadata: ThreadPersistenceMetadata {
|
|
cwd: Some(config.cwd.to_path_buf()),
|
|
model_provider: config.model_provider_id.clone(),
|
|
memory_mode: if config.memories.generate_memories {
|
|
ThreadMemoryMode::Enabled
|
|
} else {
|
|
ThreadMemoryMode::Disabled
|
|
},
|
|
},
|
|
},
|
|
)
|
|
.await
|
|
.expect("create thread persistence");
|
|
session.services.thread_store = thread_store;
|
|
session.services.live_thread = Some(live_thread);
|
|
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
|
let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new();
|
|
builder.thread_lifecycle_contributor(Arc::new(ThreadStopRecorder {
|
|
calls: Arc::clone(&calls),
|
|
expected_thread_id: session.thread_id,
|
|
}));
|
|
session.services.extensions = Arc::new(builder.build());
|
|
session
|
|
.services
|
|
.session_extension_data
|
|
.insert(SessionStopMarker);
|
|
session
|
|
.services
|
|
.thread_extension_data
|
|
.insert(ThreadStopMarker);
|
|
|
|
let (tx_sub, rx_sub) = async_channel::bounded(1);
|
|
drop(tx_sub);
|
|
let session = Arc::new(session);
|
|
submission_loop(session, Arc::clone(&turn_context.config), rx_sub).await;
|
|
|
|
assert_eq!(1, calls.load(std::sync::atomic::Ordering::SeqCst));
|
|
assert_eq!(
|
|
codex_thread_store::InMemoryThreadStoreCalls {
|
|
create_thread: 1,
|
|
shutdown_thread: 1,
|
|
..Default::default()
|
|
},
|
|
store.calls().await
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn submission_loop_channel_close_aborts_active_turn_before_thread_stop_lifecycle() {
|
|
struct LifecycleRecorder {
|
|
calls: Arc<std::sync::Mutex<Vec<&'static str>>>,
|
|
expected_thread_id: ThreadId,
|
|
expected_turn_id: String,
|
|
}
|
|
|
|
impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for LifecycleRecorder {
|
|
fn on_thread_stop<'a>(
|
|
&'a self,
|
|
input: codex_extension_api::ThreadStopInput<'a>,
|
|
) -> codex_extension_api::ExtensionFuture<'a, ()> {
|
|
Box::pin(async move {
|
|
assert_eq!(
|
|
self.expected_thread_id.to_string(),
|
|
input.thread_store.level_id()
|
|
);
|
|
self.calls
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.push("thread_stop");
|
|
})
|
|
}
|
|
}
|
|
|
|
impl codex_extension_api::TurnLifecycleContributor for LifecycleRecorder {
|
|
fn on_turn_abort<'a>(
|
|
&'a self,
|
|
input: codex_extension_api::TurnAbortInput<'a>,
|
|
) -> codex_extension_api::ExtensionFuture<'a, ()> {
|
|
Box::pin(async move {
|
|
assert_eq!(
|
|
self.expected_thread_id.to_string(),
|
|
input.thread_store.level_id()
|
|
);
|
|
assert_eq!(self.expected_turn_id, input.turn_store.level_id());
|
|
assert_eq!(TurnAbortReason::Interrupted, input.reason);
|
|
self.calls
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.push("turn_abort");
|
|
})
|
|
}
|
|
}
|
|
|
|
let (mut session, turn_context) = make_session_and_context().await;
|
|
let calls = Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
let recorder = Arc::new(LifecycleRecorder {
|
|
calls: Arc::clone(&calls),
|
|
expected_thread_id: session.thread_id,
|
|
expected_turn_id: turn_context.sub_id.clone(),
|
|
});
|
|
let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new();
|
|
builder.thread_lifecycle_contributor(recorder.clone());
|
|
builder.turn_lifecycle_contributor(recorder);
|
|
session.services.extensions = Arc::new(builder.build());
|
|
|
|
let session = Arc::new(session);
|
|
session
|
|
.spawn_task(
|
|
Arc::new(turn_context),
|
|
Vec::new(),
|
|
NeverEndingTask {
|
|
kind: TaskKind::Regular,
|
|
listen_to_cancellation_token: true,
|
|
},
|
|
)
|
|
.await;
|
|
|
|
let (tx_sub, rx_sub) = async_channel::bounded(1);
|
|
drop(tx_sub);
|
|
submission_loop(Arc::clone(&session), session.get_config().await, rx_sub).await;
|
|
|
|
assert_eq!(
|
|
vec!["turn_abort", "thread_stop"],
|
|
*calls
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn shutdown_and_wait_allows_multiple_waiters() {
|
|
let (_session, _turn_context) = make_session_and_context().await;
|
|
let (tx_sub, rx_sub) = async_channel::bounded::<Submission>(4);
|
|
let (_tx_event, rx_event) = async_channel::unbounded();
|
|
let session_loop_handle = tokio::spawn(async move {
|
|
let shutdown = rx_sub.recv().await.expect("shutdown submission");
|
|
assert!(matches!(shutdown.op, Op::Shutdown));
|
|
tokio::time::sleep(StdDuration::from_millis(50)).await;
|
|
});
|
|
let io = Arc::new(SessionIo {
|
|
tx_sub,
|
|
rx_event,
|
|
agent_status: watch::channel(AgentStatus::PendingInit).1,
|
|
session_loop_termination: session_loop_termination_from_handle(session_loop_handle),
|
|
});
|
|
|
|
let waiter_1 = {
|
|
let io = Arc::clone(&io);
|
|
tokio::spawn(async move { io.shutdown_and_wait().await })
|
|
};
|
|
let waiter_2 = {
|
|
let io = Arc::clone(&io);
|
|
tokio::spawn(async move { io.shutdown_and_wait().await })
|
|
};
|
|
|
|
waiter_1
|
|
.await
|
|
.expect("first shutdown waiter join")
|
|
.expect("first shutdown waiter");
|
|
waiter_2
|
|
.await
|
|
.expect("second shutdown waiter join")
|
|
.expect("second shutdown waiter");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn shutdown_and_wait_waits_when_shutdown_is_already_in_progress() {
|
|
let (_session, _turn_context) = make_session_and_context().await;
|
|
let (tx_sub, rx_sub) = async_channel::bounded(4);
|
|
drop(rx_sub);
|
|
let (_tx_event, rx_event) = async_channel::unbounded();
|
|
let (shutdown_complete_tx, shutdown_complete_rx) = tokio::sync::oneshot::channel();
|
|
let session_loop_handle = tokio::spawn(async move {
|
|
let _ = shutdown_complete_rx.await;
|
|
});
|
|
let io = Arc::new(SessionIo {
|
|
tx_sub,
|
|
rx_event,
|
|
agent_status: watch::channel(AgentStatus::PendingInit).1,
|
|
session_loop_termination: session_loop_termination_from_handle(session_loop_handle),
|
|
});
|
|
|
|
let waiter = {
|
|
let io = Arc::clone(&io);
|
|
tokio::spawn(async move { io.shutdown_and_wait().await })
|
|
};
|
|
|
|
tokio::time::sleep(StdDuration::from_millis(10)).await;
|
|
assert!(!waiter.is_finished());
|
|
|
|
shutdown_complete_tx
|
|
.send(())
|
|
.expect("session loop should still be waiting to terminate");
|
|
|
|
waiter
|
|
.await
|
|
.expect("shutdown waiter join")
|
|
.expect("shutdown waiter");
|
|
}
|
|
|
|
pub(crate) async fn make_session_and_context_with_auth_and_config_and_rx<F>(
|
|
auth: CodexAuth,
|
|
dynamic_tools: Vec<DynamicToolSpec>,
|
|
configure_config: F,
|
|
) -> (
|
|
Arc<Session>,
|
|
Arc<TurnContext>,
|
|
async_channel::Receiver<Event>,
|
|
)
|
|
where
|
|
F: FnOnce(&mut Config),
|
|
{
|
|
let codex_home = tempfile::tempdir().expect("create temp dir");
|
|
make_session_and_context_with_auth_config_home_and_rx(
|
|
auth,
|
|
dynamic_tools,
|
|
codex_home.path(),
|
|
configure_config,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub(crate) 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 = LocalAgentControl::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 {
|
|
turn_extension_init: Default::default(),
|
|
environments: default_environments.clone(),
|
|
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(|| render_model_instructions(&model_info)),
|
|
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,
|
|
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: Arc::from(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::for_session(&session_configuration.session_source, &config.features);
|
|
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(),
|
|
ThreadEnvironmentDefaults::new(
|
|
session_configuration.inferred_environment_config(),
|
|
session_configuration.windows_sandbox_type,
|
|
),
|
|
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(),
|
|
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()),
|
|
granted_permissions_by_environment_id: Arc::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(),
|
|
local_agent_runtime: agent_control.runtime.clone(),
|
|
agent_control: Arc::new(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::ReasoningEffortOverride),
|
|
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),
|
|
/*output_token_replay_enabled*/ false,
|
|
/*attestation_provider*/ None,
|
|
config.http_client_factory(),
|
|
config.workspace_routing_context(),
|
|
Vec::new(),
|
|
),
|
|
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,
|
|
parent_observer_event_tx: OnceLock::new(),
|
|
agent_status: agent_status_tx,
|
|
state: Mutex::new(state),
|
|
thread_settings_persistence: Semaphore::new(/*permits*/ 1),
|
|
code_mode_message_tasks: Default::default(),
|
|
managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1),
|
|
features: config.features.clone(),
|
|
|
|
isolation: codex_extension_api::SessionIsolation::Inherit,
|
|
tool_policy: Arc::default(),
|
|
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,
|
|
history_initialization: HistoryInitialization::New,
|
|
forked_from_ordinal_exclusive: None,
|
|
next_internal_sub_id: AtomicU64::new(0),
|
|
});
|
|
let per_turn_config = session.build_per_turn_config(
|
|
&session_configuration,
|
|
resolved_turn_environments.local_selection(),
|
|
);
|
|
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),
|
|
Arc::clone(&session.services.granted_permissions_by_environment_id),
|
|
Some(Arc::clone(&auth_manager)),
|
|
&session_telemetry,
|
|
session_configuration.provider.clone(),
|
|
&session_configuration,
|
|
config.multi_agent_version_from_features(),
|
|
per_turn_config,
|
|
Arc::new(super::step_settings::ResolvedStepSettings::new(
|
|
Arc::clone(&session_configuration.step_settings),
|
|
Arc::new(model_info),
|
|
&config.features,
|
|
)),
|
|
&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
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cancelled_step_capture_finishes_warning_delivery() {
|
|
struct WarningProvider {
|
|
warnings: std::sync::Mutex<Vec<String>>,
|
|
}
|
|
|
|
impl codex_extension_api::ThreadInstructionsProvider for WarningProvider {
|
|
fn load_thread_instructions(&self) -> codex_extension_api::LoadInstructionsFuture<'_> {
|
|
Box::pin(async {
|
|
codex_extension_api::LoadedUserInstructions {
|
|
instructions: None,
|
|
warnings: std::mem::take(&mut *self.warnings.lock().expect("warnings")),
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
let warnings = vec![
|
|
"first provider warning".to_string(),
|
|
"second provider warning".to_string(),
|
|
];
|
|
let warning_fields = |event: Event| match event.msg {
|
|
EventMsg::Warning(warning) => (event.id, warning.message),
|
|
other => panic!("expected a warning, got {other:?}"),
|
|
};
|
|
let (mut session, mut turn) = make_session_and_context().await;
|
|
Arc::make_mut(&mut turn.config).project_doc_max_bytes = 0;
|
|
session.services.agents_md_manager = Arc::new(AgentsMdManager::new(SessionInstructions {
|
|
thread_provider: Some(Arc::new(WarningProvider {
|
|
warnings: std::sync::Mutex::new(warnings.clone()),
|
|
})),
|
|
..Default::default()
|
|
}));
|
|
attach_thread_persistence(&mut session).await;
|
|
let (tx, rx) = async_channel::bounded(/*cap*/ 1);
|
|
session.tx_event = tx;
|
|
let session = Arc::new(session);
|
|
let turn = Arc::new(turn);
|
|
let cancellation = CancellationToken::new();
|
|
let mut capture = Box::pin(tokio::task::unconstrained(
|
|
session.capture_step_context(Arc::clone(&turn), &cancellation),
|
|
));
|
|
let first_warning = tokio::select! {
|
|
_ = &mut capture => panic!("capture must wait to deliver both warnings"),
|
|
event = timeout(Duration::from_secs(5), rx.recv()) => {
|
|
event.expect("first warning arrives").expect("event channel open")
|
|
}
|
|
};
|
|
assert_eq!(
|
|
warning_fields(first_warning.clone()),
|
|
(INITIAL_SUBMIT_ID.to_owned(), warnings[0].clone()),
|
|
);
|
|
|
|
// Keep the second warning blocked while cancellation is observed.
|
|
session
|
|
.tx_event
|
|
.try_send(first_warning.clone())
|
|
.expect("fill event channel");
|
|
cancellation.cancel();
|
|
assert!(futures::poll!(capture.as_mut()).is_pending());
|
|
assert_eq!(
|
|
warning_fields(rx.recv().await.expect("release event delivery")),
|
|
warning_fields(first_warning),
|
|
);
|
|
let Err(error) = capture.await else {
|
|
panic!("cancelled step cannot be published");
|
|
};
|
|
assert!(matches!(error.details(), CodexErrorDetails::TurnAborted));
|
|
assert_eq!(
|
|
warning_fields(
|
|
rx.try_recv()
|
|
.expect("second warning delivered before returning")
|
|
),
|
|
(INITIAL_SUBMIT_ID.to_owned(), warnings[1].clone()),
|
|
);
|
|
session
|
|
.capture_step_context(turn, &CancellationToken::new())
|
|
.await
|
|
.expect("next capture succeeds without repeating consumed warnings");
|
|
assert!(rx.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_mcp_servers_uses_latest_state_for_existing_turns() {
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
let session = Arc::new(session);
|
|
let turn_context = Arc::new(turn_context);
|
|
let old_step = session
|
|
.capture_step_context(Arc::clone(&turn_context), &CancellationToken::new())
|
|
.await
|
|
.expect("a fresh cancellation token cannot be cancelled");
|
|
|
|
let refreshed_mcp_servers = serde_json::from_value::<HashMap<String, McpServerConfig>>(json!({
|
|
"refreshed": {
|
|
"url": "https://refreshed.example/mcp",
|
|
"enabled": false
|
|
}
|
|
}))
|
|
.expect("parse refreshed MCP servers");
|
|
{
|
|
let mut state = session.state.lock().await;
|
|
let mut config = (*state.session_configuration.original_config_do_not_use).clone();
|
|
config
|
|
.mcp_servers
|
|
.set(refreshed_mcp_servers.clone())
|
|
.expect("set refreshed MCP servers");
|
|
config.mcp_oauth_credentials_store_mode =
|
|
codex_config::types::OAuthCredentialsStoreMode::Auto;
|
|
config
|
|
.features
|
|
.set_enabled(Feature::SecretAuthStorage, /*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,
|
|
&[],
|
|
/*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"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refreshed_mcp_binding_captures_current_approval_authority() {
|
|
let (session, old_turn) = make_session_and_context().await;
|
|
let session = Arc::new(session);
|
|
let old_turn = Arc::new(old_turn);
|
|
let previous_policy = old_turn.approval_policy();
|
|
assert_ne!(previous_policy, AskForApproval::Never);
|
|
assert_eq!(
|
|
old_turn.config.permissions.approval_policy.value(),
|
|
previous_policy
|
|
);
|
|
let old_step = session
|
|
.capture_step_context(Arc::clone(&old_turn), &CancellationToken::new())
|
|
.await
|
|
.expect("capture initial sampling step");
|
|
|
|
session
|
|
.update_settings(SessionSettingsUpdate {
|
|
step_settings: StepSettingsUpdate {
|
|
approval_policy: Some(AskForApproval::Never),
|
|
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
|
..Default::default()
|
|
},
|
|
permission_profile: Some(PermissionProfile::Disabled),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.expect("approval settings should update");
|
|
session.refresh_mcp_if_dirty().await;
|
|
|
|
let binding = session
|
|
.services
|
|
.mcp_runtime
|
|
.current_binding()
|
|
.await
|
|
.expect("refreshed runtime should be available");
|
|
assert!(
|
|
!Arc::ptr_eq(&old_step.mcp, &binding),
|
|
"changed approval authority must invalidate the cached MCP binding"
|
|
);
|
|
let refreshed_step = session
|
|
.capture_step_context(Arc::clone(&old_turn), &CancellationToken::new())
|
|
.await
|
|
.expect("capture existing turn after its approval authority changes");
|
|
assert!(
|
|
Arc::ptr_eq(&binding, &refreshed_step.mcp),
|
|
"existing turns must use the MCP binding with current approval authority"
|
|
);
|
|
let config = binding.config();
|
|
assert_eq!(
|
|
(
|
|
config.approval_policy.value(),
|
|
&config.permission_profile,
|
|
config.approvals_reviewer,
|
|
),
|
|
(
|
|
AskForApproval::Never,
|
|
&PermissionProfile::Disabled,
|
|
ApprovalsReviewer::AutoReview,
|
|
)
|
|
);
|
|
assert_eq!(old_turn.approval_policy(), previous_policy);
|
|
assert_eq!(
|
|
old_turn.config.permissions.approval_policy.value(),
|
|
previous_policy
|
|
);
|
|
|
|
let new_turn = session.new_default_turn().await;
|
|
assert_eq!(new_turn.approval_policy(), AskForApproval::Never);
|
|
assert_eq!(
|
|
new_turn.config.permissions.approval_policy.value(),
|
|
AskForApproval::Never
|
|
);
|
|
}
|
|
|
|
#[test_case(false; "legacy thread reviewer")]
|
|
#[test_case(true; "explicit live reviewer")]
|
|
#[tokio::test]
|
|
async fn mcp_elicitation_reviewer_uses_active_reviewer_and_latest_runtime_policy(
|
|
live_update: bool,
|
|
) {
|
|
let guardian_server = start_mock_server().await;
|
|
mount_sse_once(
|
|
&guardian_server,
|
|
sse(vec![
|
|
ev_response_created("guardian-review"),
|
|
ev_assistant_message("guardian-review", r#"{"outcome":"allow"}"#),
|
|
ev_completed("guardian-review"),
|
|
]),
|
|
)
|
|
.await;
|
|
let (session, old_turn, rx) = make_session_and_context_with_auth_and_config_and_rx(
|
|
CodexAuth::from_api_key("Test API Key"),
|
|
Vec::new(),
|
|
|config| {
|
|
config.model_provider.base_url = Some(format!("{}/v1", guardian_server.uri()));
|
|
config
|
|
.mcp_servers
|
|
.set(
|
|
serde_json::from_value(json!({
|
|
"browser-use": { "command": "missing-test-mcp-server" }
|
|
}))
|
|
.expect("test MCP server configuration should deserialize"),
|
|
)
|
|
.expect("test MCP server should be configurable");
|
|
},
|
|
)
|
|
.await;
|
|
assert_eq!(old_turn.config.approvals_reviewer, ApprovalsReviewer::User);
|
|
let task = NeverEndingTask {
|
|
kind: TaskKind::Regular,
|
|
listen_to_cancellation_token: true,
|
|
};
|
|
session
|
|
.spawn_task(Arc::clone(&old_turn), Vec::new(), task)
|
|
.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::Decline,
|
|
content: None,
|
|
meta: Some(json!({ "approvals_reviewer": "auto_review" })),
|
|
})
|
|
);
|
|
|
|
session.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
|
let (next_turn, _) = session
|
|
.new_turn_with_sub_id(
|
|
"next-turn".to_string(),
|
|
SessionSettingsUpdate {
|
|
step_settings: StepSettingsUpdate {
|
|
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
},
|
|
Default::default(),
|
|
)
|
|
.await
|
|
.expect("next turn should start with the same reviewer");
|
|
session
|
|
.spawn_task(Arc::clone(&next_turn), Vec::new(), task)
|
|
.await;
|
|
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: Some(json!({ "approvals_reviewer": "auto_review" })),
|
|
})
|
|
);
|
|
|
|
let selection = session
|
|
.services
|
|
.turn_environments
|
|
.selections()
|
|
.into_iter()
|
|
.next()
|
|
.expect("session should select its executor environment");
|
|
let mut owner_config = next_turn
|
|
.initial_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.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
|
let restricted_turn = session
|
|
.new_turn_with_default_settings("owner-restricted".to_string(), Default::default())
|
|
.await;
|
|
session.spawn_task(restricted_turn, Vec::new(), task).await;
|
|
session.refresh_mcp_if_dirty().await;
|
|
assert_eq!(
|
|
session
|
|
.mcp_elicitation_reviewer()
|
|
.review(request)
|
|
.await
|
|
.expect("elicitation review should succeed"),
|
|
Some(ElicitationResponse {
|
|
action: ElicitationAction::Decline,
|
|
content: None,
|
|
meta: Some(json!({ "approvals_reviewer": "auto_review" })),
|
|
})
|
|
);
|
|
|
|
session.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cancelled_mcp_refresh_remains_pending() {
|
|
let (session, _turn_context) = make_session_and_context().await;
|
|
let session = Arc::new(session);
|
|
|
|
{
|
|
let _state = session.state.lock().await;
|
|
{
|
|
let mut refresh = Box::pin(session.refresh_mcp_if_dirty());
|
|
let mut context = std::task::Context::from_waker(futures::task::noop_waker_ref());
|
|
assert!(std::future::Future::poll(refresh.as_mut(), &mut context).is_pending());
|
|
assert!(
|
|
!session.mcp_refresh.is_pending(),
|
|
"the refresh should have claimed its pending invalidation"
|
|
);
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
session.mcp_refresh.is_pending(),
|
|
"a cancelled refresh must leave the runtime dirty"
|
|
);
|
|
|
|
session.refresh_mcp_if_dirty().await;
|
|
assert!(
|
|
!session.mcp_refresh.is_pending(),
|
|
"the next refresh should publish the pending runtime"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_elicitation_reviewer_is_reused_across_runtime_refreshes() {
|
|
let (session, _turn_context) = make_session_and_context().await;
|
|
let session = Arc::new(session);
|
|
let previous = session.mcp_elicitation_reviewer();
|
|
|
|
session.mark_mcp_runtime_dirty();
|
|
session.refresh_mcp_if_dirty().await;
|
|
|
|
assert!(Arc::ptr_eq(&previous, &session.mcp_elicitation_reviewer()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_policy_changes_schedule_runtime_refresh() {
|
|
let (session, _turn_context) = make_session_and_context().await;
|
|
let session = Arc::new(session);
|
|
|
|
session
|
|
.new_turn_with_sub_id(
|
|
"policy-change".to_string(),
|
|
SessionSettingsUpdate {
|
|
step_settings: StepSettingsUpdate {
|
|
approval_policy: Some(AskForApproval::Never),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
},
|
|
Default::default(),
|
|
)
|
|
.await
|
|
.expect("approval policy update should succeed");
|
|
|
|
assert!(session.mcp_refresh.is_pending());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_refresh_detects_shared_auth_manager_changes() {
|
|
let (session, _turn_context) = make_session_and_context().await;
|
|
let session = Arc::new(session);
|
|
|
|
assert_eq!(
|
|
session.services.plugins_manager.auth_mode(),
|
|
Some(codex_protocol::auth::AuthMode::ApiKey)
|
|
);
|
|
session.refresh_mcp_if_dirty().await;
|
|
assert!(
|
|
session
|
|
.services
|
|
.mcp_runtime
|
|
.current_auth_matches(session.services.auth_manager.auth_cached().as_ref())
|
|
);
|
|
|
|
session
|
|
.services
|
|
.auth_manager
|
|
.logout()
|
|
.await
|
|
.expect("logout should succeed");
|
|
assert_eq!(session.services.plugins_manager.auth_mode(), None);
|
|
assert!(
|
|
!session
|
|
.services
|
|
.mcp_runtime
|
|
.current_auth_matches(session.services.auth_manager.auth_cached().as_ref())
|
|
);
|
|
|
|
session.refresh_mcp_if_dirty().await;
|
|
|
|
assert!(
|
|
session
|
|
.services
|
|
.mcp_runtime
|
|
.current_binding()
|
|
.await
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
session
|
|
.services
|
|
.mcp_runtime
|
|
.current_auth_matches(session.services.auth_manager.auth_cached().as_ref())
|
|
);
|
|
}
|
|
|
|
/// A ready registry entry alone must not expose roots outside the turn's environment selection.
|
|
#[tokio::test]
|
|
async fn capability_roots_require_turn_environment_selection() {
|
|
let (mut session, turn_context) = make_session_and_context().await;
|
|
let mut environment = turn_context
|
|
.initial_environments
|
|
.primary()
|
|
.expect("ready local environment")
|
|
.clone();
|
|
let root = codex_protocol::capabilities::SelectedCapabilityRoot {
|
|
id: "selected-root".to_string(),
|
|
location: codex_protocol::capabilities::CapabilityRootLocation::Environment {
|
|
environment_id: environment.selection.environment_id.clone(),
|
|
path: environment.cwd().clone(),
|
|
},
|
|
};
|
|
session.services.selected_capability_roots = vec![root.clone()];
|
|
environment.selection = TurnEnvironmentSelection::new(
|
|
environment.selection.into_request(),
|
|
std::slice::from_ref(&root),
|
|
);
|
|
let selected_environments = TurnEnvironmentSnapshot {
|
|
environments: vec![TurnEnvironmentState::Ready(environment)],
|
|
};
|
|
|
|
let unselected_roots = session
|
|
.resolve_selected_capability_roots_for_step(&TurnEnvironmentSnapshot::default())
|
|
.await;
|
|
assert!(unselected_roots.is_empty());
|
|
|
|
let selected_roots = session
|
|
.resolve_selected_capability_roots_for_step(&selected_environments)
|
|
.await;
|
|
assert_eq!(
|
|
selected_roots
|
|
.iter()
|
|
.map(|root| root.selected_root().clone())
|
|
.collect::<Vec<_>>(),
|
|
vec![root]
|
|
);
|
|
}
|
|
|
|
#[tracing_test::traced_test]
|
|
#[tokio::test]
|
|
async fn conflicting_ready_environment_root_ids_keep_first_location() {
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
let selected_root =
|
|
|environment_id: &str, path: &str| codex_protocol::capabilities::SelectedCapabilityRoot {
|
|
id: "shared-root".to_string(),
|
|
location: codex_protocol::capabilities::CapabilityRootLocation::Environment {
|
|
environment_id: environment_id.to_string(),
|
|
path: PathUri::parse(path).expect("root URI"),
|
|
},
|
|
};
|
|
let selected_roots = [
|
|
selected_root("executor-a", "file:///plugins/a"),
|
|
selected_root("executor-b", "file:///plugins/b"),
|
|
];
|
|
let local_environment = turn_context
|
|
.initial_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 {
|
|
selected_capability_roots: Default::default(),
|
|
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.
|
|
#[tokio::test]
|
|
async fn capability_discovery_uses_environment_permission_profile() {
|
|
let (session, mut turn_context) = make_session_and_context().await;
|
|
let config = Arc::make_mut(&mut turn_context.config);
|
|
config
|
|
.permissions
|
|
.set_permission_profile(PermissionProfile::Disabled)
|
|
.expect("unrestricted permission profile should be allowed");
|
|
config.permissions.windows_sandbox_mode = Some(WindowsSandboxModeToml::Unelevated);
|
|
config
|
|
.features
|
|
.disable(Feature::UseLegacyLandlock)
|
|
.expect("disable legacy Landlock");
|
|
turn_context.windows_sandbox_level = WindowsSandboxLevel::RestrictedToken;
|
|
let mut environment = turn_context
|
|
.initial_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.use_legacy_landlock = true;
|
|
let expected_sandbox = FileSystemSandboxContext {
|
|
sandbox_override: SandboxOverride::NoOverride,
|
|
permissions: environment.permission_profile().clone(),
|
|
cwd: 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_proxy_settings_mode: None,
|
|
use_legacy_landlock: true,
|
|
};
|
|
let environment_id = environment.selection.environment_id.clone();
|
|
turn_context.initial_environments.environments[0] = TurnEnvironmentState::Ready(environment);
|
|
|
|
let (discovery, _) = session
|
|
.executor_capability_discovery_for_step(
|
|
&turn_context.config,
|
|
/*resolved_roots*/ &[],
|
|
&turn_context.initial_environments,
|
|
/*v2_snapshots*/ None,
|
|
)
|
|
.await;
|
|
let discovery = discovery.expect("restricted environment should trigger capability discovery");
|
|
|
|
assert_eq!(
|
|
discovery.sandbox_contexts().get(&environment_id),
|
|
Some(&expected_sandbox)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn step_context_keeps_its_mcp_runtime_for_tools() -> anyhow::Result<()> {
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
let session = Arc::new(session);
|
|
let turn_context = Arc::new(turn_context);
|
|
let step_context = session
|
|
.capture_step_context(turn_context, &CancellationToken::new())
|
|
.await?;
|
|
|
|
let mut added_servers = 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,
|
|
startup_readiness: Default::default(),
|
|
supports_parallel_tool_calls: false,
|
|
tool_input_schema_max_bytes: None,
|
|
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(),
|
|
},
|
|
)]);
|
|
let ordinary: McpServerConfig = serde_json::from_value(json!({
|
|
"url": "https://ordinary.example/mcp",
|
|
"enabled": false,
|
|
}))?;
|
|
let mut current_config = session.get_config().await.as_ref().clone();
|
|
current_config.plugins = toml::from_str("[_default]\nenabled = false\n")?;
|
|
current_config
|
|
.mcp_servers
|
|
.set(HashMap::from([("ordinary".to_string(), ordinary.clone())]))?;
|
|
session
|
|
.state
|
|
.lock()
|
|
.await
|
|
.session_configuration
|
|
.original_config_do_not_use = Arc::new(current_config.clone());
|
|
let mut stale_ordinary = ordinary;
|
|
stale_ordinary.enabled = true;
|
|
added_servers.insert("ordinary".to_string(), stale_ordinary);
|
|
let mut expected_servers = codex_mcp::configured_mcp_servers(step_context.mcp.config());
|
|
expected_servers.extend(current_config.mcp_servers.get().clone());
|
|
expected_servers.insert("newer".to_string(), added_servers["newer"].clone());
|
|
session
|
|
.refresh_mcp_servers_now(added_servers, /*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_eq!(
|
|
codex_mcp::configured_mcp_servers(next_step.mcp.config()),
|
|
expected_servers
|
|
);
|
|
assert_eq!(next_step.mcp.config().plugins, current_config.plugins);
|
|
assert_eq!(session.get_config().await.plugins, current_config.plugins);
|
|
assert_eq!(
|
|
step_context.mcp.config().plugins,
|
|
step_context.turn.config.plugins
|
|
);
|
|
assert_ne!(step_context.mcp.config().plugins, current_config.plugins);
|
|
|
|
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_eq!(
|
|
codex_mcp::configured_mcp_servers(current.config()),
|
|
expected_servers
|
|
);
|
|
assert_eq!(current.config().plugins, current_config.plugins);
|
|
|
|
let router = &step_context.tool_router;
|
|
assert!(
|
|
!router
|
|
.registered_tool_names_for_test()
|
|
.iter()
|
|
.any(|name| name.to_string() == "list_mcp_resources")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_dependency_refresh_preserves_current_server_constraints() -> anyhow::Result<()> {
|
|
let (session, _) = make_session_and_context().await;
|
|
let mut config = session.get_config().await.as_ref().clone();
|
|
config.mcp_servers = crate::config::Constrained::allow_only(config.mcp_servers.get().clone());
|
|
let config = Arc::new(config);
|
|
session
|
|
.state
|
|
.lock()
|
|
.await
|
|
.session_configuration
|
|
.original_config_do_not_use = Arc::clone(&config);
|
|
|
|
session
|
|
.refresh_mcp_servers_now(
|
|
HashMap::from([(
|
|
"dependency".to_string(),
|
|
serde_json::from_value(json!({"command": "missing-test-mcp-server"}))?,
|
|
)]),
|
|
/*elicitation_reviewer*/ None,
|
|
)
|
|
.await;
|
|
|
|
assert!(Arc::ptr_eq(&session.get_config().await, &config));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn spawn_task_does_not_update_previous_turn_settings_for_non_run_turn_tasks() {
|
|
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
|
|
sess.set_previous_turn_settings(/*previous_turn_settings*/ None)
|
|
.await;
|
|
let input = vec![TurnInput::UserInput {
|
|
metadata: Default::default(),
|
|
content: vec![UserInput::Text {
|
|
text: "hello".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
client_id: None,
|
|
}];
|
|
|
|
sess.spawn_task(
|
|
Arc::clone(&tc),
|
|
input,
|
|
NeverEndingTask {
|
|
kind: TaskKind::Regular,
|
|
listen_to_cancellation_token: true,
|
|
},
|
|
)
|
|
.await;
|
|
|
|
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
|
assert_eq!(sess.previous_turn_settings().await, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_context_updates_emits_environment_item_for_network_changes() {
|
|
let (session, previous_context) = make_session_and_context().await;
|
|
let previous_context = Arc::new(previous_context);
|
|
let mut current_context = previous_context
|
|
.with_model(
|
|
previous_context.model_info().slug.clone(),
|
|
&session.services.models_manager,
|
|
)
|
|
.await;
|
|
|
|
let mut config = (*current_context.config).clone();
|
|
let mut requirements = config.config_layer_stack.requirements().clone();
|
|
requirements.network = Some(Sourced::new(
|
|
NetworkConstraints {
|
|
domains: Some(NetworkDomainPermissionsToml {
|
|
entries: std::collections::BTreeMap::from([
|
|
(
|
|
"api.example.com".to_string(),
|
|
NetworkDomainPermissionToml::Allow,
|
|
),
|
|
(
|
|
"blocked.example.com".to_string(),
|
|
NetworkDomainPermissionToml::Deny,
|
|
),
|
|
]),
|
|
}),
|
|
..Default::default()
|
|
},
|
|
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
|
));
|
|
let layers = config
|
|
.config_layer_stack
|
|
.all_layers_low_to_high()
|
|
.cloned()
|
|
.collect();
|
|
config.config_layer_stack = ConfigLayerStack::new(
|
|
layers,
|
|
requirements,
|
|
config.config_layer_stack.requirements_toml().clone(),
|
|
)
|
|
.expect("rebuild config layer stack with network requirements");
|
|
current_context.config = Arc::new(config);
|
|
|
|
let update_items =
|
|
record_context_update_items(&session, previous_context, current_context).await;
|
|
|
|
let environment_update = user_input_texts(&update_items)
|
|
.into_iter()
|
|
.find(|text| text.contains("<environment_context>"))
|
|
.expect("environment update item should be emitted");
|
|
assert!(environment_update.contains(
|
|
"<network enabled=\"true\"><allowed>api.example.com</allowed><denied>blocked.example.com</denied></network>"
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_context_updates_emits_environment_item_for_cwd_changes() {
|
|
let (session, previous_context) = make_session_and_context().await;
|
|
let previous_context = Arc::new(previous_context);
|
|
let mut current_context = previous_context
|
|
.with_model(
|
|
previous_context.model_info().slug.clone(),
|
|
&session.services.models_manager,
|
|
)
|
|
.await;
|
|
let cwd = test_path_buf("/new-repo").abs();
|
|
let environment = current_context
|
|
.initial_environments
|
|
.primary()
|
|
.expect("primary environment")
|
|
.clone();
|
|
let environment_config = environment.config().clone();
|
|
current_context.initial_environments.environments[0] =
|
|
TurnEnvironmentState::Ready(TurnEnvironment::new(
|
|
TurnEnvironmentSelection {
|
|
selected_capability_roots: Default::default(),
|
|
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>"));
|
|
}
|
|
|
|
#[test_case::test_case(true; "with_time")]
|
|
#[test_case::test_case(false; "without_time")]
|
|
#[tokio::test]
|
|
async fn record_context_updates_use_environment_permission_profile_and_workspace_roots(
|
|
include_time: bool,
|
|
) {
|
|
let (session, mut previous_context) = make_session_and_context().await;
|
|
Arc::make_mut(&mut previous_context.config).include_environment_context_time = include_time;
|
|
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
|
|
.initial_environments
|
|
.primary()
|
|
.expect("primary environment")
|
|
.clone();
|
|
previous_environment.config_mut().permission_profile =
|
|
PermissionProfileSnapshot::legacy(PermissionProfile::Disabled);
|
|
previous_context.initial_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
|
|
.initial_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.initial_environments.environments[0] =
|
|
TurnEnvironmentState::Ready(TurnEnvironment::new(
|
|
TurnEnvironmentSelection {
|
|
selected_capability_roots: Default::default(),
|
|
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}"
|
|
);
|
|
assert_eq!(environment_update.contains("<current_date"), include_time);
|
|
assert_eq!(environment_update.contains("<timezone"), include_time);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_context_updates_emits_environment_item_for_time_changes() {
|
|
let (session, previous_context) = make_session_and_context().await;
|
|
let previous_context = Arc::new(previous_context);
|
|
let mut current_context = previous_context
|
|
.with_model(
|
|
previous_context.model_info().slug.clone(),
|
|
&session.services.models_manager,
|
|
)
|
|
.await;
|
|
current_context.timezone = Some("Europe/Berlin".to_string());
|
|
|
|
let update_items =
|
|
record_context_update_items(&session, previous_context, current_context).await;
|
|
|
|
let environment_update = user_input_texts(&update_items)
|
|
.into_iter()
|
|
.find(|text| text.contains("<environment_context>"))
|
|
.expect("environment update item should be emitted");
|
|
let current_date = chrono::Local::now().format("%Y-%m-%d").to_string();
|
|
assert!(environment_update.contains(&format!("<current_date>{current_date}</current_date>")));
|
|
assert!(environment_update.contains("<timezone>Europe/Berlin</timezone>"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_context_updates_omits_environment_item_when_disabled() {
|
|
let (session, previous_context) = make_session_and_context().await;
|
|
let previous_context = Arc::new(previous_context);
|
|
let mut current_context = previous_context
|
|
.with_model(
|
|
previous_context.model_info().slug.clone(),
|
|
&session.services.models_manager,
|
|
)
|
|
.await;
|
|
let mut config = (*current_context.config).clone();
|
|
config.include_environment_context = false;
|
|
current_context.config = Arc::new(config);
|
|
let environment = current_context
|
|
.initial_environments
|
|
.primary()
|
|
.expect("primary environment")
|
|
.clone();
|
|
let environment_config = environment.config().clone();
|
|
current_context.initial_environments.environments[0] =
|
|
TurnEnvironmentState::Ready(TurnEnvironment::new(
|
|
TurnEnvironmentSelection {
|
|
selected_capability_roots: Default::default(),
|
|
environment_id: environment.selection.environment_id,
|
|
cwd: PathUri::from_abs_path(&test_path_buf("/new-repo").abs()),
|
|
workspace_roots: Vec::new(),
|
|
config: EnvironmentConfigState::Ready(environment_config),
|
|
},
|
|
environment.config_origin,
|
|
environment.environment,
|
|
environment.shell,
|
|
));
|
|
|
|
let update_items =
|
|
record_context_update_items(&session, previous_context, current_context).await;
|
|
|
|
let user_texts = user_input_texts(&update_items);
|
|
assert!(
|
|
!user_texts
|
|
.iter()
|
|
.any(|text| text.contains("<environment_context>")),
|
|
"did not expect environment context updates when disabled, got {user_texts:?}"
|
|
);
|
|
}
|
|
|
|
async fn record_context_update_items(
|
|
session: &Session,
|
|
previous_context: Arc<TurnContext>,
|
|
current_context: TurnContext,
|
|
) -> Vec<ResponseItem> {
|
|
let previous_step = StepContext::for_test(previous_context);
|
|
session
|
|
.record_context_updates_and_set_reference_context_item(&previous_step)
|
|
.await
|
|
.expect("world state should build");
|
|
let previous_len = session.clone_history().await.raw_items().len();
|
|
|
|
let current_step = StepContext::for_test(Arc::new(current_context));
|
|
session
|
|
.record_context_updates_and_set_reference_context_item(¤t_step)
|
|
.await
|
|
.expect("world state should build");
|
|
let history = session.clone_history().await;
|
|
history.raw_items().skip(previous_len).cloned().collect()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_context_updates_emits_realtime_start_when_session_becomes_live() {
|
|
let (session, previous_context) = make_session_and_context().await;
|
|
let previous_context = Arc::new(previous_context);
|
|
let mut current_context = previous_context
|
|
.with_model(
|
|
previous_context.model_info().slug.clone(),
|
|
&session.services.models_manager,
|
|
)
|
|
.await;
|
|
current_context.realtime_active = true;
|
|
|
|
let update_items =
|
|
record_context_update_items(&session, previous_context, current_context).await;
|
|
|
|
let developer_texts = developer_input_texts(&update_items);
|
|
assert!(
|
|
developer_texts
|
|
.iter()
|
|
.any(|text| text.contains("<realtime_conversation>")),
|
|
"expected a realtime start update, got {developer_texts:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_context_updates_emits_realtime_end_when_session_stops_being_live() {
|
|
let (session, mut previous_context) = make_session_and_context().await;
|
|
previous_context.realtime_active = true;
|
|
let mut current_context = previous_context
|
|
.with_model(
|
|
previous_context.model_info().slug.clone(),
|
|
&session.services.models_manager,
|
|
)
|
|
.await;
|
|
current_context.realtime_active = false;
|
|
|
|
let update_items =
|
|
record_context_update_items(&session, Arc::new(previous_context), current_context).await;
|
|
|
|
let developer_texts = developer_input_texts(&update_items);
|
|
assert!(
|
|
developer_texts
|
|
.iter()
|
|
.any(|text| text.contains("<realtime_conversation>")),
|
|
"expected a realtime end update, got {developer_texts:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_initial_context_reuses_in_flight_recommendation_prewarm() {
|
|
use wiremock::Mock;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
use wiremock::matchers::query_param;
|
|
|
|
core_test_support::skip_if_no_network!();
|
|
|
|
let server = start_mock_server().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/ps/plugins/suggested/codex"))
|
|
.and(query_param("scope", "GLOBAL"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"enabled": true,
|
|
"plugins": [{
|
|
"id": "plugin_github",
|
|
"name": "github",
|
|
"display_name": "GitHub"
|
|
}]
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
let (session, turn_context, _rx_event) = make_session_and_context_with_auth_and_config_and_rx(
|
|
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
|
/*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);
|
|
let initial_context =
|
|
crate::context_manager::updates::merge_world_state_updates(initial_context);
|
|
assert_eq!(
|
|
developer_input_texts(&initial_context)
|
|
.into_iter()
|
|
.filter(|text| text.starts_with("<recommended_plugins>"))
|
|
.collect::<Vec<_>>(),
|
|
vec![concat!(
|
|
"<recommended_plugins>\n",
|
|
"Here is a list of plugins that are available but not installed.\n\n",
|
|
"- GitHub (github@openai-curated-remote)\n",
|
|
"</recommended_plugins>",
|
|
)]
|
|
);
|
|
server.verify().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_initial_context_describes_active_realtime_state() {
|
|
let (session, mut turn_context) = make_session_and_context().await;
|
|
turn_context.realtime_active = true;
|
|
let turn_context = Arc::new(turn_context);
|
|
|
|
let initial_context = build_initial_context(&session, &turn_context).await;
|
|
let developer_texts = developer_input_texts(&initial_context);
|
|
assert!(
|
|
developer_texts
|
|
.iter()
|
|
.any(|text| text.contains("<realtime_conversation>")),
|
|
"expected initial context to describe active realtime state, got {developer_texts:?}"
|
|
);
|
|
}
|
|
|
|
async fn make_multi_agent_v2_usage_hint_test_session(
|
|
enable_multi_agent_v2: bool,
|
|
) -> (Arc<Session>, Arc<TurnContext>) {
|
|
let (session, turn_context, _rx_event) = make_session_and_context_with_auth_and_config_and_rx(
|
|
CodexAuth::from_api_key("Test API Key"),
|
|
Vec::new(),
|
|
|config| {
|
|
if enable_multi_agent_v2 {
|
|
let _ = config.features.enable(Feature::MultiAgentV2);
|
|
}
|
|
config.multi_agent_v2.root_agent_usage_hint_text = Some("Root guidance.".to_string());
|
|
config.multi_agent_v2.subagent_usage_hint_text = Some("Subagent guidance.".to_string());
|
|
},
|
|
)
|
|
.await;
|
|
(session, turn_context)
|
|
}
|
|
|
|
struct PromptExtensionTestContributor;
|
|
struct PromptExtensionTestState;
|
|
struct TurnContextExtensionTestContributor;
|
|
struct TurnContextExtensionTestState {
|
|
expected_model_context_window: Option<i64>,
|
|
}
|
|
|
|
impl codex_extension_api::ContextContributor for PromptExtensionTestContributor {
|
|
fn contribute_thread_context<'a>(
|
|
&'a self,
|
|
_session_store: &'a codex_extension_api::ExtensionData,
|
|
thread_store: &'a codex_extension_api::ExtensionData,
|
|
) -> std::pin::Pin<
|
|
Box<dyn std::future::Future<Output = Vec<codex_extension_api::PromptFragment>> + Send + 'a>,
|
|
> {
|
|
Box::pin(async move {
|
|
thread_store
|
|
.get::<PromptExtensionTestState>()
|
|
.is_some()
|
|
.then(|| {
|
|
codex_extension_api::PromptFragment::developer_policy(
|
|
"prompt extension enabled",
|
|
codex_extension_api::ContentItemKind("test.prompt_extension".to_string()),
|
|
)
|
|
})
|
|
.into_iter()
|
|
.collect()
|
|
})
|
|
}
|
|
}
|
|
|
|
fn prompt_extension_test_registry()
|
|
-> Arc<codex_extension_api::ExtensionRegistry<crate::config::Config>> {
|
|
let mut builder = codex_extension_api::ExtensionRegistryBuilder::new();
|
|
builder.prompt_contributor(Arc::new(PromptExtensionTestContributor));
|
|
Arc::new(builder.build())
|
|
}
|
|
|
|
impl codex_extension_api::ContextContributor for TurnContextExtensionTestContributor {
|
|
fn contribute_turn_context<'a>(
|
|
&'a self,
|
|
input: codex_extension_api::TurnContextContributionInput<'a>,
|
|
) -> std::pin::Pin<
|
|
Box<dyn std::future::Future<Output = Vec<codex_extension_api::PromptFragment>> + Send + 'a>,
|
|
> {
|
|
Box::pin(async move {
|
|
let Some(state) = input.turn_store.get::<TurnContextExtensionTestState>() else {
|
|
return Vec::new();
|
|
};
|
|
(input.model_context_window == state.expected_model_context_window
|
|
&& input.model_context_window.is_some()
|
|
&& !input.turn_id.is_empty())
|
|
.then(|| {
|
|
codex_extension_api::PromptFragment::developer_policy(
|
|
"turn context extension enabled",
|
|
codex_extension_api::ContentItemKind("test.turn_context".to_string()),
|
|
)
|
|
})
|
|
.into_iter()
|
|
.collect()
|
|
})
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_initial_context_includes_prompt_fragments_from_extensions() {
|
|
let (mut session, turn_context) = make_session_and_context().await;
|
|
session.services.extensions = prompt_extension_test_registry();
|
|
session
|
|
.services
|
|
.thread_extension_data
|
|
.insert(PromptExtensionTestState);
|
|
let turn_context = Arc::new(turn_context);
|
|
|
|
let initial_context = build_initial_context(&session, &turn_context).await;
|
|
let developer_messages = developer_message_texts(&initial_context);
|
|
|
|
assert!(
|
|
developer_messages
|
|
.iter()
|
|
.flatten()
|
|
.any(|text| *text == "prompt extension enabled"),
|
|
"expected prompt extension developer text, got {developer_messages:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_initial_context_includes_turn_context_fragments_from_extensions() {
|
|
let (mut session, mut turn_context) = make_session_and_context().await;
|
|
let mut builder = codex_extension_api::ExtensionRegistryBuilder::new();
|
|
builder.prompt_contributor(Arc::new(TurnContextExtensionTestContributor));
|
|
session.services.extensions = Arc::new(builder.build());
|
|
update_turn_settings_for_test(&mut turn_context, |settings| {
|
|
Arc::make_mut(&mut settings.model_info).context_window = Some(100);
|
|
Arc::make_mut(&mut settings.model_info).effective_context_window_percent = 50;
|
|
});
|
|
turn_context
|
|
.extension_data
|
|
.insert(TurnContextExtensionTestState {
|
|
expected_model_context_window: Some(50),
|
|
});
|
|
let turn_context = Arc::new(turn_context);
|
|
|
|
let initial_context = build_initial_context(&session, &turn_context).await;
|
|
let developer_messages = developer_message_texts(&initial_context);
|
|
|
|
assert!(
|
|
developer_messages
|
|
.iter()
|
|
.flatten()
|
|
.any(|text| *text == "turn context extension enabled"),
|
|
"expected turn context extension developer text, got {developer_messages:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_context_updates_includes_turn_context_fragments_on_steady_state_turns() {
|
|
let (mut session, mut turn_context) = make_session_and_context().await;
|
|
let mut builder = codex_extension_api::ExtensionRegistryBuilder::new();
|
|
builder.prompt_contributor(Arc::new(TurnContextExtensionTestContributor));
|
|
session.services.extensions = Arc::new(builder.build());
|
|
update_turn_settings_for_test(&mut turn_context, |settings| {
|
|
Arc::make_mut(&mut settings.model_info).context_window = Some(200);
|
|
Arc::make_mut(&mut settings.model_info).effective_context_window_percent = 25;
|
|
});
|
|
turn_context
|
|
.extension_data
|
|
.insert(TurnContextExtensionTestState {
|
|
expected_model_context_window: Some(50),
|
|
});
|
|
let mut previous_context_item = turn_context.to_turn_context_item();
|
|
previous_context_item.turn_id = Some("previous-turn-id".to_string());
|
|
let turn_context = Arc::new(turn_context);
|
|
let world_state = build_world_state_from_turn_context(&session, &turn_context).await;
|
|
{
|
|
let mut state = session.state.lock().await;
|
|
state.set_reference_context_item(Some(previous_context_item));
|
|
state
|
|
.history
|
|
.set_world_state_baseline(world_state.render_full().0);
|
|
}
|
|
|
|
let step_context = StepContext::for_test(Arc::clone(&turn_context));
|
|
session
|
|
.record_context_updates_and_set_reference_context_item(&step_context)
|
|
.await
|
|
.expect("world state should build");
|
|
|
|
let history = session.clone_history().await;
|
|
let history_items = raw_history_items(&history);
|
|
let developer_messages = developer_message_texts(&history_items);
|
|
assert!(
|
|
developer_messages
|
|
.iter()
|
|
.flatten()
|
|
.any(|text| *text == "turn context extension enabled"),
|
|
"expected steady-state turn context extension developer text, got {developer_messages:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_initial_context_omits_prompt_fragments_without_extension_state() {
|
|
let (mut session, turn_context) = make_session_and_context().await;
|
|
session.services.extensions = prompt_extension_test_registry();
|
|
let turn_context = Arc::new(turn_context);
|
|
|
|
let initial_context = build_initial_context(&session, &turn_context).await;
|
|
let developer_messages = developer_message_texts(&initial_context);
|
|
|
|
assert!(
|
|
!developer_messages
|
|
.iter()
|
|
.flatten()
|
|
.any(|text| *text == "prompt extension enabled"),
|
|
"did not expect prompt extension developer text, got {developer_messages:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_initial_context_adds_multi_agent_v2_root_usage_hint_as_developer_message() {
|
|
let (session, turn_context) =
|
|
make_multi_agent_v2_usage_hint_test_session(/*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:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_initial_context_adds_multi_agent_v2_subagent_usage_hint_as_developer_message() {
|
|
let (session, mut turn_context) =
|
|
make_multi_agent_v2_usage_hint_test_session(/*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().any(|message| message
|
|
.concat()
|
|
.contains("<context_window>\nAgent name: /root/worker\n")),
|
|
"expected subagent context window to include its canonical name, got {developer_messages:?}"
|
|
);
|
|
assert!(
|
|
developer_messages
|
|
.iter()
|
|
.any(|message| message.as_slice() == ["Subagent guidance."]),
|
|
"expected standalone subagent usage hint developer message, got {developer_messages:?}"
|
|
);
|
|
assert!(
|
|
!developer_messages
|
|
.iter()
|
|
.any(|message| message.as_slice() == ["Root guidance."]),
|
|
"did not expect root usage hint for subagent thread, got {developer_messages:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_feature_disabled() {
|
|
let (session, turn_context) =
|
|
make_multi_agent_v2_usage_hint_test_session(/*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:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_hint_is_empty() {
|
|
let (session, turn_context, _rx_event) = make_session_and_context_with_auth_and_config_and_rx(
|
|
CodexAuth::from_api_key("Test API Key"),
|
|
Vec::new(),
|
|
|config| {
|
|
let _ = config.features.enable(Feature::MultiAgentV2);
|
|
config.multi_agent_v2.root_agent_usage_hint_text = Some(String::new());
|
|
config.multi_agent_v2.subagent_usage_hint_text = Some(String::new());
|
|
},
|
|
)
|
|
.await;
|
|
|
|
let initial_context = build_initial_context(&session, &turn_context).await;
|
|
|
|
let developer_messages = developer_message_texts(&initial_context);
|
|
assert!(
|
|
!developer_messages.iter().any(|message| {
|
|
matches!(
|
|
message.as_slice(),
|
|
["Root guidance."] | ["Subagent guidance."]
|
|
) || message.iter().any(|text| {
|
|
text.contains("You are `/root`, the primary agent")
|
|
|| text.contains("You are an agent in a team of agents")
|
|
})
|
|
}),
|
|
"did not expect multi-agent v2 usage hint developer messages, got {developer_messages:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_initial_context_restates_realtime_start_when_reference_context_is_missing() {
|
|
let (session, mut turn_context) = make_session_and_context().await;
|
|
turn_context.realtime_active = true;
|
|
let previous_turn_settings = PreviousTurnSettings {
|
|
model: turn_context.model_info().slug.clone(),
|
|
comp_hash: None,
|
|
cyber_access_program: None,
|
|
realtime_active: Some(true),
|
|
};
|
|
|
|
session
|
|
.set_previous_turn_settings(Some(previous_turn_settings))
|
|
.await;
|
|
let turn_context = Arc::new(turn_context);
|
|
let initial_context = build_initial_context(&session, &turn_context).await;
|
|
let developer_texts = developer_input_texts(&initial_context);
|
|
assert!(
|
|
developer_texts
|
|
.iter()
|
|
.any(|text| text.contains("<realtime_conversation>")),
|
|
"expected initial context to restate active realtime when the reference context is missing, got {developer_texts:?}"
|
|
);
|
|
}
|
|
|
|
fn file_system_policy_with_unreadable_glob(turn_context: &TurnContext) -> FileSystemSandboxPolicy {
|
|
#[allow(deprecated)]
|
|
let mut policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(
|
|
&turn_context.sandbox_policy(),
|
|
&turn_context.cwd,
|
|
);
|
|
#[allow(deprecated)]
|
|
let cwd_display = turn_context.cwd.as_path().display().to_string();
|
|
policy.entries.push(FileSystemSandboxEntry {
|
|
path: FileSystemPath::GlobPattern {
|
|
pattern: format!("{cwd_display}/**/*.env"),
|
|
},
|
|
access: FileSystemAccessMode::Deny,
|
|
missing_path_behavior: None,
|
|
});
|
|
policy
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn turn_context_item_stores_local_cwd() {
|
|
let (_session, mut turn_context) = make_session_and_context().await;
|
|
let environment = turn_context
|
|
.initial_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.initial_environments.environments[0] =
|
|
TurnEnvironmentState::Ready(TurnEnvironment::new(
|
|
TurnEnvironmentSelection {
|
|
selected_capability_roots: Default::default(),
|
|
environment_id: "remote".to_string(),
|
|
cwd,
|
|
workspace_roots: Vec::new(),
|
|
config: EnvironmentConfigState::Ready(environment_config),
|
|
},
|
|
environment.config_origin,
|
|
environment.environment,
|
|
environment.shell,
|
|
));
|
|
|
|
#[allow(deprecated)]
|
|
let local_cwd = turn_context.cwd.clone();
|
|
assert_eq!(turn_context.to_turn_context_item().cwd, local_cwd);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn turn_context_item_omits_legacy_equivalent_file_system_sandbox_policy() {
|
|
let (_session, turn_context) = make_session_and_context().await;
|
|
|
|
let item = turn_context.to_turn_context_item();
|
|
|
|
assert_eq!(item.file_system_sandbox_policy, None);
|
|
assert_eq!(
|
|
item.permission_profile,
|
|
Some(turn_context.permission_profile())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn turn_context_item_stores_active_permission_profile() {
|
|
let (_session, mut turn_context) = make_session_and_context().await;
|
|
let active_permission_profile = ActivePermissionProfile::read_only();
|
|
let TurnEnvironmentState::Ready(environment) =
|
|
&mut turn_context.initial_environments.environments[0]
|
|
else {
|
|
panic!("turn environment should be ready");
|
|
};
|
|
environment.config_origin = EnvironmentConfigOrigin::Owner;
|
|
environment.config_mut().permission_profile = PermissionProfileSnapshot::active(
|
|
PermissionProfile::read_only(),
|
|
active_permission_profile.clone(),
|
|
);
|
|
|
|
assert_eq!(
|
|
turn_context
|
|
.to_turn_context_item()
|
|
.active_permission_profile,
|
|
Some(active_permission_profile)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn turn_context_item_stores_split_file_system_sandbox_policy_when_different() {
|
|
let (_session, mut turn_context) = make_session_and_context().await;
|
|
let file_system_sandbox_policy = file_system_policy_with_unreadable_glob(&turn_context);
|
|
let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement(
|
|
turn_context.permission_profile().enforcement(),
|
|
&file_system_sandbox_policy,
|
|
turn_context.network_sandbox_policy(),
|
|
);
|
|
let TurnEnvironmentState::Ready(environment) =
|
|
&mut turn_context.initial_environments.environments[0]
|
|
else {
|
|
panic!("turn environment should be ready");
|
|
};
|
|
environment.config_mut().permission_profile =
|
|
PermissionProfileSnapshot::legacy(permission_profile);
|
|
|
|
let item = turn_context.to_turn_context_item();
|
|
|
|
assert_eq!(
|
|
item.file_system_sandbox_policy,
|
|
Some(
|
|
file_system_sandbox_policy
|
|
.try_into()
|
|
.expect("serializable split policy"),
|
|
)
|
|
);
|
|
assert_eq!(
|
|
item.permission_profile,
|
|
Some(turn_context.permission_profile())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_context_updates_and_set_reference_context_item_injects_full_context_when_baseline_missing()
|
|
{
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
let turn_context = Arc::new(turn_context);
|
|
let step_context = StepContext::for_test(Arc::clone(&turn_context));
|
|
session
|
|
.record_context_updates_and_set_reference_context_item(&step_context)
|
|
.await
|
|
.expect("world state should build");
|
|
let history = session.clone_history().await;
|
|
let initial_context = build_initial_context(&session, &turn_context).await;
|
|
assert_eq!(
|
|
strip_response_item_ids(&strip_metadata_from_items(&raw_history_items(&history))),
|
|
strip_response_item_ids(&strip_metadata_from_items(&initial_context))
|
|
);
|
|
|
|
let current_context = session.reference_context_item().await;
|
|
assert_eq!(
|
|
serde_json::to_value(current_context).expect("serialize current context item"),
|
|
serde_json::to_value(Some(turn_context.to_turn_context_item()))
|
|
.expect("serialize expected context item")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_context_updates_and_set_reference_context_item_reinjects_full_context_after_clear()
|
|
{
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
let turn_context = Arc::new(turn_context);
|
|
let step_context = StepContext::for_test(Arc::clone(&turn_context));
|
|
let compacted_summary = ResponseItem::Message {
|
|
status: None,
|
|
encrypted_content: None,
|
|
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))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_context_updates_and_set_reference_context_item_persists_baseline_without_emitting_diffs()
|
|
{
|
|
let (mut session, turn_context) = make_session_and_context().await;
|
|
let previous_context_item = turn_context.to_turn_context_item();
|
|
let previous_context = Arc::new(turn_context);
|
|
let world_state = build_world_state_from_turn_context(&session, &previous_context).await;
|
|
let retained_world_state = world_state
|
|
.render_full_fragments()
|
|
.1
|
|
.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.render_full().0);
|
|
}
|
|
let rollout_path = attach_thread_persistence(&mut session).await;
|
|
|
|
let turn_context = Arc::new(turn_context);
|
|
let step_context = StepContext::for_test(Arc::clone(&turn_context));
|
|
session
|
|
.record_context_updates_and_set_reference_context_item(&step_context)
|
|
.await
|
|
.expect("world state should build");
|
|
|
|
assert_eq!(
|
|
raw_history_items(&session.clone_history().await),
|
|
retained_world_state
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(session.reference_context_item().await)
|
|
.expect("serialize current context item"),
|
|
serde_json::to_value(Some(turn_context.to_turn_context_item()))
|
|
.expect("serialize expected context item")
|
|
);
|
|
session
|
|
.ensure_rollout_materialized(PersistContext::Standard)
|
|
.await;
|
|
session.flush_rollout().await.expect("rollout should flush");
|
|
|
|
let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path)
|
|
.await
|
|
.expect("read rollout history")
|
|
else {
|
|
panic!("expected resumed rollout history");
|
|
};
|
|
let persisted_turn_context = resumed.history.iter().find_map(|item| match item {
|
|
RolloutItem::TurnContext(ctx) => Some(ctx.clone()),
|
|
_ => None,
|
|
});
|
|
assert_eq!(
|
|
serde_json::to_value(persisted_turn_context)
|
|
.expect("serialize persisted turn context item"),
|
|
serde_json::to_value(Some(turn_context.to_turn_context_item()))
|
|
.expect("serialize expected turn context item")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_context_updates_and_set_reference_context_item_persists_split_file_system_policy_to_rollout()
|
|
{
|
|
let (mut session, mut turn_context) = make_session_and_context().await;
|
|
let file_system_sandbox_policy = file_system_policy_with_unreadable_glob(&turn_context);
|
|
let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement(
|
|
turn_context.permission_profile().enforcement(),
|
|
&file_system_sandbox_policy,
|
|
turn_context.network_sandbox_policy(),
|
|
);
|
|
let TurnEnvironmentState::Ready(environment) =
|
|
&mut turn_context.initial_environments.environments[0]
|
|
else {
|
|
panic!("turn environment should be ready");
|
|
};
|
|
environment.config_mut().permission_profile =
|
|
PermissionProfileSnapshot::legacy(permission_profile);
|
|
let rollout_path = attach_thread_persistence(&mut session).await;
|
|
|
|
let turn_context = Arc::new(turn_context);
|
|
let step_context = StepContext::for_test(Arc::clone(&turn_context));
|
|
session
|
|
.record_context_updates_and_set_reference_context_item(&step_context)
|
|
.await
|
|
.expect("world state should build");
|
|
session
|
|
.ensure_rollout_materialized(PersistContext::Standard)
|
|
.await;
|
|
session.flush_rollout().await.expect("rollout should flush");
|
|
|
|
let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path)
|
|
.await
|
|
.expect("read rollout history")
|
|
else {
|
|
panic!("expected resumed rollout history");
|
|
};
|
|
let persisted_file_system_sandbox_policy = resumed.history.iter().find_map(|item| match item {
|
|
RolloutItem::TurnContext(ctx) => ctx.file_system_sandbox_policy.clone(),
|
|
_ => None,
|
|
});
|
|
assert_eq!(
|
|
persisted_file_system_sandbox_policy,
|
|
Some(
|
|
file_system_sandbox_policy
|
|
.try_into()
|
|
.expect("serializable split policy"),
|
|
)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_initial_context_uses_retained_step_after_model_change() {
|
|
let (mut session, mut turn_context, _rx_event) =
|
|
make_session_and_context_with_auth_and_config_and_rx(
|
|
CodexAuth::from_api_key("Test API Key"),
|
|
Vec::new(),
|
|
|config| {
|
|
config.features.enable(Feature::TokenBudget).unwrap();
|
|
config.features.enable(Feature::IncrementalTools).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.use_responses_lite = true;
|
|
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());
|
|
});
|
|
session.state.lock().await.base_instructions_provenance =
|
|
Some(codex_protocol::models::BaseInstructionsProvenance::Model {
|
|
model: "base-model".to_string(),
|
|
});
|
|
session
|
|
.set_previous_turn_settings(Some(PreviousTurnSettings {
|
|
model: "base-model".to_string(),
|
|
comp_hash: None,
|
|
cyber_access_program: None,
|
|
realtime_active: None,
|
|
}))
|
|
.await;
|
|
session
|
|
.replace_history(
|
|
vec![user_message("Existing legacy window")],
|
|
Some(turn_context.to_turn_context_item()),
|
|
)
|
|
.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, /*new_window*/ true)
|
|
.await
|
|
.unwrap(),
|
|
);
|
|
let (initial_a, expected_snapshot) = crate::compact::build_compaction_replacement_history(
|
|
&session,
|
|
&step_a,
|
|
&world_a,
|
|
Vec::new(),
|
|
)
|
|
.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
|
|
.next_step_settings
|
|
.store(Arc::new(ResolvedStepSettings::new(
|
|
Arc::new(selected_b),
|
|
Arc::new(model_b),
|
|
&codex_features::Features::default(),
|
|
)));
|
|
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, /*new_window*/ true)
|
|
.await
|
|
.unwrap();
|
|
let initial_b = session
|
|
.build_initial_context_with_world_state(&step_b, &world_b)
|
|
.await
|
|
.0;
|
|
let initial_b = crate::context_manager::updates::merge_world_state_updates(initial_b);
|
|
let turn_contributions_b = session.build_turn_context_contribution_items(&step_b).await;
|
|
let (restored_a, restored_snapshot) = crate::compact::build_compaction_replacement_history(
|
|
&session,
|
|
&step_a,
|
|
&world_a,
|
|
Vec::new(),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(restored_a, initial_a);
|
|
assert_eq!(restored_snapshot, expected_snapshot);
|
|
let initial_a = initial_a
|
|
.into_iter()
|
|
.map(|item| item.item)
|
|
.collect::<Vec<_>>();
|
|
let a_text = developer_input_texts(&initial_a).join("\n");
|
|
let b_text = developer_input_texts(&initial_b).join("\n");
|
|
assert!(a_text.contains("A instructions"));
|
|
assert!(!a_text.contains("<context_window>"));
|
|
assert!(b_text.contains("B instructions"));
|
|
assert!(!b_text.contains("A instructions:"));
|
|
assert!(!a_text.contains("turn context extension enabled"));
|
|
assert!(b_text.contains("turn context extension enabled"));
|
|
assert!(
|
|
developer_input_texts(&turn_contributions_b)
|
|
.join("\n")
|
|
.contains("turn context extension enabled")
|
|
);
|
|
assert!(
|
|
b_text.contains("<context_window>"),
|
|
"full-context metadata must use B's window even though the turn started without one"
|
|
);
|
|
assert_eq!(
|
|
step_b.environments.to_selections(),
|
|
step_a.environments.to_selections()
|
|
);
|
|
assert!(Arc::ptr_eq(&step_b.turn.config, &step_a.turn.config));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_initial_context_prepends_model_switch_message() {
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
let previous_turn_settings = PreviousTurnSettings {
|
|
model: "previous-regular-model".to_string(),
|
|
comp_hash: None,
|
|
cyber_access_program: None,
|
|
realtime_active: None,
|
|
};
|
|
|
|
session
|
|
.set_previous_turn_settings(Some(previous_turn_settings))
|
|
.await;
|
|
let turn_context = Arc::new(turn_context);
|
|
let initial_context = build_initial_context(&session, &turn_context).await;
|
|
|
|
let ResponseItem::Message { role, content, .. } = &initial_context[0] else {
|
|
panic!("expected developer message");
|
|
};
|
|
assert_eq!(role, "developer");
|
|
let [ContentItem::InputText { text }, ..] = content.as_slice() else {
|
|
panic!("expected developer text");
|
|
};
|
|
assert!(text.contains("<model_switch>"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_context_updates_and_set_reference_context_item_persists_full_reinjection_to_rollout()
|
|
{
|
|
let (mut session, previous_context) = make_session_and_context().await;
|
|
let next_model = if previous_context.model_info().slug == "gpt-5.4" {
|
|
"gpt-5.2"
|
|
} else {
|
|
"gpt-5.4"
|
|
};
|
|
let turn_context = previous_context
|
|
.with_model(next_model.to_string(), &session.services.models_manager)
|
|
.await;
|
|
let rollout_path = attach_thread_persistence(&mut session).await;
|
|
|
|
session
|
|
.persist_rollout_items(&[RolloutItem::EventMsg(EventMsg::UserMessage(
|
|
UserMessageEvent {
|
|
client_id: None,
|
|
message: "seed rollout".to_string(),
|
|
images: None,
|
|
local_images: Vec::new(),
|
|
text_elements: Vec::new(),
|
|
..Default::default()
|
|
},
|
|
))])
|
|
.await;
|
|
{
|
|
let mut state = session.state.lock().await;
|
|
state.set_reference_context_item(/*item*/ None);
|
|
}
|
|
|
|
session
|
|
.set_previous_turn_settings(Some(PreviousTurnSettings {
|
|
model: previous_context.model_info().slug.clone(),
|
|
comp_hash: None,
|
|
cyber_access_program: None,
|
|
realtime_active: Some(previous_context.realtime_active),
|
|
}))
|
|
.await;
|
|
let turn_context = Arc::new(turn_context);
|
|
let step_context = StepContext::for_test(Arc::clone(&turn_context));
|
|
session
|
|
.record_context_updates_and_set_reference_context_item(&step_context)
|
|
.await
|
|
.expect("world state should build");
|
|
session
|
|
.ensure_rollout_materialized(PersistContext::Standard)
|
|
.await;
|
|
session.flush_rollout().await.expect("rollout should flush");
|
|
|
|
let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path)
|
|
.await
|
|
.expect("read rollout history")
|
|
else {
|
|
panic!("expected resumed rollout history");
|
|
};
|
|
let persisted_turn_context = resumed.history.iter().find_map(|item| match item {
|
|
RolloutItem::TurnContext(ctx) => Some(ctx.clone()),
|
|
_ => None,
|
|
});
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(persisted_turn_context)
|
|
.expect("serialize persisted turn context item"),
|
|
serde_json::to_value(Some(turn_context.to_turn_context_item()))
|
|
.expect("serialize expected turn context item")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn run_user_shell_command_does_not_set_reference_context_item() {
|
|
let (session, _turn_context, rx) = make_session_and_context_with_rx().await;
|
|
{
|
|
let mut state = session.state.lock().await;
|
|
state.set_reference_context_item(/*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"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn realtime_conversation_list_voices_emits_builtin_list() {
|
|
let (session, _turn_context, rx) = make_session_and_context_with_rx().await;
|
|
|
|
handlers::realtime_conversation_list_voices(&session, "sub-id".to_string()).await;
|
|
|
|
let event = rx.recv().await.expect("event");
|
|
let voices = match event.msg {
|
|
EventMsg::RealtimeConversationListVoicesResponse(
|
|
RealtimeConversationListVoicesResponseEvent { voices },
|
|
) => voices,
|
|
msg => panic!("expected list voices response, got {msg:?}"),
|
|
};
|
|
assert_eq!(
|
|
voices,
|
|
RealtimeVoicesList {
|
|
v1: vec![
|
|
RealtimeVoice::Juniper,
|
|
RealtimeVoice::Maple,
|
|
RealtimeVoice::Spruce,
|
|
RealtimeVoice::Ember,
|
|
RealtimeVoice::Vale,
|
|
RealtimeVoice::Breeze,
|
|
RealtimeVoice::Arbor,
|
|
RealtimeVoice::Sol,
|
|
RealtimeVoice::Cove,
|
|
],
|
|
v2: vec![
|
|
RealtimeVoice::Alloy,
|
|
RealtimeVoice::Ash,
|
|
RealtimeVoice::Ballad,
|
|
RealtimeVoice::Coral,
|
|
RealtimeVoice::Echo,
|
|
RealtimeVoice::Sage,
|
|
RealtimeVoice::Shimmer,
|
|
RealtimeVoice::Verse,
|
|
RealtimeVoice::Marin,
|
|
RealtimeVoice::Cedar,
|
|
],
|
|
v3: RealtimeVoicesList::builtin().v3,
|
|
default_v1: RealtimeVoice::Cove,
|
|
default_v2: RealtimeVoice::Marin,
|
|
},
|
|
);
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct CompletingTask;
|
|
|
|
impl SessionTask for CompletingTask {
|
|
fn kind(&self) -> TaskKind {
|
|
TaskKind::Regular
|
|
}
|
|
|
|
fn span_name(&self) -> &'static str {
|
|
"session_task.completing"
|
|
}
|
|
|
|
async fn run(
|
|
self: Arc<Self>,
|
|
_session: Arc<Session>,
|
|
_ctx: Arc<TurnContext>,
|
|
_input: Vec<TurnInput>,
|
|
_cancellation_token: CancellationToken,
|
|
) -> SessionTaskResult {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum TerminalEventKind {
|
|
TurnComplete,
|
|
TurnAborted,
|
|
}
|
|
|
|
async fn attach_in_memory_thread_store(
|
|
session: &mut Session,
|
|
) -> Arc<codex_thread_store::InMemoryThreadStore> {
|
|
let store = Arc::new(codex_thread_store::InMemoryThreadStore::default());
|
|
let thread_store: Arc<dyn codex_thread_store::ThreadStore> = store.clone();
|
|
let config = session.get_config().await;
|
|
let live_thread = LiveThread::create(
|
|
Arc::clone(&thread_store),
|
|
CreateThreadParams {
|
|
attached_thread: false,
|
|
creator_user_id: None,
|
|
creator_account_id: None,
|
|
session_id: session.session_id(),
|
|
thread_id: session.thread_id,
|
|
extra_config: None,
|
|
forked_from_id: None,
|
|
parent_thread_id: None,
|
|
source: SessionSource::Exec,
|
|
thread_source: None,
|
|
originator: "test_originator".to_string(),
|
|
base_instructions: BaseInstructions::default(),
|
|
dynamic_tools: Vec::new(),
|
|
selected_capability_roots: Vec::new(),
|
|
multi_agent_version: None,
|
|
history_mode: Default::default(),
|
|
subagent_history_start_ordinal: None,
|
|
history_base: None,
|
|
initial_window_id: Uuid::now_v7().to_string(),
|
|
runtime_workspace_roots: None,
|
|
metadata: ThreadPersistenceMetadata {
|
|
cwd: Some(config.cwd.to_path_buf()),
|
|
model_provider: config.model_provider_id.clone(),
|
|
memory_mode: if config.memories.generate_memories {
|
|
ThreadMemoryMode::Enabled
|
|
} else {
|
|
ThreadMemoryMode::Disabled
|
|
},
|
|
},
|
|
},
|
|
)
|
|
.await
|
|
.expect("create thread persistence");
|
|
session.services.thread_store = thread_store;
|
|
session.services.live_thread = Some(live_thread);
|
|
store
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hook_transcript_path_does_not_persist_non_local_thread_store() {
|
|
let (mut session, _) = make_session_and_context().await;
|
|
let store = attach_in_memory_thread_store(&mut session).await;
|
|
|
|
assert_eq!(session.hook_transcript_path().await, None);
|
|
assert_eq!(
|
|
store.calls().await,
|
|
codex_thread_store::InMemoryThreadStoreCalls {
|
|
create_thread: 1,
|
|
..Default::default()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn hook_transcript_path_materializes_lazy_local_thread() {
|
|
let (mut session, _) = make_session_and_context().await;
|
|
let rollout_path = open_thread_persistence(&mut session).await;
|
|
assert!(!rollout_path.exists());
|
|
|
|
assert_eq!(
|
|
session.hook_transcript_path().await,
|
|
Some(rollout_path.clone())
|
|
);
|
|
let (items, thread_id, parse_errors) = RolloutRecorder::load_rollout_items(&rollout_path)
|
|
.await
|
|
.expect("read materialized rollout");
|
|
assert_eq!((thread_id, parse_errors), (Some(session.thread_id), 0));
|
|
assert!(matches!(
|
|
items.as_slice(),
|
|
[RolloutItem::SessionMeta(meta)] if meta.meta.id == session.thread_id
|
|
));
|
|
}
|
|
|
|
async fn wait_for_flush_count(
|
|
store: &codex_thread_store::InMemoryThreadStore,
|
|
expected_flushes: usize,
|
|
) -> codex_thread_store::InMemoryThreadStoreCalls {
|
|
timeout(Duration::from_secs(2), async {
|
|
loop {
|
|
let calls = store.calls().await;
|
|
if calls.flush_thread >= expected_flushes {
|
|
return calls;
|
|
}
|
|
sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("store should observe expected flush count")
|
|
}
|
|
|
|
async fn recv_terminal_event(
|
|
rx: &async_channel::Receiver<Event>,
|
|
expected: TerminalEventKind,
|
|
) -> Event {
|
|
timeout(Duration::from_secs(2), async {
|
|
loop {
|
|
let event = rx.recv().await.expect("event");
|
|
match (&event.msg, expected) {
|
|
(EventMsg::TurnComplete(_), TerminalEventKind::TurnComplete)
|
|
| (EventMsg::TurnAborted(_), TerminalEventKind::TurnAborted) => return event,
|
|
(EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_), _) => {
|
|
panic!("unexpected terminal event: {:?}", event.msg)
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("terminal event should be delivered")
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct NeverEndingTask {
|
|
kind: TaskKind,
|
|
listen_to_cancellation_token: bool,
|
|
}
|
|
|
|
impl SessionTask for NeverEndingTask {
|
|
fn kind(&self) -> TaskKind {
|
|
self.kind
|
|
}
|
|
|
|
fn span_name(&self) -> &'static str {
|
|
"session_task.never_ending"
|
|
}
|
|
|
|
async fn run(
|
|
self: Arc<Self>,
|
|
_session: Arc<Session>,
|
|
_ctx: Arc<TurnContext>,
|
|
_input: Vec<TurnInput>,
|
|
cancellation_token: CancellationToken,
|
|
) -> SessionTaskResult {
|
|
if self.listen_to_cancellation_token {
|
|
cancellation_token.cancelled().await;
|
|
return Ok(None);
|
|
}
|
|
loop {
|
|
sleep(Duration::from_secs(60)).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct ExtensionInterruptedTask;
|
|
|
|
impl SessionTask for ExtensionInterruptedTask {
|
|
fn kind(&self) -> TaskKind {
|
|
TaskKind::Regular
|
|
}
|
|
|
|
fn span_name(&self) -> &'static str {
|
|
"session_task.extension_interrupted"
|
|
}
|
|
|
|
async fn run(
|
|
self: Arc<Self>,
|
|
session: Arc<Session>,
|
|
ctx: Arc<TurnContext>,
|
|
_input: Vec<TurnInput>,
|
|
cancellation_token: CancellationToken,
|
|
) -> SessionTaskResult {
|
|
session
|
|
.interrupt_turn_with_warning(
|
|
&ctx.sub_id,
|
|
EventMsg::Warning(codex_protocol::protocol::WarningEvent {
|
|
message: "extension interrupted this turn".into(),
|
|
}),
|
|
/*error*/ None,
|
|
)
|
|
.await;
|
|
|
|
cancellation_token.cancelled().await;
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
pub(super) struct HeldStepTask {
|
|
pub(super) kind: TaskKind,
|
|
pub(super) finish: Arc<Notify>,
|
|
}
|
|
|
|
impl SessionTask for HeldStepTask {
|
|
fn kind(&self) -> TaskKind {
|
|
self.kind
|
|
}
|
|
|
|
fn span_name(&self) -> &'static str {
|
|
"session_task.step_activation_test"
|
|
}
|
|
|
|
async fn run(
|
|
self: Arc<Self>,
|
|
_session: Arc<Session>,
|
|
_turn: Arc<TurnContext>,
|
|
_input: Vec<TurnInput>,
|
|
cancellation_token: CancellationToken,
|
|
) -> SessionTaskResult {
|
|
tokio::select! {
|
|
_ = cancellation_token.cancelled() => {},
|
|
_ = self.finish.notified() => {},
|
|
}
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
#[test_case(TerminalEventKind::TurnComplete; "completion")]
|
|
#[test_case(TerminalEventKind::TurnAborted; "interruption")]
|
|
#[tokio::test]
|
|
async fn finished_turn_retains_last_known_step_context(terminal: TerminalEventKind) {
|
|
let (session, turn, events) = make_session_and_context_with_rx().await;
|
|
let finish = Arc::new(Notify::new());
|
|
session
|
|
.spawn_task(
|
|
Arc::clone(&turn),
|
|
Vec::new(),
|
|
HeldStepTask {
|
|
kind: TaskKind::Regular,
|
|
finish: Arc::clone(&finish),
|
|
},
|
|
)
|
|
.await;
|
|
let expected = session
|
|
.capture_step_context(turn, &CancellationToken::new())
|
|
.await
|
|
.expect("capture executing step");
|
|
let state = {
|
|
let active = session.active_turn.lock().await;
|
|
Arc::clone(&active.as_ref().expect("active turn").turn_state)
|
|
};
|
|
|
|
match terminal {
|
|
TerminalEventKind::TurnComplete => finish.notify_one(),
|
|
TerminalEventKind::TurnAborted => {
|
|
session.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
|
}
|
|
}
|
|
recv_terminal_event(&events, terminal).await;
|
|
|
|
assert!(session.active_turn.lock().await.is_none());
|
|
assert_eq!(
|
|
state
|
|
.lock()
|
|
.await
|
|
.last_known_step_context
|
|
.as_ref()
|
|
.map(Arc::as_ptr),
|
|
Some(Arc::as_ptr(&expected)),
|
|
);
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum FirstAttempt {
|
|
Succeeds,
|
|
Retries,
|
|
}
|
|
|
|
async fn make_remote_compaction_session(
|
|
server_uri: &str,
|
|
) -> (
|
|
Arc<Session>,
|
|
Arc<TurnContext>,
|
|
async_channel::Receiver<Event>,
|
|
) {
|
|
let mut provider = built_in_model_providers(/*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;
|
|
config.chatgpt_base_url = server_uri.to_string();
|
|
let _ = config.features.disable(Feature::TokenBudget);
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[test_case(FirstAttempt::Succeeds; "primary succeeds")]
|
|
#[test_case(FirstAttempt::Retries; "fallback executes")]
|
|
#[tokio::test]
|
|
async fn remote_compaction_v2_retains_only_the_selected_step(first_attempt: FirstAttempt) {
|
|
let server = responses::start_mock_server().await;
|
|
let (session, turn, events) = make_remote_compaction_session(&server.uri()).await;
|
|
session
|
|
.record_conversation_items(
|
|
&turn,
|
|
turn.model_info(),
|
|
&[user_message("before compaction")],
|
|
)
|
|
.await;
|
|
session
|
|
.spawn_task(
|
|
Arc::clone(&turn),
|
|
Vec::new(),
|
|
NeverEndingTask {
|
|
kind: TaskKind::Regular,
|
|
listen_to_cancellation_token: true,
|
|
},
|
|
)
|
|
.await;
|
|
let primary_turn = Arc::new(
|
|
turn.with_model("gpt-5.4".to_string(), &session.services.models_manager)
|
|
.await,
|
|
);
|
|
let primary = session
|
|
.capture_step_context(primary_turn, &CancellationToken::new())
|
|
.await
|
|
.expect("capture primary step");
|
|
let fallback = session
|
|
.capture_speculative_step_context(turn, &CancellationToken::new())
|
|
.await
|
|
.expect("capture speculative fallback");
|
|
let state = {
|
|
let active = session.active_turn.lock().await;
|
|
Arc::clone(&active.as_ref().expect("active turn").turn_state)
|
|
};
|
|
assert_eq!(
|
|
state
|
|
.lock()
|
|
.await
|
|
.last_known_step_context
|
|
.as_ref()
|
|
.map(Arc::as_ptr),
|
|
Some(Arc::as_ptr(&primary)),
|
|
);
|
|
|
|
let success = ResponseTemplate::new(/*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),
|
|
Arc::clone(&fallback),
|
|
&mut client_session,
|
|
Arc::new(
|
|
session
|
|
.build_world_state_for_step(&fallback, /*new_window*/ true)
|
|
.await
|
|
.unwrap(),
|
|
),
|
|
CompactionReason::ModelDownshift,
|
|
CompactionPhase::PreTurn,
|
|
/*candidate*/ None,
|
|
crate::compact_remote_v2::CompactionFallback::Foreground,
|
|
)
|
|
.await
|
|
.expect("compaction succeeds");
|
|
|
|
let (expected, models) = match first_attempt {
|
|
FirstAttempt::Succeeds => (&primary, vec![json!("gpt-5.4")]),
|
|
FirstAttempt::Retries => (&fallback, vec![json!("gpt-5.4"), json!("gpt-5.2")]),
|
|
};
|
|
assert_eq!(
|
|
state
|
|
.lock()
|
|
.await
|
|
.last_known_step_context
|
|
.as_ref()
|
|
.map(Arc::as_ptr),
|
|
Some(Arc::as_ptr(expected)),
|
|
);
|
|
assert_eq!(
|
|
requests
|
|
.requests()
|
|
.iter()
|
|
.map(|request| request.body_json()["model"].clone())
|
|
.collect::<Vec<_>>(),
|
|
models,
|
|
);
|
|
assert!(
|
|
requests
|
|
.requests()
|
|
.iter()
|
|
.all(|request| request.inputs_of_type("compaction_trigger").len() == 1),
|
|
);
|
|
session.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
|
recv_terminal_event(&events, TerminalEventKind::TurnAborted).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn interrupting_compaction_fallback_retains_last_known_step_context() {
|
|
let (release_primary, primary_gate) = tokio::sync::oneshot::channel();
|
|
let (release_fallback, fallback_gate) = tokio::sync::oneshot::channel();
|
|
let (server, _) = start_streaming_sse_server(vec![
|
|
vec![StreamingSseChunk {
|
|
gate: Some(primary_gate),
|
|
body: responses::sse_failed(
|
|
"primary",
|
|
"context_length_exceeded",
|
|
"compact with the current model",
|
|
),
|
|
}],
|
|
vec![StreamingSseChunk {
|
|
gate: Some(fallback_gate),
|
|
body: responses::sse_completed("fallback"),
|
|
}],
|
|
])
|
|
.await;
|
|
let (session, mut turn, events) = make_remote_compaction_session(server.uri()).await;
|
|
update_turn_settings_for_test(
|
|
Arc::get_mut(&mut turn).expect("unshared turn"),
|
|
|settings| {
|
|
Arc::make_mut(&mut settings.model_info).comp_hash = Some("new".to_string());
|
|
},
|
|
);
|
|
session
|
|
.set_previous_turn_settings(Some(PreviousTurnSettings {
|
|
model: "gpt-5.4".to_string(),
|
|
comp_hash: Some("old".to_string()),
|
|
cyber_access_program: None,
|
|
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;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn extension_interrupt_emits_thread_idle() {
|
|
struct ThreadIdleRecorder(async_channel::Sender<()>);
|
|
|
|
impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for ThreadIdleRecorder {
|
|
fn on_thread_idle<'a>(
|
|
&'a self,
|
|
_input: codex_extension_api::ThreadIdleInput<'a>,
|
|
) -> codex_extension_api::ExtensionFuture<'a, ()> {
|
|
Box::pin(async move {
|
|
self.0.send(()).await.expect("idle receiver open");
|
|
})
|
|
}
|
|
}
|
|
|
|
let (mut session, turn_context) = make_session_and_context().await;
|
|
let (idle_tx, idle_rx) = async_channel::bounded(1);
|
|
let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new();
|
|
builder.thread_lifecycle_contributor(Arc::new(ThreadIdleRecorder(idle_tx)));
|
|
session.services.extensions = Arc::new(builder.build());
|
|
|
|
Arc::new(session)
|
|
.spawn_task(Arc::new(turn_context), Vec::new(), ExtensionInterruptedTask)
|
|
.await;
|
|
|
|
timeout(StdDuration::from_secs(5), idle_rx.recv())
|
|
.await
|
|
.expect("extension interrupt should emit thread idle lifecycle")
|
|
.expect("idle receiver open");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn extension_interrupt_survives_the_calling_runtime() {
|
|
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
|
let input = vec![TurnInput::UserInput {
|
|
metadata: Default::default(),
|
|
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(),
|
|
}),
|
|
/*error*/ None,
|
|
)
|
|
.await;
|
|
});
|
|
});
|
|
review_thread.join().expect("helper review thread");
|
|
|
|
let mut observed = Vec::new();
|
|
let aborted = timeout(StdDuration::from_secs(5), async {
|
|
loop {
|
|
let event = rx.recv().await.expect("event");
|
|
if let EventMsg::TurnAborted(event) = &event.msg {
|
|
let event = event.clone();
|
|
observed.push(EventMsg::TurnAborted(event.clone()));
|
|
break event;
|
|
}
|
|
observed.push(event.msg);
|
|
}
|
|
})
|
|
.await
|
|
.unwrap_or_else(|_| {
|
|
panic!("extension should interrupt the turn; observed events: {observed:?}")
|
|
});
|
|
assert_eq!(aborted.reason, TurnAbortReason::Interrupted);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn turn_complete_flushes_terminal_event_after_delivery() {
|
|
let (mut sess, tc, rx) = make_session_and_context_with_rx().await;
|
|
let store = attach_in_memory_thread_store(
|
|
Arc::get_mut(&mut sess).expect("session should be uniquely owned"),
|
|
)
|
|
.await;
|
|
|
|
let input = vec![TurnInput::UserInput {
|
|
metadata: Default::default(),
|
|
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);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn turn_aborted_flushes_terminal_event_after_delivery() {
|
|
let (mut sess, tc, rx) = make_session_and_context_with_rx().await;
|
|
let store = attach_in_memory_thread_store(
|
|
Arc::get_mut(&mut sess).expect("session should be uniquely owned"),
|
|
)
|
|
.await;
|
|
|
|
let input = vec![TurnInput::UserInput {
|
|
metadata: Default::default(),
|
|
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);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[test_log::test]
|
|
async fn abort_regular_task_emits_marker_before_turn_aborted() {
|
|
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
|
let input = vec![TurnInput::UserInput {
|
|
metadata: Default::default(),
|
|
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());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn abort_gracefully_emits_marker_before_turn_aborted() {
|
|
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
|
let input = vec![TurnInput::UserInput {
|
|
metadata: Default::default(),
|
|
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")
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() {
|
|
let (sess, mut tc, rx) = make_session_and_context_with_auth_and_config_and_rx(
|
|
CodexAuth::from_api_key("Test API Key"),
|
|
Vec::new(),
|
|
|config| {
|
|
config.features.enable(Feature::UnifiedImageBudget).unwrap();
|
|
},
|
|
)
|
|
.await;
|
|
update_turn_settings_for_test(
|
|
Arc::get_mut(&mut tc).expect("unshared context"),
|
|
|settings| {
|
|
let model = Arc::make_mut(&mut settings.model_info);
|
|
model.use_responses_lite = false;
|
|
model.supports_image_detail_original = false;
|
|
},
|
|
);
|
|
let input = vec![TurnInput::UserInput {
|
|
metadata: Default::default(),
|
|
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.next_step_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 {
|
|
status: None,
|
|
encrypted_content: None,
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![
|
|
ContentItem::InputText {
|
|
text: "late pending input".to_string(),
|
|
},
|
|
ContentItem::InputImage {
|
|
image: ImageReference::Inline {
|
|
image_url: image_url.clone(),
|
|
},
|
|
detail: Some(ImageDetail::Original),
|
|
},
|
|
],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
};
|
|
assert!(
|
|
strip_response_item_ids(&strip_metadata_from_items(&raw_history_items(&history)))
|
|
.contains(&expected),
|
|
"expected pending input to use the current model's image rules on turn completion"
|
|
);
|
|
|
|
let first = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
|
|
.await
|
|
.expect("expected raw response item event")
|
|
.expect("channel open");
|
|
assert!(matches!(first.msg, EventMsg::RawResponseItem(_)));
|
|
|
|
let second = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
|
|
.await
|
|
.expect("expected item started event")
|
|
.expect("channel open");
|
|
assert!(matches!(
|
|
second.msg,
|
|
EventMsg::ItemStarted(ItemStartedEvent {
|
|
item: TurnItem::UserMessage(UserMessageItem { content, .. }),
|
|
..
|
|
}) if content == pending_user_input
|
|
));
|
|
|
|
let third = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
|
|
.await
|
|
.expect("expected item completed event")
|
|
.expect("channel open");
|
|
assert!(matches!(
|
|
third.msg,
|
|
EventMsg::ItemCompleted(ItemCompletedEvent {
|
|
item: TurnItem::UserMessage(UserMessageItem { content, .. }),
|
|
..
|
|
}) if content == pending_user_input
|
|
));
|
|
|
|
let fourth = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
|
|
.await
|
|
.expect("expected legacy user message event")
|
|
.expect("channel open");
|
|
assert!(matches!(
|
|
fourth.msg,
|
|
EventMsg::UserMessage(UserMessageEvent {
|
|
client_id: None,
|
|
message,
|
|
images,
|
|
text_elements,
|
|
local_images,
|
|
..
|
|
}) if message == "late pending input"
|
|
&& images == Some(vec![image_url])
|
|
&& text_elements == vec![text_element]
|
|
&& local_images.is_empty()
|
|
));
|
|
|
|
let fifth = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
|
|
.await
|
|
.expect("expected turn complete event")
|
|
.expect("channel open");
|
|
assert!(matches!(
|
|
fifth.msg,
|
|
EventMsg::TurnComplete(TurnCompleteEvent {
|
|
turn_id,
|
|
last_agent_message: None,
|
|
error: None,
|
|
time_to_first_token_ms: None,
|
|
..
|
|
}) if turn_id == tc.sub_id
|
|
));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn task_finish_emits_thread_idle_lifecycle_after_active_turn_clears() {
|
|
struct ThreadIdleRecorder {
|
|
calls: Arc<std::sync::atomic::AtomicUsize>,
|
|
idle_tx: async_channel::Sender<()>,
|
|
expected_thread_id: ThreadId,
|
|
}
|
|
|
|
impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for ThreadIdleRecorder {
|
|
fn on_thread_idle<'a>(
|
|
&'a self,
|
|
input: codex_extension_api::ThreadIdleInput<'a>,
|
|
) -> codex_extension_api::ExtensionFuture<'a, ()> {
|
|
Box::pin(async move {
|
|
assert_eq!(
|
|
self.expected_thread_id.to_string(),
|
|
input.thread_store.level_id()
|
|
);
|
|
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
self.idle_tx.send(()).await.expect("idle receiver open");
|
|
})
|
|
}
|
|
}
|
|
|
|
let (mut session, turn_context) = make_session_and_context().await;
|
|
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
|
let (idle_tx, idle_rx) = async_channel::bounded(1);
|
|
let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new();
|
|
builder.thread_lifecycle_contributor(Arc::new(ThreadIdleRecorder {
|
|
calls: Arc::clone(&calls),
|
|
idle_tx,
|
|
expected_thread_id: session.thread_id,
|
|
}));
|
|
session.services.extensions = Arc::new(builder.build());
|
|
|
|
let session = Arc::new(session);
|
|
session
|
|
.spawn_task(Arc::new(turn_context), Vec::new(), CompletingTask)
|
|
.await;
|
|
|
|
timeout(StdDuration::from_secs(2), idle_rx.recv())
|
|
.await
|
|
.expect("thread idle lifecycle")
|
|
.expect("idle receiver open");
|
|
assert_eq!(1, calls.load(std::sync::atomic::Ordering::SeqCst));
|
|
assert!(session.active_turn.lock().await.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_idle_lifecycle_waits_for_trigger_turn_mailbox_work() {
|
|
struct ThreadIdleRecorder {
|
|
calls: Arc<std::sync::atomic::AtomicUsize>,
|
|
}
|
|
|
|
impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for ThreadIdleRecorder {
|
|
fn on_thread_idle<'a>(
|
|
&'a self,
|
|
_input: codex_extension_api::ThreadIdleInput<'a>,
|
|
) -> codex_extension_api::ExtensionFuture<'a, ()> {
|
|
Box::pin(async move {
|
|
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
|
})
|
|
}
|
|
}
|
|
|
|
let (mut session, _turn_context) = make_session_and_context().await;
|
|
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
|
let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new();
|
|
builder.thread_lifecycle_contributor(Arc::new(ThreadIdleRecorder {
|
|
calls: Arc::clone(&calls),
|
|
}));
|
|
session.services.extensions = Arc::new(builder.build());
|
|
session
|
|
.input_queue
|
|
.enqueue_mailbox_communication(
|
|
InterAgentCommunication::new(
|
|
AgentPath::root(),
|
|
AgentPath::root(),
|
|
Vec::new(),
|
|
"pending trigger".to_string(),
|
|
/*trigger_turn*/ true,
|
|
)
|
|
.into(),
|
|
Default::default(),
|
|
)
|
|
.await;
|
|
|
|
session
|
|
.emit_thread_idle_lifecycle_if_idle(codex_extension_api::ThreadIdleCause::Completed)
|
|
.await;
|
|
|
|
assert_eq!(0, calls.load(std::sync::atomic::Ordering::SeqCst));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn abort_empty_active_turn_preserves_pending_input() {
|
|
let (sess, _tc, _rx) = make_session_and_context_with_rx().await;
|
|
let pending_item = ResponseItem::Message {
|
|
status: None,
|
|
encrypted_content: None,
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: "late pending input".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
};
|
|
let turn_state = {
|
|
let mut active = sess.active_turn.lock().await;
|
|
let active_turn = active.get_or_insert_with(ActiveTurn::default);
|
|
Arc::clone(&active_turn.turn_state)
|
|
};
|
|
sess.input_queue
|
|
.extend_pending_input_for_turn_state(
|
|
turn_state.as_ref(),
|
|
vec![TurnInput::ResponseItem(pending_item.clone().into())],
|
|
)
|
|
.await;
|
|
|
|
sess.abort_all_tasks(TurnAbortReason::Replaced).await;
|
|
|
|
assert!(sess.active_turn.lock().await.is_none());
|
|
assert_eq!(
|
|
sess.input_queue
|
|
.take_pending_input_for_turn_state(turn_state.as_ref())
|
|
.await,
|
|
vec![TurnInput::ResponseItem(pending_item.into())]
|
|
);
|
|
}
|
|
|
|
async fn set_total_token_usage(sess: &Session, total_token_usage: TokenUsage) {
|
|
let mut state = sess.state.lock().await;
|
|
state.set_token_info(Some(TokenUsageInfo {
|
|
total_token_usage,
|
|
last_token_usage: TokenUsage::default(),
|
|
model_context_window: None,
|
|
}));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn queue_only_mailbox_mail_waits_for_next_turn_after_answer_boundary() {
|
|
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
|
|
let communication = InterAgentCommunication::new(
|
|
AgentPath::try_from("/root/worker").expect("worker path should parse"),
|
|
AgentPath::root(),
|
|
Vec::new(),
|
|
"late queue-only update".to_string(),
|
|
/*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().into(), 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.into())],
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn trigger_turn_mailbox_mail_waits_for_next_turn_after_answer_boundary() {
|
|
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
|
|
sess.spawn_task(
|
|
Arc::clone(&tc),
|
|
Vec::new(),
|
|
NeverEndingTask {
|
|
kind: TaskKind::Regular,
|
|
listen_to_cancellation_token: true,
|
|
},
|
|
)
|
|
.await;
|
|
|
|
sess.input_queue
|
|
.defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id)
|
|
.await;
|
|
sess.input_queue
|
|
.enqueue_mailbox_communication(
|
|
InterAgentCommunication::new(
|
|
AgentPath::try_from("/root/worker").expect("worker path should parse"),
|
|
AgentPath::root(),
|
|
Vec::new(),
|
|
"late trigger update".to_string(),
|
|
/*trigger_turn*/ true,
|
|
)
|
|
.into(),
|
|
Default::default(),
|
|
)
|
|
.await;
|
|
|
|
assert!(
|
|
!sess.input_queue.has_pending_input(&sess.active_turn).await,
|
|
"trigger-turn mailbox mail should not extend the current turn after its answer boundary"
|
|
);
|
|
|
|
sess.abort_all_tasks(TurnAbortReason::Replaced).await;
|
|
|
|
assert!(sess.input_queue.has_trigger_turn_mailbox_items().await);
|
|
}
|
|
|
|
#[test_case(None; "independent root")]
|
|
#[test_case(Some("root-a"); "inherited root")]
|
|
#[tokio::test]
|
|
async fn active_turn_keeps_first_root_when_mail_coalesces(inherited_root: Option<&str>) {
|
|
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
|
|
if let Some(root) = inherited_root {
|
|
tc.turn_metadata_state.set_root_turn_id(root.to_string());
|
|
}
|
|
let first = InterAgentCommunication::new(
|
|
AgentPath::try_from("/root/worker_a").expect("worker path should parse"),
|
|
AgentPath::root(),
|
|
Vec::new(),
|
|
"first".to_string(),
|
|
/*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.into(),
|
|
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.into()),
|
|
TurnInput::InterAgentCommunication(second.into()),
|
|
]
|
|
);
|
|
assert_eq!(
|
|
tc.turn_metadata_state.root_turn_id().as_deref(),
|
|
Some(inherited_root.unwrap_or(&tc.sub_id))
|
|
);
|
|
assert!(!sess.input_queue.has_pending_mailbox_items().await);
|
|
|
|
sess.abort_all_tasks(TurnAbortReason::Replaced).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn steered_input_reopens_mailbox_delivery_for_current_turn() {
|
|
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
|
|
let communication = InterAgentCommunication::new(
|
|
AgentPath::try_from("/root/worker").expect("worker path should parse"),
|
|
AgentPath::root(),
|
|
Vec::new(),
|
|
"queued child update".to_string(),
|
|
/*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().into(), 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 {
|
|
metadata: crate::session::UserInputMetadata {
|
|
acceptance_order: Some(0),
|
|
..Default::default()
|
|
},
|
|
content: vec![UserInput::Text {
|
|
text: "follow up".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
client_id: None,
|
|
},
|
|
TurnInput::InterAgentCommunication(communication.into()),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stale_defer_mailbox_delivery_does_not_override_steered_input() {
|
|
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
|
|
let communication = InterAgentCommunication::new(
|
|
AgentPath::try_from("/root/worker").expect("worker path should parse"),
|
|
AgentPath::root(),
|
|
Vec::new(),
|
|
"queued child update".to_string(),
|
|
/*trigger_turn*/ false,
|
|
);
|
|
sess.spawn_task(
|
|
Arc::clone(&tc),
|
|
Vec::new(),
|
|
NeverEndingTask {
|
|
kind: TaskKind::Regular,
|
|
listen_to_cancellation_token: true,
|
|
},
|
|
)
|
|
.await;
|
|
|
|
sess.input_queue
|
|
.defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id)
|
|
.await;
|
|
sess.input_queue
|
|
.enqueue_mailbox_communication(communication.clone().into(), 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 {
|
|
metadata: crate::session::UserInputMetadata {
|
|
acceptance_order: Some(0),
|
|
..Default::default()
|
|
},
|
|
content: vec![UserInput::Text {
|
|
text: "follow up".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
client_id: None,
|
|
},
|
|
TurnInput::InterAgentCommunication(communication.into()),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tool_calls_reopen_mailbox_delivery_for_current_turn() {
|
|
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
|
|
let communication = InterAgentCommunication::new(
|
|
AgentPath::try_from("/root/worker").expect("worker path should parse"),
|
|
AgentPath::root(),
|
|
Vec::new(),
|
|
"queued child update".to_string(),
|
|
/*trigger_turn*/ false,
|
|
);
|
|
sess.spawn_task(
|
|
Arc::clone(&tc),
|
|
Vec::new(),
|
|
NeverEndingTask {
|
|
kind: TaskKind::Regular,
|
|
listen_to_cancellation_token: true,
|
|
},
|
|
)
|
|
.await;
|
|
|
|
sess.input_queue
|
|
.defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id)
|
|
.await;
|
|
sess.input_queue
|
|
.enqueue_mailbox_communication(communication.clone().into(), Default::default())
|
|
.await;
|
|
|
|
let item = ResponseItem::FunctionCall {
|
|
status: None,
|
|
encrypted_content: None,
|
|
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)),
|
|
defer_tool_dispatch: false,
|
|
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.into())],
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn abort_review_task_emits_exited_then_aborted_and_records_history() {
|
|
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
|
let input = vec![TurnInput::UserInput {
|
|
metadata: Default::default(),
|
|
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"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn fatal_tool_error_stops_turn_and_reports_error() {
|
|
let (session, turn_context, _rx) = make_session_and_context_with_rx().await;
|
|
let step_context = StepContext::for_test(Arc::clone(&turn_context));
|
|
let (registry, hosted_specs) = tool_registry_for_test_step(step_context.as_ref());
|
|
let router = ToolRouter::from_registry(
|
|
step_context.turn.as_ref(),
|
|
step_context.turn.model_info(),
|
|
registry,
|
|
hosted_specs,
|
|
&Default::default(),
|
|
);
|
|
let item = ResponseItem::CustomToolCall {
|
|
encrypted_content: None,
|
|
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(Box::new(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,
|
|
resume_metadata: 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(Box::new(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,
|
|
resume_metadata: None,
|
|
})));
|
|
|
|
let user3 = user_message("third user");
|
|
live_history.record_items(
|
|
std::iter::once(&user3),
|
|
reconstruction_turn.model_info().truncation_policy.into(),
|
|
);
|
|
rollout_items.push(RolloutItem::ResponseItem(user3.into()));
|
|
|
|
let assistant3 = assistant_message("assistant reply three");
|
|
live_history.record_items(
|
|
std::iter::once(&assistant3),
|
|
reconstruction_turn.model_info().truncation_policy.into(),
|
|
);
|
|
rollout_items.push(RolloutItem::ResponseItem(assistant3.into()));
|
|
|
|
(rollout_items, raw_history_items(&live_history))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() {
|
|
use crate::sandboxing::SandboxPermissions;
|
|
use crate::turn_diff_tracker::TurnDiffTracker;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
|
|
let (session, mut turn_context_raw) = make_session_and_context().await;
|
|
Arc::make_mut(&mut turn_context_raw.config)
|
|
.permissions
|
|
.approval_policy
|
|
.set(AskForApproval::Never)
|
|
.expect("test setup should allow updating approval policy");
|
|
let session = Arc::new(session);
|
|
let turn_context = Arc::new(turn_context_raw);
|
|
let step_context = StepContext::for_test(Arc::clone(&turn_context));
|
|
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
|
|
|
|
let handler = ExecCommandHandler::default();
|
|
let resp = handler
|
|
.handle(ToolInvocation {
|
|
session: Arc::clone(&session),
|
|
turn: Arc::clone(&turn_context),
|
|
step_context,
|
|
cancellation_token: CancellationToken::new(),
|
|
tracker: Arc::clone(&tracker),
|
|
call_id: "exec-call".to_string(),
|
|
tool_name: codex_tools::ToolName::plain("exec_command"),
|
|
source: crate::tools::context::ToolCallSource::Direct,
|
|
environments: Default::default(),
|
|
payload: ToolPayload::Function {
|
|
arguments: serde_json::json!({
|
|
"cmd": "echo hi",
|
|
"sandbox_permissions": SandboxPermissions::RequireEscalated,
|
|
"justification": "need unsandboxed execution",
|
|
})
|
|
.to_string(),
|
|
},
|
|
})
|
|
.await;
|
|
|
|
let Err(FunctionCallError::RespondToModel(output)) = resp else {
|
|
panic!("expected error result");
|
|
};
|
|
|
|
let expected = format!(
|
|
"approval policy is {policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {policy:?}",
|
|
policy = turn_context.approval_policy()
|
|
);
|
|
|
|
pretty_assertions::assert_eq!(output, expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_start_hooks_only_load_from_trusted_project_layers() -> std::io::Result<()> {
|
|
let temp = tempfile::tempdir()?;
|
|
let codex_home = temp.path().join("home");
|
|
let project_root = temp.path().join("project");
|
|
let nested = project_root.join("nested");
|
|
let root_dot_codex = project_root.join(".codex");
|
|
let nested_dot_codex = nested.join(".codex");
|
|
|
|
std::fs::create_dir_all(&codex_home)?;
|
|
std::fs::create_dir_all(&nested_dot_codex)?;
|
|
std::fs::write(project_root.join(".git"), "gitdir: here")?;
|
|
write_project_hooks(&root_dot_codex)?;
|
|
write_project_hooks(&nested_dot_codex)?;
|
|
write_project_trust_config(&codex_home, &[(&nested, TrustLevel::Trusted)]).await?;
|
|
|
|
let config = ConfigBuilder::default()
|
|
.codex_home(codex_home)
|
|
.fallback_cwd(Some(nested))
|
|
.build()
|
|
.await?;
|
|
|
|
let hook_list = codex_hooks::list_hooks(codex_hooks::HooksConfig {
|
|
feature_enabled: true,
|
|
config_layer_stack: Some(config.config_layer_stack.clone()),
|
|
..codex_hooks::HooksConfig::default()
|
|
});
|
|
let expected_source_path = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(
|
|
nested_dot_codex.join("hooks.json"),
|
|
)?;
|
|
assert_eq!(
|
|
hook_list
|
|
.hooks
|
|
.iter()
|
|
.map(|hook| &hook.source_path)
|
|
.collect::<Vec<_>>(),
|
|
vec![&expected_source_path],
|
|
);
|
|
assert_eq!(
|
|
hook_list.hooks[0].trust_status,
|
|
codex_protocol::protocol::HookTrustStatus::Untrusted
|
|
);
|
|
assert!(preview_session_start_hooks(&config).await?.is_empty());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_start_hooks_require_project_trust_without_config_toml() -> std::io::Result<()> {
|
|
let temp = tempfile::tempdir()?;
|
|
let project_root = temp.path().join("project");
|
|
let nested = project_root.join("nested");
|
|
let dot_codex = project_root.join(".codex");
|
|
std::fs::create_dir_all(&nested)?;
|
|
std::fs::write(project_root.join(".git"), "gitdir: here")?;
|
|
write_project_hooks(&dot_codex)?;
|
|
|
|
let cases = [
|
|
("unknown", Vec::<(&Path, TrustLevel)>::new(), 0_usize),
|
|
(
|
|
"untrusted",
|
|
vec![(&project_root as &Path, TrustLevel::Untrusted)],
|
|
0_usize,
|
|
),
|
|
(
|
|
"trusted",
|
|
vec![(&project_root as &Path, TrustLevel::Trusted)],
|
|
1_usize,
|
|
),
|
|
];
|
|
|
|
for (name, trust_entries, expected_hooks) in cases {
|
|
let codex_home = temp.path().join(format!("home_{name}"));
|
|
std::fs::create_dir_all(&codex_home)?;
|
|
write_project_trust_config(&codex_home, &trust_entries).await?;
|
|
|
|
let config = ConfigBuilder::default()
|
|
.codex_home(codex_home)
|
|
.fallback_cwd(Some(nested.clone()))
|
|
.build()
|
|
.await?;
|
|
|
|
let hook_list = codex_hooks::list_hooks(codex_hooks::HooksConfig {
|
|
feature_enabled: true,
|
|
config_layer_stack: Some(config.config_layer_stack.clone()),
|
|
..codex_hooks::HooksConfig::default()
|
|
});
|
|
assert_eq!(
|
|
hook_list.hooks.len(),
|
|
expected_hooks,
|
|
"unexpected discovered hook count for {name}",
|
|
);
|
|
assert!(preview_session_start_hooks(&config).await?.is_empty());
|
|
if expected_hooks == 1 {
|
|
assert_eq!(
|
|
hook_list.hooks[0].trust_status,
|
|
codex_protocol::protocol::HookTrustStatus::Untrusted
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejected_mcp_refresh_then_corrected_user_config_blocks_ordinary_replacement() {
|
|
let (session, _turn_context) = make_session_and_context().await;
|
|
let base = session.get_config().await;
|
|
let enterprise: McpServerConfig = serde_json::from_value(json!({
|
|
"url": "https://enterprise.example/mcp", "auth": "ema_auth", "enabled": true
|
|
}))
|
|
.unwrap();
|
|
let ordinary: McpServerConfig = serde_json::from_value(json!({
|
|
"url": "https://ordinary.example/mcp", "enabled": true
|
|
}))
|
|
.unwrap();
|
|
let mut initial = base.as_ref().clone();
|
|
initial.mcp_servers = crate::config::Constrained::allow_any(HashMap::from([(
|
|
"enterprise".to_string(),
|
|
enterprise,
|
|
)]));
|
|
initial.config_layer_stack = ConfigLayerStack::new(
|
|
vec![codex_config::ConfigLayerEntry::new(
|
|
codex_config::ConfigLayerSource::User {
|
|
file: initial.codex_home.join(CONFIG_TOML_FILE),
|
|
profile: None,
|
|
},
|
|
toml::from_str("features = 'invalid retained features'").unwrap(),
|
|
)],
|
|
base.config_layer_stack.requirements().clone(),
|
|
base.config_layer_stack.requirements_toml().clone(),
|
|
)
|
|
.unwrap();
|
|
session
|
|
.state
|
|
.lock()
|
|
.await
|
|
.session_configuration
|
|
.original_config_do_not_use = Arc::new(initial);
|
|
let current = session.get_config().await;
|
|
let mut corrected = base.as_ref().clone();
|
|
corrected.mcp_servers = crate::config::Constrained::allow_any(HashMap::from([(
|
|
"enterprise".to_string(),
|
|
ordinary,
|
|
)]));
|
|
assert_eq!(
|
|
session.refresh_mcp_config(current, corrected.clone()).await,
|
|
crate::ConfigRefreshOutcome::Rejected
|
|
);
|
|
let rejected = session.get_config().await;
|
|
assert!(rejected.mcp_enterprise_managed_auth.is_none());
|
|
assert_eq!(
|
|
session.refresh_runtime_config(rejected, corrected).await,
|
|
crate::ConfigRefreshOutcome::Published
|
|
);
|
|
let final_config = session.get_config().await;
|
|
assert!(
|
|
!final_config
|
|
.mcp_servers
|
|
.get()
|
|
.get("enterprise")
|
|
.is_some_and(|server| server.enabled),
|
|
"rejection must not permit same-name ordinary-auth replacement in this session"
|
|
);
|
|
}
|