Merge branch 'abhinav/hooks-updated-input' into abhinav/hooks-updated-input-permission-request

This commit is contained in:
Abhinav
2026-05-06 13:10:43 -07:00
committed by GitHub
241 changed files with 7557 additions and 2132 deletions
+18
View File
@@ -370,12 +370,18 @@
"apps_mcp_path_override": {
"$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml"
},
"auth_elicitation": {
"type": "boolean"
},
"browser_use": {
"type": "boolean"
},
"browser_use_external": {
"type": "boolean"
},
"builtin_mcp": {
"type": "boolean"
},
"child_agents_md": {
"type": "boolean"
},
@@ -517,6 +523,9 @@
"request_rule": {
"type": "boolean"
},
"responses_websocket_response_processed": {
"type": "boolean"
},
"responses_websockets": {
"type": "boolean"
},
@@ -3919,12 +3928,18 @@
"apps_mcp_path_override": {
"$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml"
},
"auth_elicitation": {
"type": "boolean"
},
"browser_use": {
"type": "boolean"
},
"browser_use_external": {
"type": "boolean"
},
"builtin_mcp": {
"type": "boolean"
},
"child_agents_md": {
"type": "boolean"
},
@@ -4066,6 +4081,9 @@
"request_rule": {
"type": "boolean"
},
"responses_websocket_response_processed": {
"type": "boolean"
},
"responses_websockets": {
"type": "boolean"
},
+13
View File
@@ -14,6 +14,7 @@ use crate::thread_manager::ThreadManagerState;
use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns;
use codex_features::Feature;
use codex_protocol::AgentPath;
use codex_protocol::SessionId;
use codex_protocol::ThreadId;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
@@ -132,6 +133,9 @@ fn keep_forked_rollout_item(item: &RolloutItem) -> bool {
/// which keeps the registry scoped to that root thread rather than the entire `ThreadManager`.
#[derive(Clone, Default)]
pub(crate) struct AgentControl {
/// ID shared by the whole agent control session. This means every sub-agents from a common
/// root share the same session ID.
session_id: SessionId,
/// Weak handle back to the global thread registry/state.
/// This is `Weak` to avoid reference cycles and shadow persistence of the form
/// `ThreadManagerState -> CodexThread -> Session -> SessionServices -> ThreadManagerState`.
@@ -148,6 +152,15 @@ impl AgentControl {
}
}
pub(crate) fn with_session_id(mut self, session_id: SessionId) -> Self {
self.session_id = session_id;
self
}
pub(crate) fn session_id(&self) -> SessionId {
self.session_id
}
/// Spawn a new agent thread and submit the initial prompt.
#[cfg(test)]
pub(crate) async fn spawn_agent(
+61 -36
View File
@@ -58,7 +58,7 @@ use codex_api::SseTelemetry;
use codex_api::TransportError;
use codex_api::WebsocketTelemetry;
use codex_api::auth_header_telemetry;
use codex_api::build_conversation_headers;
use codex_api::build_session_headers;
use codex_api::create_text_param_for_request;
use codex_api::response_create_client_metadata;
use codex_app_server_protocol::AuthMode;
@@ -70,9 +70,9 @@ use codex_login::default_client::build_reqwest_client;
use codex_otel::SessionTelemetry;
use codex_otel::current_span_w3c_trace_context;
use codex_protocol::SessionId;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::Verbosity as VerbosityConfig;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelInfo;
@@ -100,6 +100,7 @@ use tokio::sync::oneshot::error::TryRecvError;
use tokio_tungstenite::tungstenite::Error;
use tokio_tungstenite::tungstenite::Message;
use tokio_util::sync::CancellationToken;
use tracing::debug;
use tracing::instrument;
use tracing::trace;
use tracing::warn;
@@ -146,13 +147,20 @@ const MEMORIES_SUMMARIZE_ENDPOINT: &str = "/memories/trace_summarize";
pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration =
Duration::from_millis(DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS);
pub(crate) struct CompactConversationRequestSettings {
pub(crate) effort: Option<ReasoningEffortConfig>,
pub(crate) summary: ReasoningSummaryConfig,
pub(crate) service_tier: Option<String>,
}
/// Session-scoped state shared by all [`ModelClient`] clones.
///
/// This is intentionally kept minimal so `ModelClient` does not need to hold a full `Config`. Most
/// configuration is per turn and is passed explicitly to streaming/unary methods.
#[derive(Debug)]
struct ModelClientState {
conversation_id: ThreadId,
session_id: SessionId,
thread_id: ThreadId,
window_generation: AtomicU64,
installation_id: String,
provider: SharedModelProvider,
@@ -190,7 +198,7 @@ impl RequestRouteTelemetry {
/// A session-scoped client for model-provider API calls.
///
/// This holds configuration and state that should be shared across turns within a Codex session
/// (auth, provider selection, conversation id, and transport fallback state).
/// (auth, provider selection, thread id, and transport fallback state).
///
/// WebSocket fallback is session-scoped: once a turn activates the HTTP fallback, subsequent turns
/// will also use HTTP for the remainder of the session.
@@ -297,7 +305,8 @@ impl ModelClient {
/// are passed to [`ModelClientSession::stream`] (and other turn-scoped methods) explicitly.
pub fn new(
auth_manager: Option<Arc<AuthManager>>,
conversation_id: ThreadId,
session_id: SessionId,
thread_id: ThreadId,
installation_id: String,
provider_info: ModelProviderInfo,
session_source: SessionSource,
@@ -315,7 +324,8 @@ impl ModelClient {
collect_auth_env_telemetry(model_provider.info(), codex_api_key_env_enabled);
Self {
state: Arc::new(ModelClientState {
conversation_id,
session_id,
thread_id,
window_generation: AtomicU64::new(0),
installation_id,
provider: model_provider,
@@ -360,9 +370,9 @@ impl ModelClient {
}
fn current_window_id(&self) -> String {
let conversation_id = self.state.conversation_id;
let thread_id = self.state.thread_id;
let window_generation = self.state.window_generation.load(Ordering::Relaxed);
format!("{conversation_id}:{window_generation}")
format!("{thread_id}:{window_generation}")
}
fn take_cached_websocket_session(&self) -> WebsocketSession {
@@ -410,12 +420,11 @@ impl ModelClient {
///
/// The model selection and telemetry context are passed explicitly to keep `ModelClient`
/// session-scoped.
pub async fn compact_conversation_history(
pub(crate) async fn compact_conversation_history(
&self,
prompt: &Prompt,
model_info: &ModelInfo,
effort: Option<ReasoningEffortConfig>,
summary: ReasoningSummaryConfig,
settings: CompactConversationRequestSettings,
session_telemetry: &SessionTelemetry,
compaction_trace: &CompactionTraceContext,
) -> Result<Vec<ResponseItem>> {
@@ -438,9 +447,9 @@ impl ModelClient {
&client_setup.api_provider,
prompt,
model_info,
effort,
summary,
/*service_tier*/ None,
settings.effort,
settings.summary,
settings.service_tier,
)?;
let ResponsesApiRequest {
model,
@@ -449,6 +458,8 @@ impl ModelClient {
tools,
parallel_tool_calls,
reasoning,
service_tier,
prompt_cache_key,
text,
..
} = request;
@@ -462,6 +473,8 @@ impl ModelClient {
tools,
parallel_tool_calls,
reasoning,
service_tier: service_tier.as_deref(),
prompt_cache_key: prompt_cache_key.as_deref(),
text,
};
@@ -475,9 +488,10 @@ impl ModelClient {
/*turn_metadata_header*/ None,
));
extra_headers.extend(self.build_responses_identity_headers());
extra_headers.extend(build_conversation_headers(Some(
self.state.conversation_id.to_string(),
)));
extra_headers.extend(build_session_headers(
Some(self.state.session_id.to_string()),
Some(self.state.thread_id.to_string()),
));
let trace_attempt = compaction_trace.start_attempt(&payload);
let result = client
.compact_input(&payload, extra_headers)
@@ -669,7 +683,7 @@ impl ModelClient {
model_info: &ModelInfo,
effort: Option<ReasoningEffortConfig>,
summary: ReasoningSummaryConfig,
service_tier: Option<ServiceTier>,
service_tier: Option<String>,
) -> Result<ResponsesApiRequest> {
let instructions = &prompt.base_instructions.text;
let input = prompt.get_formatted_input();
@@ -696,7 +710,7 @@ impl ModelClient {
&prompt.output_schema,
prompt.output_schema_strict,
);
let prompt_cache_key = Some(self.state.conversation_id.to_string());
let prompt_cache_key = Some(self.state.thread_id.to_string());
let request = ResponsesApiRequest {
model: model_info.slug.clone(),
instructions: instructions.clone(),
@@ -708,11 +722,7 @@ impl ModelClient {
store: provider.is_azure_responses_endpoint(),
stream: true,
include,
service_tier: match service_tier {
Some(ServiceTier::Fast) => Some("priority".to_string()),
Some(service_tier) => Some(service_tier.to_string()),
None => None,
},
service_tier,
prompt_cache_key,
text,
client_metadata: Some(HashMap::from([(
@@ -850,16 +860,17 @@ impl ModelClient {
turn_metadata_header: Option<&str>,
) -> ApiHeaderMap {
let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header);
let conversation_id = self.state.conversation_id.to_string();
let session_id = self.state.session_id.to_string();
let thread_id = self.state.thread_id.to_string();
let mut headers = build_responses_headers(
self.state.beta_features_header.as_deref(),
turn_state,
turn_metadata_header.as_ref(),
);
if let Ok(header_value) = HeaderValue::from_str(&conversation_id) {
if let Ok(header_value) = HeaderValue::from_str(&thread_id) {
headers.insert("x-client-request-id", header_value);
}
headers.extend(build_conversation_headers(Some(conversation_id)));
headers.extend(build_session_headers(Some(session_id), Some(thread_id)));
headers.extend(self.build_responses_identity_headers());
headers.insert(
OPENAI_BETA_HEADER,
@@ -892,6 +903,18 @@ impl ModelClientSession {
.set_connection_reused(/*connection_reused*/ false);
}
pub(crate) async fn send_response_processed(&self, response_id: &str) {
let Some(connection) = self.websocket_session.connection.as_ref() else {
return;
};
if let Err(err) = connection
.send_response_processed(response_id.to_string())
.await
{
debug!("failed to send response.processed websocket request: {err}");
}
}
#[allow(clippy::too_many_arguments)]
/// Builds shared Responses API transport options and request-body options.
///
@@ -903,9 +926,11 @@ impl ModelClientSession {
compression: Compression,
) -> ApiResponsesOptions {
let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header);
let conversation_id = self.client.state.conversation_id.to_string();
let session_id = self.client.state.session_id.to_string();
let thread_id = self.client.state.thread_id.to_string();
ApiResponsesOptions {
conversation_id: Some(conversation_id),
session_id: Some(session_id),
thread_id: Some(thread_id),
session_source: Some(self.client.state.session_source.clone()),
extra_headers: {
let mut headers = build_responses_headers(
@@ -1151,7 +1176,7 @@ impl ModelClientSession {
session_telemetry: &SessionTelemetry,
effort: Option<ReasoningEffortConfig>,
summary: ReasoningSummaryConfig,
service_tier: Option<ServiceTier>,
service_tier: Option<String>,
turn_metadata_header: Option<&str>,
inference_trace: &InferenceTraceContext,
) -> Result<ResponseStream> {
@@ -1198,7 +1223,7 @@ impl ModelClientSession {
model_info,
effort,
summary,
service_tier,
service_tier.clone(),
)?;
let inference_trace_attempt = inference_trace.start_attempt();
inference_trace_attempt.record_started(&request);
@@ -1276,7 +1301,7 @@ impl ModelClientSession {
session_telemetry: &SessionTelemetry,
effort: Option<ReasoningEffortConfig>,
summary: ReasoningSummaryConfig,
service_tier: Option<ServiceTier>,
service_tier: Option<String>,
turn_metadata_header: Option<&str>,
warmup: bool,
request_trace: Option<W3cTraceContext>,
@@ -1304,7 +1329,7 @@ impl ModelClientSession {
model_info,
effort,
summary,
service_tier,
service_tier.clone(),
)?;
let mut ws_payload = ResponseCreateWsRequest {
client_metadata: response_create_client_metadata(
@@ -1436,7 +1461,7 @@ impl ModelClientSession {
session_telemetry: &SessionTelemetry,
effort: Option<ReasoningEffortConfig>,
summary: ReasoningSummaryConfig,
service_tier: Option<ServiceTier>,
service_tier: Option<String>,
turn_metadata_header: Option<&str>,
) -> Result<()> {
if !self.client.responses_websocket_enabled() {
@@ -1497,7 +1522,7 @@ impl ModelClientSession {
session_telemetry: &SessionTelemetry,
effort: Option<ReasoningEffortConfig>,
summary: ReasoningSummaryConfig,
service_tier: Option<ServiceTier>,
service_tier: Option<String>,
turn_metadata_header: Option<&str>,
inference_trace: &InferenceTraceContext,
) -> Result<ResponseStream> {
@@ -1513,7 +1538,7 @@ impl ModelClientSession {
session_telemetry,
effort,
summary,
service_tier,
service_tier.clone(),
turn_metadata_header,
/*warmup*/ false,
request_trace,
+5 -3
View File
@@ -52,9 +52,11 @@ use tracing_subscriber::util::SubscriberInitExt;
fn test_model_client(session_source: SessionSource) -> ModelClient {
let provider = create_oss_provider_with_base_url("https://example.com/v1", WireApi::Responses);
let thread_id = ThreadId::new();
ModelClient::new(
/*auth_manager*/ None,
ThreadId::new(),
thread_id.into(),
thread_id,
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
provider,
session_source,
@@ -270,7 +272,7 @@ fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() {
client.advance_window_generation();
let client_metadata = client.build_ws_client_metadata(Some(r#"{"turn_id":"turn-123"}"#));
let conversation_id = client.state.conversation_id;
let thread_id = client.state.thread_id;
assert_eq!(
client_metadata,
std::collections::HashMap::from([
@@ -280,7 +282,7 @@ fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() {
),
(
X_CODEX_WINDOW_ID_HEADER.to_string(),
format!("{conversation_id}:1"),
format!("{thread_id}:1"),
),
(
X_OPENAI_SUBAGENT_HEADER.to_string(),
+1
View File
@@ -76,6 +76,7 @@ pub(crate) async fn run_codex_thread_interactive(
let (tx_ops, rx_ops) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let CodexSpawnOk { codex, .. } = Box::pin(Codex::spawn(CodexSpawnArgs {
config,
installation_id: parent_session.installation_id.clone(),
auth_manager,
models_manager,
environment_manager: Arc::clone(&parent_session.services.environment_manager),
+3 -4
View File
@@ -11,7 +11,6 @@ use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
@@ -53,7 +52,7 @@ use codex_rollout::state_db::StateDbHandle;
pub struct ThreadConfigSnapshot {
pub model: String,
pub model_provider_id: String,
pub service_tier: Option<ServiceTier>,
pub service_tier: Option<String>,
pub approval_policy: AskForApproval,
pub approvals_reviewer: ApprovalsReviewer,
pub permission_profile: PermissionProfile,
@@ -91,7 +90,7 @@ pub struct CodexThreadTurnContextOverrides {
pub model: Option<String>,
pub effort: Option<Option<ReasoningEffort>>,
pub summary: Option<ReasoningSummary>,
pub service_tier: Option<Option<ServiceTier>>,
pub service_tier: Option<Option<String>>,
pub collaboration_mode: Option<CollaborationMode>,
pub personality: Option<Personality>,
}
@@ -391,7 +390,7 @@ impl CodexThread {
self.rollout_path.clone()
}
pub(crate) fn session_configured(&self) -> SessionConfiguredEvent {
pub fn session_configured(&self) -> SessionConfiguredEvent {
self.session_configured.clone()
}
+1 -1
View File
@@ -524,7 +524,7 @@ async fn drain_to_completed(
&turn_context.session_telemetry,
turn_context.reasoning_effort,
turn_context.reasoning_summary,
turn_context.config.service_tier,
turn_context.config.service_tier.clone(),
turn_metadata_header,
// Rollout tracing currently models remote compaction only; local compaction streams
// are left untraced until the reducer has a first-class local compaction lifecycle.
+6 -2
View File
@@ -2,6 +2,7 @@ use std::collections::HashSet;
use std::sync::Arc;
use crate::Prompt;
use crate::client::CompactConversationRequestSettings;
use crate::compact::CompactionAnalyticsAttempt;
use crate::compact::InitialContextInjection;
use crate::compact::compaction_status_from_result;
@@ -170,8 +171,11 @@ async fn run_remote_compact_task_inner_impl(
.compact_conversation_history(
&prompt,
&turn_context.model_info,
turn_context.reasoning_effort,
turn_context.reasoning_summary,
CompactConversationRequestSettings {
effort: turn_context.reasoning_effort,
summary: turn_context.reasoning_summary,
service_tier: turn_context.config.service_tier.clone(),
},
&turn_context.session_telemetry,
&compaction_trace,
)
+1 -1
View File
@@ -252,7 +252,7 @@ async fn run_remote_compaction_request_v2(
&turn_context.session_telemetry,
turn_context.reasoning_effort,
turn_context.reasoning_summary,
turn_context.config.service_tier,
turn_context.config.service_tier.clone(),
turn_metadata_header,
&InferenceTraceContext::disabled(),
)
+499 -63
View File
@@ -6,6 +6,7 @@ use crate::config::edit::ConfigEditsBuilder;
use crate::config::edit::apply_blocking;
use assert_matches::assert_matches;
use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigLayerEntry;
use codex_config::RequirementSource;
use codex_config::config_toml::AgentRoleToml;
use codex_config::config_toml::AgentsToml;
@@ -61,6 +62,7 @@ use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID;
use codex_model_provider_info::WireApi;
use codex_models_manager::bundled_models_response;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::ActivePermissionProfileModification;
use codex_protocol::models::ManagedFileSystemPermissions;
@@ -194,12 +196,13 @@ async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> {
"\n global instructions \n",
)?;
let config = Config::load_from_base_config_with_overrides(
let mut config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
let _ = config.features.enable(Feature::MemoryTool);
assert_eq!(
config.user_instructions.as_deref(),
@@ -2845,6 +2848,307 @@ fn filter_plugin_mcp_servers_by_allowlist_blocks_unlisted_plugin() {
);
}
#[tokio::test]
async fn rebuild_preserving_session_layers_refreshes_requirements() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home.path());
let project_dot_codex =
AbsolutePathBuf::resolve_path_against_base("project/.codex", codex_home.path());
let mcp_requirements = BTreeMap::from([
(
"session_overrides_user".to_string(),
McpServerRequirement {
identity: McpServerIdentity::Command {
command: "session-command".to_string(),
},
},
),
(
"managed_overrides_session".to_string(),
McpServerRequirement {
identity: McpServerIdentity::Command {
command: "managed-command".to_string(),
},
},
),
(
"fresh_global".to_string(),
McpServerRequirement {
identity: McpServerIdentity::Command {
command: "fresh-global-command".to_string(),
},
},
),
(
"fresh_project".to_string(),
McpServerRequirement {
identity: McpServerIdentity::Command {
command: "fresh-project-command".to_string(),
},
},
),
]);
let requirements_toml = codex_config::ConfigRequirementsToml {
mcp_servers: Some(mcp_requirements.clone()),
..Default::default()
};
let requirements = codex_config::ConfigRequirements {
mcp_servers: Some(Sourced::new(mcp_requirements, RequirementSource::Unknown)),
..Default::default()
};
let refreshed_layer_stack = ConfigLayerStack::new(
vec![
ConfigLayerEntry::new(
codex_app_server_protocol::ConfigLayerSource::User {
file: user_file.clone(),
},
toml::toml! {
[mcp_servers.session_overrides_user]
command = "new-user-command"
[mcp_servers.managed_overrides_session]
command = "new-user-command"
[mcp_servers.fresh_global]
command = "fresh-global-command"
}
.into(),
),
ConfigLayerEntry::new(
codex_app_server_protocol::ConfigLayerSource::Project {
dot_codex_folder: project_dot_codex.clone(),
},
toml::toml! {
[mcp_servers.fresh_project]
command = "fresh-project-command"
}
.into(),
),
ConfigLayerEntry::new(
codex_app_server_protocol::ConfigLayerSource::LegacyManagedConfigTomlFromMdm,
toml::toml! {
[mcp_servers.managed_overrides_session]
command = "managed-command"
}
.into(),
),
],
requirements,
requirements_toml,
)
.map_err(std::io::Error::other)?;
let refreshed_toml = refreshed_layer_stack
.effective_config()
.try_into()
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
let refreshed_config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
refreshed_toml,
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
refreshed_layer_stack,
)
.await?;
let thread_layer_stack = ConfigLayerStack::new(
vec![
ConfigLayerEntry::new(
codex_app_server_protocol::ConfigLayerSource::User {
file: user_file.clone(),
},
toml::toml! {
[mcp_servers.session_overrides_user]
command = "old-user-command"
[mcp_servers.managed_overrides_session]
command = "old-user-command"
[mcp_servers.fresh_global]
command = "old-global-command"
}
.into(),
),
ConfigLayerEntry::new(
codex_app_server_protocol::ConfigLayerSource::Project {
dot_codex_folder: project_dot_codex,
},
toml::toml! {
[mcp_servers.fresh_project]
command = "old-project-command"
}
.into(),
),
ConfigLayerEntry::new(
codex_app_server_protocol::ConfigLayerSource::SessionFlags,
toml::toml! {
[mcp_servers.session_overrides_user]
command = "session-command"
[mcp_servers.managed_overrides_session]
command = "session-command"
[mcp_servers.blocked_session]
command = "blocked-session-command"
}
.into(),
),
ConfigLayerEntry::new(
codex_app_server_protocol::ConfigLayerSource::LegacyManagedConfigTomlFromMdm,
toml::toml! {
[mcp_servers.managed_overrides_session]
command = "old-managed-command"
}
.into(),
),
],
Default::default(),
Default::default(),
)
.map_err(std::io::Error::other)?;
let thread_toml = thread_layer_stack
.effective_config()
.try_into()
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
let thread_config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
thread_toml,
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
thread_layer_stack,
)
.await?;
let config = thread_config
.rebuild_preserving_session_layers(&refreshed_config)
.await?;
assert_eq!(
config.mcp_servers.get(),
&HashMap::from([
(
"session_overrides_user".to_string(),
stdio_mcp("session-command"),
),
(
"managed_overrides_session".to_string(),
stdio_mcp("managed-command"),
),
(
"fresh_global".to_string(),
stdio_mcp("fresh-global-command"),
),
(
"fresh_project".to_string(),
stdio_mcp("fresh-project-command"),
),
(
"blocked_session".to_string(),
McpServerConfig {
enabled: false,
disabled_reason: Some(McpServerDisabledReason::Requirements {
source: RequirementSource::Unknown,
}),
..stdio_mcp("blocked-session-command")
},
),
])
);
Ok(())
}
#[tokio::test]
async fn rebuild_preserving_session_layers_refreshes_plugin_derived_mcp_config()
-> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)?;
std::fs::write(
plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample": {
"type": "http",
"url": "https://sample.example/mcp"
}
}
}"#,
)?;
let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home.path());
let refreshed_layer_stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
codex_app_server_protocol::ConfigLayerSource::User {
file: user_file.clone(),
},
toml::toml! {
[features]
plugins = true
[plugins."sample@test"]
enabled = true
}
.into(),
)],
Default::default(),
Default::default(),
)?;
let refreshed_config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
refreshed_layer_stack.effective_config().try_into()?,
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
refreshed_layer_stack,
)
.await?;
let thread_layer_stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
codex_app_server_protocol::ConfigLayerSource::User { file: user_file },
toml::toml! {
[features]
plugins = false
[plugins."sample@test"]
enabled = true
}
.into(),
)],
Default::default(),
Default::default(),
)?;
let thread_config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
thread_layer_stack.effective_config().try_into()?,
ConfigOverrides {
cwd: Some(codex_home.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
thread_layer_stack,
)
.await?;
let config = thread_config
.rebuild_preserving_session_layers(&refreshed_config)
.await?;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert_eq!(
mcp_config.configured_mcp_servers.get("sample"),
Some(&http_mcp("https://sample.example/mcp"))
);
Ok(())
}
#[tokio::test]
async fn to_mcp_config_applies_plugin_mcp_cloud_requirements() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
@@ -3042,6 +3346,77 @@ async fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result<
Ok(())
}
#[tokio::test]
async fn to_mcp_config_empty_mcp_requirements_preserve_builtin_mcps() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let requirements = codex_config::ConfigRequirementsToml {
mcp_servers: Some(BTreeMap::new()),
..Default::default()
};
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.cloud_requirements(CloudRequirementsLoader::new(async move {
Ok(Some(requirements))
}))
.build()
.await?;
config.codex_self_exe = Some(PathBuf::from("/tmp/codex"));
let _ = config.features.enable(Feature::BuiltInMcp);
let _ = config.features.enable(Feature::MemoryTool);
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert_eq!(
mcp_config
.configured_mcp_servers
.get(codex_mcp::MEMORIES_MCP_SERVER_NAME)
.map(|server| (server.enabled, server.disabled_reason.clone())),
Some((true, None))
);
Ok(())
}
#[tokio::test]
async fn to_mcp_config_nonempty_mcp_requirements_preserve_builtin_mcps() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let requirements = codex_config::ConfigRequirementsToml {
mcp_servers: Some(BTreeMap::from([(
"docs".to_string(),
McpServerRequirement {
identity: McpServerIdentity::Command {
command: "docs-mcp".to_string(),
},
},
)])),
..Default::default()
};
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.cloud_requirements(CloudRequirementsLoader::new(async move {
Ok(Some(requirements))
}))
.build()
.await?;
config.codex_self_exe = Some(PathBuf::from("/tmp/codex"));
let _ = config.features.enable(Feature::BuiltInMcp);
let _ = config.features.enable(Feature::MemoryTool);
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert_eq!(
mcp_config
.configured_mcp_servers
.get(codex_mcp::MEMORIES_MCP_SERVER_NAME)
.map(|server| (server.enabled, server.disabled_reason.clone())),
Some((true, None))
);
Ok(())
}
#[tokio::test]
async fn sqlite_home_defaults_to_codex_home_for_workspace_write() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -3864,6 +4239,107 @@ async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<(
Ok(())
}
#[tokio::test]
async fn to_mcp_config_includes_enabled_builtin_mcps() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides {
codex_self_exe: Some(PathBuf::from("/tmp/codex")),
..ConfigOverrides::default()
},
codex_home.abs(),
)
.await?;
let _ = config.features.enable(Feature::BuiltInMcp);
let _ = config.features.enable(Feature::MemoryTool);
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert_eq!(
mcp_config
.configured_mcp_servers
.get(codex_mcp::MEMORIES_MCP_SERVER_NAME)
.map(|server| &server.transport),
Some(&McpServerTransportConfig::Stdio {
command: "/tmp/codex".to_string(),
args: vec![
"builtin-mcp".to_string(),
"memories".to_string(),
"--codex-home".to_string(),
codex_home.path().display().to_string(),
],
env: None,
env_vars: Vec::new(),
cwd: None,
})
);
Ok(())
}
#[tokio::test]
async fn to_mcp_config_omits_builtin_mcps_when_feature_is_disabled() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides {
codex_self_exe: Some(PathBuf::from("/tmp/codex")),
..ConfigOverrides::default()
},
codex_home.abs(),
)
.await?;
let _ = config.features.enable(Feature::MemoryTool);
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert!(
!mcp_config
.configured_mcp_servers
.contains_key(codex_mcp::MEMORIES_MCP_SERVER_NAME)
);
Ok(())
}
#[tokio::test]
async fn to_mcp_config_reserves_enabled_builtin_mcp_names() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut config = Config::load_from_base_config_with_overrides(
ConfigToml {
mcp_servers: HashMap::from([(
codex_mcp::MEMORIES_MCP_SERVER_NAME.to_string(),
http_mcp("https://user.example/memories"),
)]),
..ConfigToml::default()
},
ConfigOverrides {
codex_self_exe: Some(PathBuf::from("/tmp/codex")),
..ConfigOverrides::default()
},
codex_home.abs(),
)
.await?;
let _ = config.features.enable(Feature::BuiltInMcp);
let _ = config.features.enable(Feature::MemoryTool);
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert!(matches!(
mcp_config
.configured_mcp_servers
.get(codex_mcp::MEMORIES_MCP_SERVER_NAME)
.map(|server| &server.transport),
Some(McpServerTransportConfig::Stdio { .. })
));
Ok(())
}
#[tokio::test]
async fn load_global_mcp_servers_rejects_inline_bearer_token() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
@@ -6657,6 +7133,28 @@ async fn explicit_null_service_tier_override_sets_fast_default_opt_out() -> std:
Ok(())
}
#[tokio::test]
async fn legacy_fast_service_tier_override_uses_priority_request_value() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
ConfigOverrides {
cwd: Some(fixture.cwd_path()),
service_tier: Some(Some("fast".to_string())),
..Default::default()
},
fixture.codex_home(),
)
.await?;
assert_eq!(
config.service_tier,
Some(ServiceTier::Fast.request_value().to_string())
);
Ok(())
}
#[tokio::test]
async fn fast_default_opt_out_notice_config_is_respected() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
@@ -8625,68 +9123,6 @@ hide_spawn_agent_metadata = true
Ok(())
}
#[tokio::test]
async fn multi_agent_v2_usage_hint_templates_use_materialized_config_values() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features.multi_agent_v2]
enabled = true
usage_hint_text = "Limit {{ features.multi_agent_v2.max_concurrent_threads_per_session }}"
root_agent_usage_hint_text = "Root {{ features.multi_agent_v2.max_concurrent_threads_per_session }}"
subagent_usage_hint_text = "Subagent {{ features.multi_agent_v2.max_concurrent_threads_per_session }}"
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(
config.multi_agent_v2.usage_hint_text.as_deref(),
Some("Limit 4")
);
assert_eq!(
config.multi_agent_v2.root_agent_usage_hint_text.as_deref(),
Some("Root 4")
);
assert_eq!(
config.multi_agent_v2.subagent_usage_hint_text.as_deref(),
Some("Subagent 4")
);
Ok(())
}
#[tokio::test]
async fn multi_agent_v2_usage_hint_templates_fail_when_placeholder_is_missing() {
let codex_home = TempDir::new().expect("create codex home");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features.multi_agent_v2]
enabled = true
usage_hint_text = "{{ features.multi_agent_v2.does_not_exist }}"
"#,
)
.expect("write config");
let err = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await
.expect_err("config load should fail");
assert!(
err.to_string()
.contains("features.multi_agent_v2.does_not_exist"),
"unexpected error: {err}",
);
}
#[tokio::test]
async fn profile_multi_agent_v2_config_overrides_base() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
+88 -70
View File
@@ -70,7 +70,9 @@ use codex_features::FeaturesToml;
use codex_features::MultiAgentV2ConfigToml;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_login::AuthManagerConfig;
use codex_mcp::BuiltinMcpServerOptions;
use codex_mcp::McpConfig;
use codex_mcp::configured_builtin_mcp_servers;
use codex_memories_read::memory_root;
use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID;
use codex_model_provider_info::ModelProviderInfo;
@@ -133,7 +135,6 @@ mod network_proxy_spec;
mod permissions;
#[cfg(test)]
mod schema;
pub(crate) mod template_interpolation;
pub use codex_config::Constrained;
pub use codex_config::ConstraintError;
pub use codex_config::ConstraintResult;
@@ -401,8 +402,8 @@ pub struct Config {
/// Optional override of model selection.
pub model: Option<String>,
/// Effective service tier preference for new turns (`fast` or `flex`).
pub service_tier: Option<ServiceTier>,
/// Effective service tier request id preference for new turns.
pub service_tier: Option<String>,
/// Model used specifically for review sessions.
pub review_model: Option<String>,
@@ -1088,6 +1089,13 @@ impl Config {
) -> McpConfig {
let plugins_input = self.plugins_config_input();
let loaded_plugins = plugins_manager.plugins_for_config(&plugins_input).await;
let builtin_mcp_servers = configured_builtin_mcp_servers(BuiltinMcpServerOptions {
codex_self_exe: self.codex_self_exe.as_deref(),
codex_home: self.codex_home.as_path(),
memories_enabled: self.features.enabled(Feature::BuiltInMcp)
&& self.features.enabled(Feature::MemoryTool)
&& self.memories.use_memories,
});
let mut configured_mcp_servers = self.mcp_servers.get().clone();
for plugin in loaded_plugins
.plugins()
@@ -1107,9 +1115,12 @@ impl Config {
if let Some(mcp_requirements) = self.config_layer_stack.requirements().mcp_servers.as_ref()
&& mcp_requirements.value.is_empty()
{
// A present empty allowlist bans all MCPs, including plugin MCPs merged above.
// A present empty allowlist bans configurable MCPs, including plugin MCPs merged
// above. Built-ins are product-owned and stay available regardless of admin
// allowlists.
filter_mcp_servers_by_requirements(&mut configured_mcp_servers, Some(mcp_requirements));
}
configured_mcp_servers.extend(builtin_mcp_servers);
McpConfig {
chatgpt_base_url: self.chatgpt_base_url.clone(),
@@ -1130,6 +1141,62 @@ impl Config {
}
}
pub async fn rebuild_preserving_session_layers(
&self,
refreshed_config: &Config,
) -> std::io::Result<Self> {
let mut layers = refreshed_config
.config_layer_stack
.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.filter(|layer| !is_session_layer(&layer.name))
.cloned()
.collect::<Vec<_>>();
layers.extend(
self.config_layer_stack
.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.filter(|layer| is_session_layer(&layer.name))
.cloned(),
);
layers.sort_by_key(|layer| layer.name.precedence());
let config_layer_stack = ConfigLayerStack::new(
layers,
refreshed_config.config_layer_stack.requirements().clone(),
refreshed_config
.config_layer_stack
.requirements_toml()
.clone(),
)?
.with_user_and_project_exec_policy_rules_ignored(
refreshed_config
.config_layer_stack
.ignore_user_and_project_exec_policy_rules(),
);
let cfg: ConfigToml = config_layer_stack
.effective_config()
.try_into()
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
Self::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
cfg,
ConfigOverrides {
cwd: Some(self.cwd.to_path_buf()),
..Default::default()
},
refreshed_config.codex_home.clone(),
config_layer_stack,
)
.await
}
/// This is the preferred way to create an instance of [Config].
pub async fn load_with_cli_overrides(
cli_overrides: Vec<(String, TomlValue)>,
@@ -1680,6 +1747,10 @@ fn thread_store_config(
}
}
fn is_session_layer(source: &ConfigLayerSource) -> bool {
matches!(source, ConfigLayerSource::SessionFlags)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PermissionConfigSyntax {
Legacy,
@@ -1796,7 +1867,7 @@ pub struct ConfigOverrides {
pub permission_profile: Option<PermissionProfile>,
pub default_permissions: Option<String>,
pub model_provider: Option<String>,
pub service_tier: Option<Option<ServiceTier>>,
pub service_tier: Option<Option<String>>,
pub config_profile: Option<String>,
pub codex_self_exe: Option<PathBuf>,
pub codex_linux_sandbox_exe: Option<PathBuf>,
@@ -2023,63 +2094,6 @@ impl Config {
overrides: ConfigOverrides,
codex_home: AbsolutePathBuf,
config_layer_stack: ConfigLayerStack,
) -> std::io::Result<Self> {
let config = Self::build_config_with_layer_stack(
fs,
cfg.clone(),
overrides.clone(),
codex_home.clone(),
config_layer_stack.clone(),
)
.await?;
let mut interpolation_source_cfg = cfg.clone();
template_interpolation::apply_resolved_config_fields(
&config,
&mut interpolation_source_cfg,
)
.map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("failed to materialize config for interpolation: {err}"),
)
})?;
let interpolation_source =
toml::Value::try_from(interpolation_source_cfg).map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("failed to serialize config for interpolation: {err}"),
)
})?;
let mut interpolated_cfg = cfg;
let interpolated = template_interpolation::interpolate_config_string_fields(
&mut interpolated_cfg,
&interpolation_source,
)
.map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("failed to interpolate config template fields: {err}"),
)
})?;
if interpolated {
return Self::build_config_with_layer_stack(
fs,
interpolated_cfg,
overrides,
codex_home,
config_layer_stack,
)
.await;
}
Ok(config)
}
async fn build_config_with_layer_stack(
fs: &dyn ExecutorFileSystem,
cfg: ConfigToml,
overrides: ConfigOverrides,
codex_home: AbsolutePathBuf,
config_layer_stack: ConfigLayerStack,
) -> std::io::Result<Self> {
// Keep the large config-construction future off small test thread stacks.
Box::pin(async move {
@@ -2721,16 +2735,20 @@ impl Config {
notices.fast_default_opt_out = Some(true);
None
}
None => config_profile.service_tier.or(cfg.service_tier),
None => config_profile
.service_tier
.or(cfg.service_tier)
.map(|service_tier| service_tier.request_value().to_string()),
};
let service_tier = match service_tier {
Some(ServiceTier::Fast) if features.enabled(Feature::FastMode) => {
Some(ServiceTier::Fast)
let service_tier = service_tier.and_then(|service_tier| {
match ServiceTier::from_request_value(&service_tier) {
Some(ServiceTier::Fast) => features
.enabled(Feature::FastMode)
.then(|| ServiceTier::Fast.request_value().to_string()),
Some(ServiceTier::Flex) => Some(ServiceTier::Flex.request_value().to_string()),
None => Some(service_tier),
}
Some(ServiceTier::Fast) => None,
Some(ServiceTier::Flex) => Some(ServiceTier::Flex),
None => None,
};
});
let compact_prompt = compact_prompt.or(cfg.compact_prompt).and_then(|value| {
let trimmed = value.trim();
@@ -1,189 +0,0 @@
use anyhow::Context;
use anyhow::bail;
use codex_config::config_toml::ConfigToml;
use codex_config::types::MemoriesToml;
use codex_features::AppsMcpPathOverrideConfigToml;
use codex_features::Feature;
use codex_features::FeatureToml;
use codex_features::FeaturesToml;
use codex_features::MultiAgentV2ConfigToml;
use codex_utils_template::Template;
use toml::Value as TomlValue;
use super::Config;
const INTERPOLATED_CONFIG_STRING_FIELDS: &[&str] = &[
"features.multi_agent_v2.usage_hint_text",
"features.multi_agent_v2.root_agent_usage_hint_text",
"features.multi_agent_v2.subagent_usage_hint_text",
];
pub(crate) fn materialized_config_toml(config: &Config) -> anyhow::Result<ConfigToml> {
let mut materialized: ConfigToml = config
.config_layer_stack
.effective_config()
.try_into()
.context("failed to deserialize effective config for config interpolation")?;
apply_resolved_config_fields(config, &mut materialized)?;
Ok(materialized)
}
pub(crate) fn interpolate_config_string_fields(
config_toml: &mut ConfigToml,
interpolation_source: &TomlValue,
) -> anyhow::Result<bool> {
let mut target_value = TomlValue::try_from(config_toml.clone())
.context("failed to serialize config for interpolation")?;
let mut changed = false;
for field_path in INTERPOLATED_CONFIG_STRING_FIELDS {
let Some(value) = value_mut_at_path(&mut target_value, field_path) else {
continue;
};
let Some(template_source) = value.as_str() else {
bail!("interpolated config field `{field_path}` must be a string");
};
let template = Template::parse(template_source)
.with_context(|| format!("failed to parse template in config field `{field_path}`"))?;
let rendered = render_template(&template, interpolation_source, field_path)?;
if rendered != template_source {
*value = TomlValue::String(rendered);
changed = true;
}
}
if changed {
*config_toml = target_value
.try_into()
.context("failed to deserialize interpolated config")?;
}
Ok(changed)
}
pub(crate) fn apply_resolved_config_fields(
config: &Config,
config_toml: &mut ConfigToml,
) -> anyhow::Result<()> {
config_toml.web_search = Some(config.web_search_mode.value());
config_toml.model_provider = Some(config.model_provider_id.clone());
config_toml.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort;
config_toml.model_verbosity = config.model_verbosity;
config_toml.include_permissions_instructions = Some(config.include_permissions_instructions);
config_toml.include_apps_instructions = Some(config.include_apps_instructions);
config_toml.include_environment_context = Some(config.include_environment_context);
config_toml.background_terminal_max_timeout = Some(config.background_terminal_max_timeout);
// Feature aliases and feature configs need to be written in their resolved
// form; otherwise replay can drift when a legacy key maps to the same
// runtime feature.
let features = config_toml
.features
.get_or_insert_with(FeaturesToml::default);
features.materialize_resolved_enabled(config.features.get());
let mut multi_agent_v2: MultiAgentV2ConfigToml =
resolved_config_to_toml(&config.multi_agent_v2, "features.multi_agent_v2")?;
multi_agent_v2.enabled = Some(config.features.enabled(Feature::MultiAgentV2));
features.multi_agent_v2 = Some(FeatureToml::Config(multi_agent_v2));
features.apps_mcp_path_override = Some(FeatureToml::Config(AppsMcpPathOverrideConfigToml {
enabled: Some(config.features.enabled(Feature::AppsMcpPathOverride)),
path: config.apps_mcp_path_override.clone(),
}));
config_toml.memories = Some(resolved_config_to_toml::<MemoriesToml>(
&config.memories,
"memories",
)?);
let agents = config_toml.agents.get_or_insert_with(Default::default);
// Multi-agent v2 owns thread fanout through its feature config. Preserve
// the legacy agents.max_threads setting only when v2 is disabled.
agents.max_threads = if config.features.enabled(Feature::MultiAgentV2) {
None
} else {
config.agent_max_threads
};
agents.max_depth = Some(config.agent_max_depth);
agents.job_max_runtime_seconds = config.agent_job_max_runtime_seconds;
agents.interrupt_message = Some(config.agent_interrupt_message_enabled);
config_toml
.skills
.get_or_insert_with(Default::default)
.include_instructions = Some(config.include_skill_instructions);
Ok(())
}
fn render_template(
template: &Template,
interpolation_source: &TomlValue,
field_path: &str,
) -> anyhow::Result<String> {
let variables =
template
.placeholders()
.map(|placeholder| {
let value = lookup_scalar_path(interpolation_source, placeholder).with_context(|| {
format!("failed to render config field `{field_path}` placeholder `{placeholder}`")
})?;
Ok((placeholder.to_string(), value))
})
.collect::<anyhow::Result<Vec<_>>>()?;
template
.render(
variables
.iter()
.map(|(name, value)| (name.as_str(), value.as_str())),
)
.with_context(|| format!("failed to render config field `{field_path}`"))
}
fn lookup_scalar_path(value: &TomlValue, path: &str) -> anyhow::Result<String> {
let resolved = value_at_path(value, path)
.with_context(|| format!("template placeholder `{path}` does not exist"))?;
match resolved {
TomlValue::String(value) => Ok(value.clone()),
TomlValue::Integer(value) => Ok(value.to_string()),
TomlValue::Float(value) => Ok(value.to_string()),
TomlValue::Boolean(value) => Ok(value.to_string()),
_ => bail!(
"template placeholder `{path}` must resolve to a scalar string, integer, float, or boolean"
),
}
}
fn value_at_path<'a>(value: &'a TomlValue, path: &str) -> Option<&'a TomlValue> {
let mut current = value;
for segment in path.split('.') {
current = current.as_table()?.get(segment)?;
}
Some(current)
}
fn value_mut_at_path<'a>(value: &'a mut TomlValue, path: &str) -> Option<&'a mut TomlValue> {
let mut current = value;
let mut segments = path.split('.').peekable();
while let Some(segment) = segments.next() {
let table = current.as_table_mut()?;
if segments.peek().is_none() {
return table.get_mut(segment);
}
current = table.get_mut(segment)?;
}
Some(current)
}
fn resolved_config_to_toml<Toml>(
value: &impl serde::Serialize,
label: &'static str,
) -> anyhow::Result<Toml>
where
Toml: serde::de::DeserializeOwned + serde::Serialize,
{
crate::config_lock::toml_round_trip(value, label).map_err(anyhow::Error::from)
}
+3
View File
@@ -45,6 +45,7 @@ use codex_mcp::ToolInfo;
use codex_mcp::ToolPluginProvenance;
use codex_mcp::codex_apps_tools_cache_key;
use codex_mcp::compute_auth_statuses;
use codex_mcp::host_owned_codex_apps_enabled;
use codex_mcp::with_codex_apps_mcp;
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
@@ -246,6 +247,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
let mcp_config = config.to_mcp_config(plugins_manager.as_ref()).await;
let mcp_servers = with_codex_apps_mcp(HashMap::new(), auth.as_ref(), &mcp_config);
let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(&mcp_config, auth.as_ref());
if mcp_servers.is_empty() {
return Ok(AccessibleConnectorsStatus {
connectors: Vec::new(),
@@ -278,6 +280,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()),
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth.as_ref()),
host_owned_codex_apps_enabled,
ToolPluginProvenance::default(),
auth.as_ref(),
)
+1 -1
View File
@@ -1894,7 +1894,7 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() ->
#[tokio::test]
async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> anyhow::Result<()> {
const TEST_STACK_SIZE_BYTES: usize = 2 * 1024 * 1024;
const TEST_STACK_SIZE_BYTES: usize = 4 * 1024 * 1024;
let handle =
std::thread::Builder::new()
-5
View File
@@ -61,14 +61,9 @@ pub use codex_mcp::SandboxState;
mod mcp_openai_file;
mod mcp_tool_call;
pub(crate) mod mention_syntax;
pub(crate) mod message_history;
pub(crate) mod utils;
pub use mention_syntax::PLUGIN_TEXT_MENTION_SIGIL;
pub use mention_syntax::TOOL_MENTION_SIGIL;
pub use message_history::HistoryEntry as MessageHistoryEntry;
pub use message_history::append_entry as append_message_history_entry;
pub use message_history::history_metadata as message_history_metadata;
pub use message_history::lookup as lookup_message_history_entry;
pub use utils::path_utils;
pub mod personality_migration;
pub(crate) mod plugins;
+132 -11
View File
@@ -42,8 +42,11 @@ use codex_config::types::AppToolApproval;
use codex_features::Feature;
use codex_hooks::PermissionRequestDecision;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::MCP_TOOL_CODEX_APPS_META_KEY;
use codex_mcp::McpPermissionPromptAutoApproveContext;
use codex_mcp::SandboxState;
use codex_mcp::auth_elicitation_completed_result;
use codex_mcp::build_auth_elicitation_plan;
use codex_mcp::declared_openai_file_input_param_names;
use codex_mcp::mcp_permission_prompt_is_auto_approved;
use codex_otel::sanitize_metric_tag_value;
@@ -53,6 +56,7 @@ use codex_protocol::items::McpToolCallStatus;
use codex_protocol::items::TurnItem;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::openai_models::InputModality;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::request_user_input::RequestUserInputAnswer;
@@ -367,9 +371,10 @@ async fn handle_approved_mcp_tool_call(
let result = execute_mcp_tool_call(
sess,
turn_context,
&server,
&tool_name,
call_id,
&invocation,
rewritten_arguments,
metadata,
request_meta,
)
.await;
@@ -568,28 +573,145 @@ fn truncate_str_to_char_boundary(value: &str, max_chars: usize) -> &str {
async fn execute_mcp_tool_call(
sess: &Session,
turn_context: &TurnContext,
server: &str,
tool_name: &str,
call_id: &str,
invocation: &McpInvocation,
rewritten_arguments: Option<JsonValue>,
metadata: Option<&McpToolApprovalMetadata>,
request_meta: Option<JsonValue>,
) -> Result<CallToolResult, String> {
let request_meta =
with_mcp_tool_call_thread_id_meta(request_meta, &sess.conversation_id.to_string());
let request_meta =
augment_mcp_tool_request_meta_with_sandbox_state(sess, turn_context, server, request_meta)
.await
.map_err(|e| format!("failed to build MCP tool request metadata: {e:#}"))?;
let request_meta = augment_mcp_tool_request_meta_with_sandbox_state(
sess,
turn_context,
&invocation.server,
request_meta,
)
.await
.map_err(|e| format!("failed to build MCP tool request metadata: {e:#}"))?;
let result = sess
.call_tool(server, tool_name, rewritten_arguments, request_meta)
.call_tool(
&invocation.server,
&invocation.tool,
rewritten_arguments,
request_meta,
)
.await
.map_err(|e| format!("tool call error: {e:?}"))?;
sanitize_mcp_tool_result_for_model(
let result = sanitize_mcp_tool_result_for_model(
turn_context
.model_info
.input_modalities
.contains(&InputModality::Image),
Ok(result),
)?;
Ok(maybe_request_codex_apps_auth_elicitation(
sess,
turn_context,
call_id,
&invocation.server,
metadata,
result,
)
.await)
}
async fn maybe_request_codex_apps_auth_elicitation(
sess: &Session,
turn_context: &TurnContext,
call_id: &str,
server: &str,
metadata: Option<&McpToolApprovalMetadata>,
result: CallToolResult,
) -> CallToolResult {
if !sess
.services
.mcp_connection_manager
.read()
.await
.is_host_owned_codex_apps_server(server)
{
return result;
}
if !turn_context.features.enabled(Feature::AuthElicitation) {
return result;
}
match turn_context.approval_policy.value() {
AskForApproval::Never => return result,
AskForApproval::Granular(granular_config) if !granular_config.allows_mcp_elicitations() => {
return result;
}
AskForApproval::OnFailure
| AskForApproval::OnRequest
| AskForApproval::UnlessTrusted
| AskForApproval::Granular(_) => {}
}
let connector_id = metadata.and_then(|metadata| metadata.connector_id.as_deref());
let connector_name = metadata.and_then(|metadata| metadata.connector_name.as_deref());
let install_url = connector_id.map(|connector_id| {
codex_connectors::metadata::connector_install_url(
connector_name.unwrap_or(connector_id),
connector_id,
)
});
let Some(plan) =
build_auth_elicitation_plan(call_id, &result, connector_id, connector_name, install_url)
else {
return result;
};
let request_id = rmcp::model::RequestId::String(plan.elicitation.elicitation_id.clone().into());
let params = McpServerElicitationRequestParams {
thread_id: sess.conversation_id.to_string(),
turn_id: Some(turn_context.sub_id.clone()),
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
request: McpServerElicitationRequest::Url {
meta: Some(plan.elicitation.meta),
message: plan.elicitation.message,
url: plan.elicitation.url,
elicitation_id: plan.elicitation.elicitation_id,
},
};
let response = sess
.request_mcp_server_elicitation(turn_context, request_id, params)
.await;
if !response
.as_ref()
.is_some_and(|response| response.action == ElicitationAction::Accept)
{
return result;
}
refresh_codex_apps_after_connector_auth(sess, turn_context).await;
auth_elicitation_completed_result(&plan.auth_failure, result.meta)
}
#[expect(
clippy::await_holding_invalid_type,
reason = "Codex Apps cache refresh reads through the session-owned manager guard"
)]
async fn refresh_codex_apps_after_connector_auth(sess: &Session, turn_context: &TurnContext) {
let mcp_tools_result = {
let manager = sess.services.mcp_connection_manager.read().await;
manager.hard_refresh_codex_apps_tools_cache().await
};
match mcp_tools_result {
Ok(mcp_tools) => {
let auth = sess.services.auth_manager.auth().await;
connectors::refresh_accessible_connectors_cache_from_mcp_tools(
&turn_context.config,
auth.as_ref(),
&mcp_tools,
);
}
Err(err) => {
tracing::warn!("failed to refresh Codex Apps tools after connector auth: {err:#}");
}
}
}
#[expect(
@@ -862,7 +984,6 @@ pub(crate) struct McpToolApprovalMetadata {
openai_file_input_params: Option<Vec<String>>,
}
const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps";
const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate";
const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri";
const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId";
+247
View File
@@ -1,5 +1,6 @@
use super::*;
use crate::config::ConfigBuilder;
use crate::config::ManagedFeatures;
use crate::session::tests::make_session_and_context;
use crate::session::tests::make_session_and_context_with_rx;
use crate::state::ActiveTurn;
@@ -14,11 +15,13 @@ use codex_config::types::ApprovalsReviewer;
use codex_config::types::AppsConfigToml;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerToolConfig;
use codex_features::Features;
use codex_hooks::Hooks;
use codex_hooks::HooksConfig;
use codex_model_provider::create_model_provider;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::GranularApprovalConfig;
use core_test_support::PathExt;
use core_test_support::hooks::trusted_config_layer_stack;
@@ -1065,6 +1068,250 @@ async fn codex_apps_tool_call_request_meta_includes_call_id_without_existing_cod
);
}
fn codex_apps_auth_failure_result() -> CallToolResult {
CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": "Connector reauthentication required",
})],
structured_content: None,
is_error: Some(true),
meta: Some(serde_json::json!({
MCP_TOOL_CODEX_APPS_META_KEY: {
"connector_auth_failure": {
"is_auth_failure": true,
"auth_reason": "reauthentication_required",
"connector_id": "connector_calendar",
"connector_name": "Untrusted Calendar",
"link_id": "link_123",
"error_code": "UNAUTHORIZED",
"error_http_status_code": 401,
"error_action": "TRIGGER_REAUTHENTICATION",
},
},
})),
}
}
fn codex_apps_auth_failure_metadata() -> McpToolApprovalMetadata {
approval_metadata(
Some("connector_calendar"),
Some("Google Calendar"),
Some("Manage events and schedules."),
Some("Create Event"),
Some("Create a calendar event."),
)
}
async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: &TurnContext) {
let auth = session.services.auth_manager.auth().await;
let environment = session
.services
.environment_manager
.default_environment()
.unwrap_or_else(|| session.services.environment_manager.local_environment());
let (manager, _cancel_token) = codex_mcp::McpConnectionManager::new(
&HashMap::new(),
turn_context.config.mcp_oauth_credentials_store_mode,
HashMap::new(),
&turn_context.approval_policy,
turn_context.sub_id.clone(),
session.get_tx_event(),
turn_context.permission_profile(),
codex_mcp::McpRuntimeEnvironment::new(environment, turn_context.cwd.to_path_buf()),
turn_context.config.codex_home.to_path_buf(),
codex_mcp::codex_apps_tools_cache_key(auth.as_ref()),
/*host_owned_codex_apps_enabled*/ true,
codex_mcp::ToolPluginProvenance::default(),
auth.as_ref(),
)
.await;
*session.services.mcp_connection_manager.write().await = manager;
}
#[tokio::test]
async fn codex_apps_auth_elicitation_feature_disabled_returns_original_result() {
let (session, turn_context, rx_event) = make_session_and_context_with_rx().await;
install_host_owned_codex_apps_manager(&session, &turn_context).await;
let result = codex_apps_auth_failure_result();
let metadata = codex_apps_auth_failure_metadata();
let returned = maybe_request_codex_apps_auth_elicitation(
&session,
&turn_context,
"call_123",
CODEX_APPS_MCP_SERVER_NAME,
Some(&metadata),
result.clone(),
)
.await;
assert_eq!(returned, result);
assert!(rx_event.try_recv().is_err());
}
#[tokio::test]
async fn codex_apps_auth_elicitation_non_host_owned_server_returns_original_result() {
let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await;
let mut features = Features::with_defaults();
features.enable(Feature::AuthElicitation);
Arc::get_mut(&mut turn_context)
.expect("single turn context ref")
.features = ManagedFeatures::from(features);
let result = codex_apps_auth_failure_result();
let metadata = codex_apps_auth_failure_metadata();
let returned = maybe_request_codex_apps_auth_elicitation(
&session,
&turn_context,
"call_123",
CODEX_APPS_MCP_SERVER_NAME,
Some(&metadata),
result.clone(),
)
.await;
assert_eq!(returned, result);
assert!(rx_event.try_recv().is_err());
}
#[tokio::test]
async fn codex_apps_auth_elicitation_disallowed_by_policy_returns_original_result() {
let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await;
install_host_owned_codex_apps_manager(&session, &turn_context).await;
let mut features = Features::with_defaults();
features.enable(Feature::AuthElicitation);
let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref");
turn_context.features = ManagedFeatures::from(features);
turn_context
.approval_policy
.set(AskForApproval::Never)
.expect("test setup should allow updating approval policy");
let result = codex_apps_auth_failure_result();
let metadata = codex_apps_auth_failure_metadata();
let returned = maybe_request_codex_apps_auth_elicitation(
&session,
turn_context,
"call_123",
CODEX_APPS_MCP_SERVER_NAME,
Some(&metadata),
result.clone(),
)
.await;
assert_eq!(returned, result);
assert!(rx_event.try_recv().is_err());
}
#[tokio::test]
async fn codex_apps_auth_elicitation_granular_mcp_disabled_returns_original_result() {
let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await;
install_host_owned_codex_apps_manager(&session, &turn_context).await;
let mut features = Features::with_defaults();
features.enable(Feature::AuthElicitation);
let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref");
turn_context.features = ManagedFeatures::from(features);
turn_context
.approval_policy
.set(AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: true,
rules: true,
skill_approval: true,
request_permissions: true,
mcp_elicitations: false,
}))
.expect("test setup should allow updating approval policy");
let result = codex_apps_auth_failure_result();
let metadata = codex_apps_auth_failure_metadata();
let returned = maybe_request_codex_apps_auth_elicitation(
&session,
turn_context,
"call_123",
CODEX_APPS_MCP_SERVER_NAME,
Some(&metadata),
result.clone(),
)
.await;
assert_eq!(returned, result);
assert!(rx_event.try_recv().is_err());
}
#[tokio::test]
async fn codex_apps_auth_elicitation_feature_enabled_requests_elicitation() {
let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await;
install_host_owned_codex_apps_manager(&session, &turn_context).await;
*session.active_turn.lock().await = Some(ActiveTurn::default());
let mut features = Features::with_defaults();
features.enable(Feature::AuthElicitation);
Arc::get_mut(&mut turn_context)
.expect("single turn context ref")
.features = ManagedFeatures::from(features);
let result = codex_apps_auth_failure_result();
let metadata = codex_apps_auth_failure_metadata();
let request_task = tokio::spawn({
let session = Arc::clone(&session);
let turn_context = Arc::clone(&turn_context);
async move {
maybe_request_codex_apps_auth_elicitation(
&session,
&turn_context,
"call_123",
CODEX_APPS_MCP_SERVER_NAME,
Some(&metadata),
result,
)
.await
}
});
let request = loop {
let event = tokio::time::timeout(std::time::Duration::from_secs(1), rx_event.recv())
.await
.expect("elicitation event timed out")
.expect("expected elicitation event");
if let EventMsg::ElicitationRequest(request) = event.msg {
break request;
}
};
assert_eq!(request.server_name, CODEX_APPS_MCP_SERVER_NAME);
assert_eq!(
request.id,
codex_protocol::mcp::RequestId::String("codex_apps_auth_call_123".to_string())
);
assert!(matches!(
request.request,
codex_protocol::approvals::ElicitationRequest::Url { .. }
));
session
.resolve_elicitation(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
rmcp::model::RequestId::String("codex_apps_auth_call_123".into()),
ElicitationResponse {
action: ElicitationAction::Accept,
content: None,
meta: None,
},
)
.await
.expect("elicitation should resolve");
let returned = tokio::time::timeout(std::time::Duration::from_secs(1), request_task)
.await
.expect("auth elicitation task timed out")
.expect("auth elicitation task failed");
assert_eq!(
returned.content,
vec![serde_json::json!({
"type": "text",
"text": "Authentication for Google Calendar was requested and accepted. Retry this tool call now.",
})]
);
}
#[test]
fn mcp_tool_call_thread_id_meta_is_added_to_request_meta() {
assert_eq!(
-418
View File
@@ -1,418 +0,0 @@
//! Persistence layer for the global, append-only *message history* file.
//!
//! The history is stored at `~/.codex/history.jsonl` with **one JSON object per
//! line** so that it can be efficiently appended to and parsed with standard
//! JSON-Lines tooling. Each record has the following schema:
//!
//! ````text
//! {"conversation_id":"<uuid>","ts":<unix_seconds>,"text":"<message>"}
//! ````
//!
//! To minimise the chance of interleaved writes when multiple processes are
//! appending concurrently, callers should *prepare the full line* (record +
//! trailing `\n`) and write it with a **single `write(2)` system call** while
//! the file descriptor is opened with the `O_APPEND` flag. POSIX guarantees
//! that writes up to `PIPE_BUF` bytes are atomic in that case.
//! Note: `conversation_id` stores the thread id; the field name is preserved for
//! backwards compatibility with existing history files.
use std::fs::File;
use std::fs::OpenOptions;
use std::io::BufRead;
use std::io::BufReader;
use std::io::Read;
use std::io::Result;
use std::io::Seek;
use std::io::SeekFrom;
use std::io::Write;
use std::path::Path;
use serde::Deserialize;
use serde::Serialize;
use std::time::Duration;
use tokio::fs;
use tokio::io::AsyncReadExt;
use crate::config::Config;
use codex_config::types::HistoryPersistence;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_protocol::ThreadId;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
/// Filename that stores the message history inside `~/.codex`.
const HISTORY_FILENAME: &str = "history.jsonl";
/// When history exceeds the hard cap, trim it down to this fraction of `max_bytes`.
const HISTORY_SOFT_CAP_RATIO: f64 = 0.8;
const MAX_RETRIES: usize = 10;
const RETRY_SLEEP: Duration = Duration::from_millis(100);
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct HistoryEntry {
pub session_id: String,
pub ts: u64,
pub text: String,
}
fn history_filepath(config: &Config) -> AbsolutePathBuf {
config.codex_home.join(HISTORY_FILENAME)
}
/// Append a `text` entry associated with `conversation_id` to the history file.
///
/// Uses advisory file locking (`File::try_lock`) with a retry loop to ensure
/// concurrent writes from multiple TUI processes do not interleave. The lock
/// acquisition and write are performed inside `spawn_blocking` so the caller's
/// async runtime is not blocked.
///
/// The entry is silently skipped when `config.history.persistence` is
/// [`HistoryPersistence::None`].
///
/// # Errors
///
/// Returns an I/O error if the history file cannot be opened/created, the
/// system clock is before the Unix epoch, or the exclusive lock cannot be
/// acquired after [`MAX_RETRIES`] attempts.
pub async fn append_entry(text: &str, conversation_id: &ThreadId, config: &Config) -> Result<()> {
match config.history.persistence {
HistoryPersistence::SaveAll => {
// Save everything: proceed.
}
HistoryPersistence::None => {
// No history persistence requested.
return Ok(());
}
}
// TODO: check `text` for sensitive patterns
// Resolve `~/.codex/history.jsonl` and ensure the parent directory exists.
let path = history_filepath(config);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
// Compute timestamp (seconds since the Unix epoch).
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| std::io::Error::other(format!("system clock before Unix epoch: {e}")))?
.as_secs();
// Construct the JSON line first so we can write it in a single syscall.
let entry = HistoryEntry {
session_id: conversation_id.to_string(),
ts,
text: text.to_string(),
};
let mut line = serde_json::to_string(&entry)
.map_err(|e| std::io::Error::other(format!("failed to serialise history entry: {e}")))?;
line.push('\n');
// Open the history file for read/write access (append-only on Unix).
let mut options = OpenOptions::new();
options.read(true).write(true).create(true);
#[cfg(unix)]
{
options.append(true);
options.mode(0o600);
}
let mut history_file = options.open(&path)?;
// Ensure permissions.
ensure_owner_only_permissions(&history_file).await?;
let history_max_bytes = config.history.max_bytes;
// Perform a blocking write under an advisory write lock using std::fs.
tokio::task::spawn_blocking(move || -> Result<()> {
// Retry a few times to avoid indefinite blocking when contended.
for _ in 0..MAX_RETRIES {
match history_file.try_lock() {
Ok(()) => {
// While holding the exclusive lock, write the full line.
// We do not open the file with `append(true)` on Windows, so ensure the
// cursor is positioned at the end before writing.
history_file.seek(SeekFrom::End(0))?;
history_file.write_all(line.as_bytes())?;
history_file.flush()?;
enforce_history_limit(&mut history_file, history_max_bytes)?;
return Ok(());
}
Err(std::fs::TryLockError::WouldBlock) => {
std::thread::sleep(RETRY_SLEEP);
}
Err(e) => return Err(e.into()),
}
}
Err(std::io::Error::new(
std::io::ErrorKind::WouldBlock,
"could not acquire exclusive lock on history file after multiple attempts",
))
})
.await??;
Ok(())
}
/// Trim the history file to honor `max_bytes`, dropping the oldest lines while holding
/// the write lock so the newest entry is always retained. When the file exceeds the
/// hard cap, it rewrites the remaining tail to a soft cap to avoid trimming again
/// immediately on the next write.
fn enforce_history_limit(file: &mut File, max_bytes: Option<usize>) -> Result<()> {
let Some(max_bytes) = max_bytes else {
return Ok(());
};
if max_bytes == 0 {
return Ok(());
}
let max_bytes = match u64::try_from(max_bytes) {
Ok(value) => value,
Err(_) => return Ok(()),
};
let mut current_len = file.metadata()?.len();
if current_len <= max_bytes {
return Ok(());
}
let mut reader_file = file.try_clone()?;
reader_file.seek(SeekFrom::Start(0))?;
let mut buf_reader = BufReader::new(reader_file);
let mut line_lengths = Vec::new();
let mut line_buf = String::new();
loop {
line_buf.clear();
let bytes = buf_reader.read_line(&mut line_buf)?;
if bytes == 0 {
break;
}
line_lengths.push(bytes as u64);
}
if line_lengths.is_empty() {
return Ok(());
}
let last_index = line_lengths.len() - 1;
let trim_target = trim_target_bytes(max_bytes, line_lengths[last_index]);
let mut drop_bytes = 0u64;
let mut idx = 0usize;
while current_len > trim_target && idx < last_index {
current_len = current_len.saturating_sub(line_lengths[idx]);
drop_bytes += line_lengths[idx];
idx += 1;
}
if drop_bytes == 0 {
return Ok(());
}
let mut reader = buf_reader.into_inner();
reader.seek(SeekFrom::Start(drop_bytes))?;
let capacity = usize::try_from(current_len).unwrap_or(0);
let mut tail = Vec::with_capacity(capacity);
reader.read_to_end(&mut tail)?;
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
file.write_all(&tail)?;
file.flush()?;
Ok(())
}
fn trim_target_bytes(max_bytes: u64, newest_entry_len: u64) -> u64 {
let soft_cap_bytes = ((max_bytes as f64) * HISTORY_SOFT_CAP_RATIO)
.floor()
.clamp(1.0, max_bytes as f64) as u64;
soft_cap_bytes.max(newest_entry_len)
}
/// Asynchronously fetch the history file's *identifier* and current entry count.
///
/// The identifier is the file's inode on Unix or creation time on Windows.
/// The entry count is derived by counting newline bytes in the file. Returns
/// `(0, 0)` when the file does not exist or its metadata cannot be read. If
/// metadata succeeds but the file cannot be opened or scanned, returns
/// `(log_id, 0)` so callers can still detect that a history file exists.
pub async fn history_metadata(config: &Config) -> (u64, usize) {
let path = history_filepath(config);
history_metadata_for_file(&path).await
}
/// Look up a single history entry by file identity and zero-based offset.
///
/// Returns `Some(entry)` when the current history file's identifier (inode on
/// Unix, creation time on Windows) matches `log_id` **and** a valid JSON
/// record exists at `offset`. Returns `None` on any mismatch, I/O error, or
/// parse failure, all of which are logged at `warn` level.
///
/// This function is synchronous because it acquires a shared advisory file lock
/// via `File::try_lock_shared`. Callers on an async runtime should wrap it in
/// `spawn_blocking`.
pub fn lookup(log_id: u64, offset: usize, config: &Config) -> Option<HistoryEntry> {
let path = history_filepath(config);
lookup_history_entry(&path, log_id, offset)
}
/// On Unix systems, ensure the file permissions are `0o600` (rw-------). If the
/// permissions cannot be changed the error is propagated to the caller.
#[cfg(unix)]
async fn ensure_owner_only_permissions(file: &File) -> Result<()> {
let metadata = file.metadata()?;
let current_mode = metadata.permissions().mode() & 0o777;
if current_mode != 0o600 {
let mut perms = metadata.permissions();
perms.set_mode(0o600);
let perms_clone = perms.clone();
let file_clone = file.try_clone()?;
tokio::task::spawn_blocking(move || file_clone.set_permissions(perms_clone)).await??;
}
Ok(())
}
#[cfg(windows)]
// On Windows, simply succeed.
async fn ensure_owner_only_permissions(_file: &File) -> Result<()> {
Ok(())
}
async fn history_metadata_for_file(path: &Path) -> (u64, usize) {
let log_id = match fs::metadata(path).await {
Ok(metadata) => history_log_id(&metadata).unwrap_or(0),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (0, 0),
Err(_) => return (0, 0),
};
// Open the file.
let mut file = match fs::File::open(path).await {
Ok(f) => f,
Err(_) => return (log_id, 0),
};
// Count newline bytes.
let mut buf = [0u8; 8192];
let mut count = 0usize;
loop {
match file.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
count += buf[..n].iter().filter(|&&b| b == b'\n').count();
}
Err(_) => return (log_id, 0),
}
}
(log_id, count)
}
fn lookup_history_entry(path: &Path, log_id: u64, offset: usize) -> Option<HistoryEntry> {
use std::io::BufRead;
use std::io::BufReader;
let file: File = match OpenOptions::new().read(true).open(path) {
Ok(f) => f,
Err(e) => {
tracing::warn!(error = %e, "failed to open history file");
return None;
}
};
let metadata = match file.metadata() {
Ok(m) => m,
Err(e) => {
tracing::warn!(error = %e, "failed to stat history file");
return None;
}
};
let current_log_id = history_log_id(&metadata)?;
if log_id != 0 && current_log_id != log_id {
return None;
}
// Open & lock file for reading using a shared lock.
// Retry a few times to avoid indefinite blocking.
for _ in 0..MAX_RETRIES {
let lock_result = file.try_lock_shared();
match lock_result {
Ok(()) => {
let reader = BufReader::new(&file);
for (idx, line_res) in reader.lines().enumerate() {
let line = match line_res {
Ok(l) => l,
Err(e) => {
tracing::warn!(error = %e, "failed to read line from history file");
return None;
}
};
if idx == offset {
match serde_json::from_str::<HistoryEntry>(&line) {
Ok(entry) => return Some(entry),
Err(e) => {
tracing::warn!(error = %e, "failed to parse history entry");
return None;
}
}
}
}
// Not found at requested offset.
return None;
}
Err(std::fs::TryLockError::WouldBlock) => {
std::thread::sleep(RETRY_SLEEP);
}
Err(e) => {
tracing::warn!(error = %e, "failed to acquire shared lock on history file");
return None;
}
}
}
None
}
#[cfg(unix)]
fn history_log_id(metadata: &std::fs::Metadata) -> Option<u64> {
use std::os::unix::fs::MetadataExt;
Some(metadata.ino())
}
#[cfg(windows)]
fn history_log_id(metadata: &std::fs::Metadata) -> Option<u64> {
use std::os::windows::fs::MetadataExt;
Some(metadata.creation_time())
}
#[cfg(not(any(unix, windows)))]
fn history_log_id(_metadata: &std::fs::Metadata) -> Option<u64> {
None
}
#[cfg(test)]
#[path = "message_history_tests.rs"]
mod tests;
-211
View File
@@ -1,211 +0,0 @@
use super::*;
use crate::config::ConfigBuilder;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use std::fs::File;
use std::io::Write;
use tempfile::TempDir;
#[tokio::test]
async fn lookup_reads_history_entries() {
let temp_dir = TempDir::new().expect("create temp dir");
let history_path = temp_dir.path().join(HISTORY_FILENAME);
let entries = vec![
HistoryEntry {
session_id: "first-session".to_string(),
ts: 1,
text: "first".to_string(),
},
HistoryEntry {
session_id: "second-session".to_string(),
ts: 2,
text: "second".to_string(),
},
];
let mut file = File::create(&history_path).expect("create history file");
for entry in &entries {
writeln!(
file,
"{}",
serde_json::to_string(entry).expect("serialize history entry")
)
.expect("write history entry");
}
let (log_id, count) = history_metadata_for_file(&history_path).await;
assert_eq!(count, entries.len());
let second_entry = lookup_history_entry(&history_path, log_id, /*offset*/ 1)
.expect("fetch second history entry");
assert_eq!(second_entry, entries[1]);
}
#[tokio::test]
async fn lookup_uses_stable_log_id_after_appends() {
let temp_dir = TempDir::new().expect("create temp dir");
let history_path = temp_dir.path().join(HISTORY_FILENAME);
let initial = HistoryEntry {
session_id: "first-session".to_string(),
ts: 1,
text: "first".to_string(),
};
let appended = HistoryEntry {
session_id: "second-session".to_string(),
ts: 2,
text: "second".to_string(),
};
let mut file = File::create(&history_path).expect("create history file");
writeln!(
file,
"{}",
serde_json::to_string(&initial).expect("serialize initial entry")
)
.expect("write initial entry");
let (log_id, count) = history_metadata_for_file(&history_path).await;
assert_eq!(count, 1);
let mut append = std::fs::OpenOptions::new()
.append(true)
.open(&history_path)
.expect("open history file for append");
writeln!(
append,
"{}",
serde_json::to_string(&appended).expect("serialize appended entry")
)
.expect("append history entry");
let fetched = lookup_history_entry(&history_path, log_id, /*offset*/ 1)
.expect("lookup appended history entry");
assert_eq!(fetched, appended);
}
#[tokio::test]
async fn append_entry_trims_history_when_beyond_max_bytes() {
let codex_home = TempDir::new().expect("create temp dir");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("load config");
let conversation_id = ThreadId::new();
let entry_one = "a".repeat(200);
let entry_two = "b".repeat(200);
let history_path = codex_home.path().join("history.jsonl");
append_entry(&entry_one, &conversation_id, &config)
.await
.expect("write first entry");
let first_len = std::fs::metadata(&history_path).expect("metadata").len();
let limit_bytes = first_len + 10;
config.history.max_bytes =
Some(usize::try_from(limit_bytes).expect("limit should fit into usize"));
append_entry(&entry_two, &conversation_id, &config)
.await
.expect("write second entry");
let contents = std::fs::read_to_string(&history_path).expect("read history");
let entries = contents
.lines()
.map(|line| serde_json::from_str::<HistoryEntry>(line).expect("parse entry"))
.collect::<Vec<HistoryEntry>>();
assert_eq!(
entries.len(),
1,
"only one entry left because entry_one should be evicted"
);
assert_eq!(entries[0].text, entry_two);
assert!(std::fs::metadata(&history_path).expect("metadata").len() <= limit_bytes);
}
#[tokio::test]
async fn append_entry_trims_history_to_soft_cap() {
let codex_home = TempDir::new().expect("create temp dir");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("load config");
let conversation_id = ThreadId::new();
let short_entry = "a".repeat(200);
let long_entry = "b".repeat(400);
let history_path = codex_home.path().join("history.jsonl");
append_entry(&short_entry, &conversation_id, &config)
.await
.expect("write first entry");
let short_entry_len = std::fs::metadata(&history_path).expect("metadata").len();
append_entry(&long_entry, &conversation_id, &config)
.await
.expect("write second entry");
let two_entry_len = std::fs::metadata(&history_path).expect("metadata").len();
let long_entry_len = two_entry_len
.checked_sub(short_entry_len)
.expect("second entry length should be larger than first entry length");
config.history.max_bytes = Some(
usize::try_from((2 * long_entry_len) + (short_entry_len / 2))
.expect("max bytes should fit into usize"),
);
append_entry(&long_entry, &conversation_id, &config)
.await
.expect("write third entry");
let contents = std::fs::read_to_string(&history_path).expect("read history");
let entries = contents
.lines()
.map(|line| serde_json::from_str::<HistoryEntry>(line).expect("parse entry"))
.collect::<Vec<HistoryEntry>>();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].text, long_entry);
let pruned_len = std::fs::metadata(&history_path).expect("metadata").len();
let max_bytes = config
.history
.max_bytes
.expect("max bytes should be configured") as u64;
assert!(pruned_len <= max_bytes);
let soft_cap_bytes = ((max_bytes as f64) * HISTORY_SOFT_CAP_RATIO)
.floor()
.clamp(1.0, max_bytes as f64) as u64;
let len_without_first = 2 * long_entry_len;
assert!(
len_without_first <= max_bytes,
"dropping only the first entry would satisfy the hard cap"
);
assert!(
len_without_first > soft_cap_bytes,
"soft cap should require more aggressive trimming than the hard cap"
);
assert_eq!(pruned_len, long_entry_len);
assert!(pruned_len <= soft_cap_bytes.max(long_entry_len));
}
+3
View File
@@ -13,6 +13,7 @@ use codex_protocol::user_input::UserInput;
use tokio_util::sync::CancellationToken;
use crate::config::Config;
use crate::resolve_installation_id;
use crate::session::session::Session;
use crate::session::turn::build_prompt;
use crate::session::turn::built_tools;
@@ -42,6 +43,7 @@ pub async fn build_prompt_input(
.ok_or_else(|| std::io::Error::other("prompt debug requires state db"))?;
let thread_store = thread_store_from_config(&config, state_db.clone());
let agent_graph_store = agent_graph_store_from_state_db(state_db.clone());
let installation_id = resolve_installation_id(&config.codex_home).await?;
let thread_manager = ThreadManager::new(
&config,
Arc::clone(&auth_manager),
@@ -51,6 +53,7 @@ pub async fn build_prompt_input(
state_db,
thread_store,
agent_graph_store,
installation_id,
);
let thread = thread_manager.start_thread(config).await?;
+90 -6
View File
@@ -1,12 +1,19 @@
use anyhow::Context;
use codex_config::config_toml::ConfigLockfileToml;
use codex_config::config_toml::ConfigToml;
use codex_config::types::MemoriesToml;
use codex_features::AppsMcpPathOverrideConfigToml;
use codex_features::Feature;
use codex_features::FeatureToml;
use codex_features::FeaturesToml;
use codex_features::MultiAgentV2ConfigToml;
use codex_protocol::ThreadId;
use crate::config::template_interpolation::materialized_config_toml;
use crate::config::Config;
use crate::config_lock::ConfigLockReplayOptions;
use crate::config_lock::clear_config_lock_debug_controls;
use crate::config_lock::config_lockfile;
use crate::config_lock::toml_round_trip;
use crate::config_lock::validate_config_lock_replay;
use super::SessionConfiguration;
@@ -74,12 +81,20 @@ fn session_configuration_to_lock_config_toml(
sc: &SessionConfiguration,
) -> anyhow::Result<ConfigToml> {
let config = sc.original_config_do_not_use.as_ref();
let mut lock_config = materialized_config_toml(config)?;
// Start from the resolved layer stack, then patch in values that are only
// known after session setup. Export and replay validation both use this
// path, so every field here is part of the lockfile contract.
let mut lock_config: ConfigToml = config
.config_layer_stack
.effective_config()
.try_into()
.context("failed to deserialize effective config for config lock")?;
if config.config_lock_save_fields_resolved_from_model_catalog {
save_session_resolved_fields(sc, &mut lock_config);
}
save_config_resolved_fields(config, &mut lock_config)?;
drop_lockfile_inputs(&mut lock_config);
Ok(lock_config)
@@ -94,7 +109,10 @@ fn save_session_resolved_fields(sc: &SessionConfiguration, lock_config: &mut Con
lock_config.model = Some(sc.collaboration_mode.model().to_string());
lock_config.model_reasoning_effort = sc.collaboration_mode.reasoning_effort();
lock_config.model_reasoning_summary = sc.model_reasoning_summary;
lock_config.service_tier = sc.service_tier;
lock_config.service_tier = sc
.service_tier
.as_deref()
.and_then(codex_protocol::config_types::ServiceTier::from_request_value);
lock_config.instructions = Some(sc.base_instructions.clone());
lock_config.developer_instructions = sc.developer_instructions.clone();
lock_config.compact_prompt = sc.compact_prompt.clone();
@@ -103,6 +121,64 @@ fn save_session_resolved_fields(sc: &SessionConfiguration, lock_config: &mut Con
lock_config.approvals_reviewer = Some(sc.approvals_reviewer);
}
/// Saves values stored on `Config` after higher-level resolution,
/// normalization, defaulting, or feature materialization.
///
/// Persist the resolved representation so replay compares against the behavior
/// Codex actually ran with, not only the user-authored TOML inputs.
fn save_config_resolved_fields(
config: &Config,
lock_config: &mut ConfigToml,
) -> anyhow::Result<()> {
lock_config.web_search = Some(config.web_search_mode.value());
lock_config.model_provider = Some(config.model_provider_id.clone());
lock_config.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort;
lock_config.model_verbosity = config.model_verbosity;
lock_config.include_permissions_instructions = Some(config.include_permissions_instructions);
lock_config.include_apps_instructions = Some(config.include_apps_instructions);
lock_config.include_environment_context = Some(config.include_environment_context);
lock_config.background_terminal_max_timeout = Some(config.background_terminal_max_timeout);
// Feature aliases and feature configs need to be written in their resolved
// form; otherwise replay can drift when a legacy key maps to the same
// runtime feature.
let features = lock_config
.features
.get_or_insert_with(FeaturesToml::default);
features.materialize_resolved_enabled(config.features.get());
let mut multi_agent_v2: MultiAgentV2ConfigToml =
resolved_config_to_toml(&config.multi_agent_v2, "features.multi_agent_v2")?;
multi_agent_v2.enabled = Some(config.features.enabled(Feature::MultiAgentV2));
features.multi_agent_v2 = Some(FeatureToml::Config(multi_agent_v2));
features.apps_mcp_path_override = Some(FeatureToml::Config(AppsMcpPathOverrideConfigToml {
enabled: Some(config.features.enabled(Feature::AppsMcpPathOverride)),
path: config.apps_mcp_path_override.clone(),
}));
lock_config.memories = Some(resolved_config_to_toml::<MemoriesToml>(
&config.memories,
"memories",
)?);
let agents = lock_config.agents.get_or_insert_with(Default::default);
// Multi-agent v2 owns thread fanout through its feature config. Preserve
// the legacy agents.max_threads setting only when v2 is disabled.
agents.max_threads = if config.features.enabled(Feature::MultiAgentV2) {
None
} else {
config.agent_max_threads
};
agents.max_depth = Some(config.agent_max_depth);
agents.job_max_runtime_seconds = config.agent_job_max_runtime_seconds;
agents.interrupt_message = Some(config.agent_interrupt_message_enabled);
lock_config
.skills
.get_or_insert_with(Default::default)
.include_instructions = Some(config.include_skill_instructions);
Ok(())
}
fn drop_lockfile_inputs(lock_config: &mut ConfigToml) {
// The lockfile should contain replayable values, not the profile,
// debug-control, file-include, and environment-specific inputs that
@@ -122,11 +198,19 @@ fn drop_lockfile_inputs(lock_config: &mut ConfigToml) {
lock_config.experimental_use_freeform_apply_patch = None;
}
fn resolved_config_to_toml<Toml>(
value: &impl serde::Serialize,
label: &'static str,
) -> anyhow::Result<Toml>
where
Toml: serde::de::DeserializeOwned + serde::Serialize,
{
toml_round_trip(value, label).map_err(anyhow::Error::from)
}
#[cfg(test)]
mod tests {
use super::*;
use codex_features::FeatureToml;
use codex_features::MultiAgentV2ConfigToml;
use pretty_assertions::assert_eq;
use std::sync::Arc;
@@ -199,7 +283,7 @@ mod tests {
sc.base_instructions = "catalog instructions".to_string();
sc.developer_instructions = Some("catalog developer instructions".to_string());
sc.compact_prompt = Some("catalog compact prompt".to_string());
sc.service_tier = Some(codex_protocol::config_types::ServiceTier::Flex);
sc.service_tier = Some("flex".to_string());
let lockfile = sc.to_config_lockfile_toml().expect("lock should serialize");
let lock = &lockfile.config;
-90
View File
@@ -24,8 +24,6 @@ use crate::tasks::CompactTask;
use crate::tasks::UserShellCommandMode;
use crate::tasks::UserShellCommandTask;
use crate::tasks::execute_user_shell_command;
use codex_mcp::collect_mcp_snapshot_from_manager;
use codex_mcp::compute_auth_statuses;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::protocol::CodexErrorInfo;
@@ -460,53 +458,6 @@ pub async fn dynamic_tool_response(sess: &Arc<Session>, id: String, response: Dy
sess.notify_dynamic_tool_response(&id, response).await;
}
pub async fn add_to_history(sess: &Arc<Session>, config: &Arc<Config>, text: String) {
let id = sess.conversation_id;
let config = Arc::clone(config);
tokio::spawn(async move {
if let Err(e) = crate::message_history::append_entry(&text, &id, &config).await {
warn!("failed to append to message history: {e}");
}
});
}
pub async fn get_history_entry_request(
sess: &Arc<Session>,
config: &Arc<Config>,
sub_id: String,
offset: usize,
log_id: u64,
) {
let config = Arc::clone(config);
let sess_clone = Arc::clone(sess);
tokio::spawn(async move {
// Run lookup in blocking thread because it does file IO + locking.
let entry_opt = tokio::task::spawn_blocking(move || {
crate::message_history::lookup(log_id, offset, &config)
})
.await
.unwrap_or(None);
let event = Event {
id: sub_id,
msg: EventMsg::GetHistoryEntryResponse(
codex_protocol::protocol::GetHistoryEntryResponseEvent {
offset,
log_id,
entry: entry_opt.map(|e| codex_protocol::message_history::HistoryEntry {
conversation_id: e.session_id,
ts: e.ts,
text: e.text,
}),
},
),
};
sess_clone.send_event_raw(event).await;
});
}
pub async fn refresh_mcp_servers(sess: &Arc<Session>, refresh_config: McpServerRefreshConfig) {
let mut guard = sess.pending_mcp_server_refresh_config.lock().await;
*guard = Some(refresh_config);
@@ -516,35 +467,6 @@ pub async fn reload_user_config(sess: &Arc<Session>) {
sess.reload_user_config_layer().await;
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP tool listing reads through the session-owned manager guard"
)]
pub async fn list_mcp_tools(sess: &Session, config: &Arc<Config>, sub_id: String) {
let mcp_connection_manager = sess.services.mcp_connection_manager.read().await;
let auth = sess.services.auth_manager.auth().await;
let mcp_servers = sess
.services
.mcp_manager
.effective_servers(config, auth.as_ref())
.await;
let snapshot = collect_mcp_snapshot_from_manager(
&mcp_connection_manager,
compute_auth_statuses(
mcp_servers.iter(),
config.mcp_oauth_credentials_store_mode,
auth.as_ref(),
)
.await,
)
.await;
let event = Event {
id: sub_id,
msg: EventMsg::McpListToolsResponse(snapshot),
};
sess.send_event_raw(event).await;
}
pub async fn compact(sess: &Arc<Session>, sub_id: String) {
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
@@ -910,18 +832,6 @@ pub(super) async fn submission_loop(
dynamic_tool_response(&sess, id, response).await;
false
}
Op::AddToHistory { text } => {
add_to_history(&sess, &config, text).await;
false
}
Op::GetHistoryEntryRequest { offset, log_id } => {
get_history_entry_request(&sess, &config, sub.id.clone(), offset, log_id).await;
false
}
Op::ListMcpTools => {
list_mcp_tools(&sess, &config, sub.id.clone()).await;
false
}
Op::RefreshMcpServers { config } => {
refresh_mcp_servers(&sess, config).await;
false
+17
View File
@@ -11,6 +11,20 @@ impl Session {
request_id: RequestId,
params: McpServerElicitationRequestParams,
) -> Option<ElicitationResponse> {
if self
.services
.mcp_connection_manager
.read()
.await
.elicitations_auto_deny()
{
return Some(ElicitationResponse {
action: codex_rmcp_client::ElicitationAction::Accept,
content: Some(serde_json::json!({})),
meta: None,
});
}
let server_name = params.server_name.clone();
let request = match params.request {
McpServerElicitationRequest::Form {
@@ -219,6 +233,8 @@ impl Session {
.tool_plugin_provenance(config.as_ref())
.await;
let mcp_servers = with_codex_apps_mcp(mcp_servers, auth.as_ref(), &mcp_config);
let host_owned_codex_apps_enabled =
host_owned_codex_apps_enabled(&mcp_config, auth.as_ref());
let auth_statuses =
compute_auth_statuses(mcp_servers.iter(), store_mode, auth.as_ref()).await;
let mcp_runtime_environment = match turn_context.environments.primary() {
@@ -250,6 +266,7 @@ impl Session {
mcp_runtime_environment,
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth.as_ref()),
host_owned_codex_apps_enabled,
tool_plugin_provenance,
auth.as_ref(),
)
+8 -5
View File
@@ -32,7 +32,6 @@ use crate::context::PersonalitySpecInstructions;
use crate::default_skill_metadata_budget;
use crate::environment_selection::ResolvedTurnEnvironments;
use crate::exec_policy::ExecPolicyManager;
use crate::installation_id::resolve_installation_id;
use crate::parse_turn_item;
use crate::path_utils::normalize_for_native_workdir;
use crate::realtime_conversation::RealtimeConversationManager;
@@ -307,6 +306,7 @@ use crate::windows_sandbox::WindowsSandboxLevelExt;
use codex_core_plugins::PluginsManager;
use codex_git_utils::get_git_repo_root;
use codex_mcp::compute_auth_statuses;
use codex_mcp::host_owned_codex_apps_enabled;
use codex_mcp::with_codex_apps_mcp;
use codex_otel::SessionTelemetry;
use codex_otel::THREAD_STARTED_METRIC;
@@ -383,6 +383,7 @@ pub struct CodexSpawnOk {
pub(crate) struct CodexSpawnArgs {
pub(crate) config: Config,
pub(crate) installation_id: String,
pub(crate) auth_manager: Arc<AuthManager>,
pub(crate) models_manager: SharedModelsManager,
pub(crate) environment_manager: Arc<EnvironmentManager>,
@@ -446,6 +447,7 @@ impl Codex {
async fn spawn_internal(args: CodexSpawnArgs) -> CodexResult<CodexSpawnOk> {
let CodexSpawnArgs {
mut config,
installation_id,
auth_manager,
models_manager,
environment_manager,
@@ -586,7 +588,7 @@ impl Codex {
.auth_cached()
.and_then(|auth| auth.account_plan_type());
let service_tier = get_service_tier(
config.service_tier,
config.service_tier.clone(),
config.notices.fast_default_opt_out.unwrap_or(false),
account_plan_type,
config.features.enabled(Feature::FastMode),
@@ -629,6 +631,7 @@ impl Codex {
let session = Session::new(
session_configuration,
config.clone(),
installation_id,
auth_manager.clone(),
models_manager.clone(),
exec_policy,
@@ -784,18 +787,18 @@ impl Codex {
}
fn get_service_tier(
configured_service_tier: Option<ServiceTier>,
configured_service_tier: Option<String>,
fast_default_opt_out: bool,
account_plan_type: Option<AccountPlanType>,
fast_mode_enabled: bool,
) -> Option<ServiceTier> {
) -> Option<String> {
if configured_service_tier.is_some() || fast_default_opt_out || !fast_mode_enabled {
return configured_service_tier;
}
account_plan_type
.is_some_and(is_enterprise_default_service_tier_plan)
.then_some(ServiceTier::Fast)
.then_some(ServiceTier::Fast.request_value().to_string())
}
fn is_enterprise_default_service_tier_plan(plan_type: AccountPlanType) -> bool {
+2 -1
View File
@@ -102,7 +102,8 @@ pub(super) async fn spawn_review_thread(
let per_turn_config = Arc::new(per_turn_config);
let review_turn_id = sub_id.to_string();
let turn_metadata_state = Arc::new(TurnMetadataState::new(
sess.conversation_id.to_string(),
sess.session_id().to_string(),
sess.thread_id().to_string(),
parent_turn_context.thread_source,
review_turn_id.clone(),
parent_turn_context.cwd.clone(),
+54 -39
View File
@@ -1,5 +1,7 @@
use super::*;
use crate::goals::GoalRuntimeState;
use codex_protocol::SessionId;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::ThreadSource;
@@ -11,6 +13,7 @@ use tokio::sync::Semaphore;
/// A session has at most 1 running task at a time, and can be interrupted by user input.
pub(crate) struct Session {
pub(crate) conversation_id: ThreadId,
pub(crate) installation_id: String,
pub(super) tx_event: Sender<Event>,
pub(super) agent_status: watch::Sender<AgentStatus>,
pub(super) out_of_band_elicitation_paused: watch::Sender<bool>,
@@ -40,7 +43,7 @@ pub(crate) struct SessionConfiguration {
pub(super) collaboration_mode: CollaborationMode,
pub(super) model_reasoning_summary: Option<ReasoningSummaryConfig>,
pub(super) service_tier: Option<ServiceTier>,
pub(super) service_tier: Option<String>,
/// Developer instructions that supplement the base instructions.
pub(super) developer_instructions: Option<String>,
@@ -134,7 +137,7 @@ impl SessionConfiguration {
ThreadConfigSnapshot {
model: self.collaboration_mode.model().to_string(),
model_provider_id: self.original_config_do_not_use.model_provider_id.clone(),
service_tier: self.service_tier,
service_tier: self.service_tier.clone(),
approval_policy: self.approval_policy.value(),
approvals_reviewer: self.approvals_reviewer,
permission_profile: self.permission_profile(),
@@ -180,8 +183,15 @@ impl SessionConfiguration {
if let Some(summary) = updates.reasoning_summary {
next_configuration.model_reasoning_summary = Some(summary);
}
if let Some(service_tier) = updates.service_tier {
next_configuration.service_tier = service_tier;
if let Some(service_tier) = updates.service_tier.clone() {
// TODO(aibrahim): Remove once v2 clients no longer send the legacy
// "fast" service tier value.
next_configuration.service_tier = service_tier.map(|service_tier| {
ServiceTier::from_request_value(&service_tier)
.map_or(service_tier, |service_tier| {
service_tier.request_value().to_string()
})
});
}
if let Some(personality) = updates.personality {
next_configuration.personality = Some(personality);
@@ -307,7 +317,7 @@ pub(crate) struct SessionSettingsUpdate {
pub(crate) windows_sandbox_level: Option<WindowsSandboxLevel>,
pub(crate) collaboration_mode: Option<CollaborationMode>,
pub(crate) reasoning_summary: Option<ReasoningSummaryConfig>,
pub(crate) service_tier: Option<Option<ServiceTier>>,
pub(crate) service_tier: Option<Option<String>>,
pub(crate) final_output_json_schema: Option<Option<Value>>,
/// Turn-local environment override. `None` inherits the sticky thread
/// environments stored on `SessionConfiguration`; `Some([])` explicitly
@@ -324,6 +334,16 @@ pub(crate) struct AppServerClientMetadata {
}
impl Session {
/// Returns the concrete identity for this thread.
pub(crate) fn thread_id(&self) -> ThreadId {
self.conversation_id
}
/// Returns the identity shared by the root thread and all descendant threads.
pub(crate) fn session_id(&self) -> SessionId {
self.services.agent_control.session_id()
}
#[instrument(name = "session_init", level = "info", skip_all)]
#[allow(clippy::too_many_arguments)]
#[expect(
@@ -333,6 +353,7 @@ impl Session {
pub(crate) async fn new(
mut session_configuration: SessionConfiguration,
config: Arc<Config>,
installation_id: String,
auth_manager: Arc<AuthManager>,
models_manager: SharedModelsManager,
exec_policy: Arc<ExecPolicyManager>,
@@ -363,7 +384,7 @@ impl Session {
} else {
ThreadEventPersistenceMode::Limited
};
let conversation_id = match &initial_history {
let thread_id = match &initial_history {
InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => {
ThreadId::default()
}
@@ -394,7 +415,7 @@ impl Session {
LiveThread::create(
Arc::clone(&thread_store),
CreateThreadParams {
thread_id: conversation_id,
thread_id,
forked_from_id,
source: session_source,
thread_source: session_configuration.thread_source,
@@ -449,19 +470,6 @@ impl Session {
));
let state_db_ctx = if config.ephemeral { None } else { state_db };
let is_subagent = session_configuration.session_source.is_non_root_agent();
let history_meta_fut = async {
if is_subagent {
(0, 0)
} else {
crate::message_history::history_metadata(&config).await
}
}
.instrument(info_span!(
"session_init.history_metadata",
otel.name = "session_init.history_metadata",
session_init.is_subagent = is_subagent,
));
let auth_manager_clone = Arc::clone(&auth_manager);
let config_for_mcp = Arc::clone(&config);
let mcp_manager_for_mcp = Arc::clone(&mcp_manager);
@@ -484,11 +492,8 @@ impl Session {
));
// Join all independent futures.
let (
thread_persistence_result,
(history_log_id, history_entry_count),
(auth, mcp_servers, auth_statuses),
) = tokio::join!(thread_persistence_fut, history_meta_fut, auth_and_mcp_fut);
let (thread_persistence_result, (auth, mcp_servers, auth_statuses)) =
tokio::join!(thread_persistence_fut, auth_and_mcp_fut);
let mut live_thread_init =
LiveThreadInitGuard::new(thread_persistence_result.map_err(|e| {
@@ -508,7 +513,7 @@ impl Session {
let trace_task_name =
(!trace_agent_path.is_root()).then(|| trace_agent_path.name().to_string());
let trace_metadata = ThreadStartedTraceMetadata {
thread_id: conversation_id.to_string(),
thread_id: thread_id.to_string(),
agent_path: trace_agent_path.to_string(),
task_name: trace_task_name,
nickname: session_configuration.session_source.get_nickname(),
@@ -599,7 +604,7 @@ impl Session {
auth_manager.codex_api_key_env_enabled(),
);
let mut session_telemetry = SessionTelemetry::new(
conversation_id,
thread_id,
session_model.as_str(),
session_model.as_str(),
account_id.clone(),
@@ -615,7 +620,7 @@ impl Session {
session_telemetry = session_telemetry.with_metrics_service_name(service_name);
}
let network_proxy_audit_metadata = NetworkProxyAuditMetadata {
conversation_id: Some(conversation_id.to_string()),
conversation_id: Some(thread_id.to_string()),
app_version: Some(env!("CARGO_PKG_VERSION").to_string()),
user_account_id: account_id,
auth_mode: auth_mode.map(|mode| mode.to_string()),
@@ -685,7 +690,7 @@ impl Session {
} else {
ShellSnapshot::start_snapshotting(
config.codex_home.clone(),
conversation_id,
thread_id,
session_configuration.cwd.clone(),
&mut default_shell,
session_telemetry.clone(),
@@ -698,7 +703,7 @@ impl Session {
tx
};
let thread_name =
thread_title_from_state_db(state_db_ctx.as_ref(), &config.codex_home, conversation_id)
thread_title_from_state_db(state_db_ctx.as_ref(), &config.codex_home, thread_id)
.instrument(info_span!(
"session_init.thread_name_lookup",
otel.name = "session_init.thread_name_lookup",
@@ -706,7 +711,7 @@ impl Session {
.await;
session_configuration.thread_name = thread_name.clone();
validate_config_lock_if_configured(&session_configuration).await?;
export_config_lock_if_configured(&session_configuration, conversation_id).await?;
export_config_lock_if_configured(&session_configuration, thread_id).await?;
let state = SessionState::new(session_configuration.clone());
let managed_network_requirements_configured = config
.config_layer_stack
@@ -778,7 +783,6 @@ impl Session {
});
}
let installation_id = resolve_installation_id(&config.codex_home).await?;
let analytics_events_client = analytics_events_client.unwrap_or_else(|| {
AnalyticsEventsClient::new(
Arc::clone(&auth_manager),
@@ -786,6 +790,12 @@ impl Session {
config.analytics_enabled,
)
});
let session_id = if session_configuration.session_source.is_non_root_agent() {
agent_control.session_id()
} else {
SessionId::from(thread_id)
};
let agent_control = agent_control.with_session_id(session_id);
let services = SessionServices {
// Initialize the MCP connection manager with an uninitialized
// instance. It will be replaced with one created via
@@ -830,8 +840,9 @@ impl Session {
thread_store: Arc::clone(&thread_store),
model_client: ModelClient::new(
Some(Arc::clone(&auth_manager)),
conversation_id,
installation_id,
session_id,
thread_id,
installation_id.clone(),
session_configuration.provider.clone(),
session_configuration.session_source.clone(),
config.model_verbosity,
@@ -850,7 +861,8 @@ impl Session {
let (mailbox, mailbox_rx) = Mailbox::new();
let sess = Arc::new(Session {
conversation_id,
conversation_id: thread_id,
installation_id,
tx_event: tx_event.clone(),
agent_status,
out_of_band_elicitation_paused,
@@ -878,21 +890,20 @@ impl Session {
let events = std::iter::once(Event {
id: INITIAL_SUBMIT_ID.to_owned(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: conversation_id,
session_id,
thread_id,
forked_from_id,
thread_source: session_configuration.thread_source,
thread_name: session_configuration.thread_name.clone(),
model: session_configuration.collaboration_mode.model().to_string(),
model_provider_id: config.model_provider_id.clone(),
service_tier: session_configuration.service_tier,
service_tier: session_configuration.service_tier.clone(),
approval_policy: session_configuration.approval_policy.value(),
approvals_reviewer: session_configuration.approvals_reviewer,
permission_profile: session_configuration.permission_profile(),
active_permission_profile: session_configuration.active_permission_profile(),
cwd: session_configuration.cwd.clone(),
reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(),
history_log_id,
history_entry_count,
initial_messages,
network_proxy: session_network_proxy.filter(|_| {
Self::managed_network_proxy_active_for_permission_profile(
@@ -918,6 +929,9 @@ impl Session {
let enabled_mcp_server_count = mcp_servers.values().filter(|server| server.enabled).count();
let required_mcp_server_count = required_mcp_servers.len();
let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config.as_ref()).await;
let host_owned_codex_apps_enabled = config
.features
.apps_enabled_for_auth(auth.as_ref().is_some_and(|auth| auth.uses_codex_backend()));
{
let mut cancel_guard = sess.services.mcp_startup_cancellation_token.lock().await;
cancel_guard.cancel();
@@ -959,6 +973,7 @@ impl Session {
mcp_runtime_environment,
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth),
host_owned_codex_apps_enabled,
tool_plugin_provenance,
auth,
)
+260 -18
View File
@@ -30,6 +30,7 @@ use codex_models_manager::model_info;
use codex_models_manager::test_support::construct_model_info_offline_for_tests;
use codex_models_manager::test_support::get_model_offline_for_tests;
use codex_protocol::AgentPath;
use codex_protocol::SessionId;
use codex_protocol::ThreadId;
use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::config_types::ServiceTier;
@@ -74,6 +75,7 @@ use crate::tools::registry::ToolHandler;
use crate::tools::router::ToolCallSource;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::McpElicitationSchema;
use codex_config::config_toml::ConfigToml;
use codex_config::config_toml::ProjectConfig;
use codex_execpolicy::Decision;
@@ -124,6 +126,7 @@ use codex_protocol::protocol::UserMessageEvent;
use codex_protocol::protocol::W3cTraceContext;
use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_rmcp_client::ElicitationAction;
use core_test_support::PathBufExt;
use core_test_support::PathExt;
use core_test_support::context_snapshot;
@@ -293,6 +296,49 @@ async fn regular_turn_emits_turn_started_without_waiting_for_startup_prewarm() {
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
}
#[tokio::test]
async fn request_mcp_server_elicitation_auto_accepts_when_auto_deny_is_enabled() {
let (session, turn_context, rx) = make_session_and_context_with_rx().await;
session
.services
.mcp_connection_manager
.read()
.await
.set_elicitations_auto_deny(/*auto_deny*/ true);
let requested_schema: McpElicitationSchema = serde_json::from_value(json!({
"type": "object",
"properties": {},
}))
.expect("schema should deserialize");
let response = session
.request_mcp_server_elicitation(
turn_context.as_ref(),
RequestId::String("request-1".into()),
McpServerElicitationRequestParams {
thread_id: session.conversation_id.to_string(),
turn_id: Some(turn_context.sub_id.clone()),
server_name: "codex_apps".to_string(),
request: McpServerElicitationRequest::Form {
meta: None,
message: "Allow this request?".to_string(),
requested_schema,
},
},
)
.await;
assert_eq!(
response,
Some(ElicitationResponse {
action: ElicitationAction::Accept,
content: Some(json!({})),
meta: None,
})
);
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;
@@ -348,10 +394,12 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted
}
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,
ThreadId::try_from("00000000-0000-4000-8000-000000000001")
.expect("test thread id should be valid"),
thread_id.into(),
thread_id,
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
ModelProviderInfo::create_openai_provider(/* base_url */ /*base_url*/ None),
codex_protocol::protocol::SessionSource::Exec,
@@ -2843,7 +2891,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() {
Some(AccountPlanType::Enterprise),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast)
Some(ServiceTier::Fast.request_value().to_string())
);
assert_eq!(
get_service_tier(
@@ -2852,7 +2900,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() {
Some(AccountPlanType::EnterpriseCbpUsageBased),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast)
Some(ServiceTier::Fast.request_value().to_string())
);
assert_eq!(
get_service_tier(
@@ -2861,7 +2909,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() {
Some(AccountPlanType::Business),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast)
Some(ServiceTier::Fast.request_value().to_string())
);
assert_eq!(
get_service_tier(
@@ -2870,7 +2918,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() {
Some(AccountPlanType::Team),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast)
Some(ServiceTier::Fast.request_value().to_string())
);
assert_eq!(
get_service_tier(
@@ -2879,7 +2927,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() {
Some(AccountPlanType::SelfServeBusinessUsageBased),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast)
Some(ServiceTier::Fast.request_value().to_string())
);
}
@@ -2932,6 +2980,23 @@ async fn session_settings_null_service_tier_update_clears_service_tier() {
assert_eq!(updated.service_tier, None);
}
#[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 {
service_tier: Some(Some("fast".to_string())),
..Default::default()
})
.expect("legacy fast service tier update should apply");
assert_eq!(
updated.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;
@@ -3518,6 +3583,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
let result = Session::new(
session_configuration,
Arc::clone(&config),
"11111111-1111-4111-8111-111111111111".to_string(),
auth_manager,
models_manager,
Arc::new(ExecPolicyManager::default()),
@@ -3560,7 +3626,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
let codex_home = tempfile::tempdir().expect("create temp dir");
let config = build_test_config(codex_home.path()).await;
let config = Arc::new(config);
let conversation_id = ThreadId::default();
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(),
@@ -3626,7 +3692,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
&per_turn_config.to_models_manager_config(),
);
let session_telemetry = session_telemetry(
conversation_id,
thread_id,
config.as_ref(),
&model_info,
session_configuration.session_source.clone(),
@@ -3698,7 +3764,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
)),
model_client: ModelClient::new(
Some(auth_manager.clone()),
conversation_id,
thread_id.into(),
thread_id,
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
session_configuration.provider.clone(),
session_configuration.session_source.clone(),
@@ -3727,7 +3794,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
);
let turn_environments = turn_environments_for_tests(&environment, &session_configuration.cwd);
let turn_context = Session::make_turn_context(
conversation_id,
thread_id,
SessionId::from(thread_id),
Some(Arc::clone(&auth_manager)),
&session_telemetry,
session_configuration.provider.clone(),
@@ -3748,7 +3816,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
let (mailbox, mailbox_rx) = crate::agent::Mailbox::new();
let session = Session {
conversation_id,
conversation_id: thread_id,
installation_id: "11111111-1111-4111-8111-111111111111".to_string(),
tx_event,
agent_status: agent_status_tx,
out_of_band_elicitation_paused: watch::channel(false).0,
@@ -3855,6 +3924,7 @@ async fn make_session_with_config_and_rx(
let session = Session::new(
session_configuration,
Arc::clone(&config),
"11111111-1111-4111-8111-111111111111".to_string(),
auth_manager,
models_manager,
Arc::new(ExecPolicyManager::default()),
@@ -3886,6 +3956,175 @@ async fn make_session_with_config_and_rx(
Ok((session, rx_event, codex_home))
}
async fn make_session_with_history_source_and_agent_control_and_rx(
initial_history: InitialHistory,
session_source: SessionSource,
agent_control: AgentControl,
) -> anyhow::Result<(Arc<Session>, async_channel::Receiver<Event>)> {
let codex_home = tempfile::tempdir().expect("create temp dir");
let mut config = build_test_config(codex_home.path()).await;
config.ephemeral = true;
let config = Arc::new(config);
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
let models_manager = models_manager_with_provider(
config.codex_home.to_path_buf(),
auth_manager.clone(),
config.model_provider.clone(),
);
let model = get_model_offline_for_tests(config.model.as_deref());
let model_info =
construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config());
let collaboration_mode = CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model,
reasoning_effort: config.model_reasoning_effort,
developer_instructions: None,
},
};
let default_environments = vec![TurnEnvironmentSelection {
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
cwd: config.cwd.clone(),
}];
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
collaboration_mode,
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
user_instructions: config.user_instructions.clone(),
service_tier: None,
personality: config.personality,
base_instructions: config
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
approvals_reviewer: config.approvals_reviewer,
permission_profile: config.permissions.permission_profile.clone(),
active_permission_profile: config.permissions.active_permission_profile(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
thread_name: None,
environments: default_environments,
original_config_do_not_use: Arc::clone(&config),
metrics_service_name: None,
app_server_client_name: None,
app_server_client_version: None,
session_source: session_source.clone(),
thread_source: None,
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
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(PluginsManager::new(config.codex_home.to_path_buf()));
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
let skills_manager = Arc::new(SkillsManager::new(
config.codex_home.clone(),
/*bundled_skills_enabled*/ true,
));
let session = Session::new(
session_configuration,
Arc::clone(&config),
"11111111-1111-4111-8111-111111111111".to_string(),
auth_manager,
models_manager,
Arc::new(ExecPolicyManager::default()),
tx_event,
agent_status_tx,
initial_history,
session_source,
skills_manager,
plugins_manager,
mcp_manager,
Arc::new(SkillsWatcher::noop()),
agent_control,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
/*state_db*/ None,
Arc::new(codex_thread_store::LocalThreadStore::new(
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.model_provider_id.clone(),
)
.await
.expect("state db should initialize"),
)),
codex_rollout_trace::ThreadTraceContext::disabled(),
)
.await?;
Ok((session, rx_event))
}
#[tokio::test]
async fn resumed_root_session_uses_thread_id_as_session_id() {
let thread_id = ThreadId::new();
let (session, rx_event) = make_session_with_history_source_and_agent_control_and_rx(
InitialHistory::Resumed(ResumedHistory {
conversation_id: thread_id,
history: Vec::new(),
rollout_path: None,
}),
SessionSource::Exec,
AgentControl::default(),
)
.await
.expect("resume should succeed");
assert_eq!(session.thread_id(), thread_id);
assert_eq!(session.session_id(), SessionId::from(thread_id));
let event = rx_event.recv().await.expect("session configured event");
let EventMsg::SessionConfigured(event) = event.msg else {
panic!("expected session configured event");
};
assert_eq!(event.session_id, SessionId::from(thread_id));
assert_eq!(event.thread_id, thread_id);
}
#[tokio::test]
async fn resumed_subagent_session_keeps_inherited_session_id() {
let parent_thread_id = ThreadId::new();
let parent_session_id = SessionId::from(parent_thread_id);
let thread_id = ThreadId::new();
let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
});
let (session, rx_event) = make_session_with_history_source_and_agent_control_and_rx(
InitialHistory::Resumed(ResumedHistory {
conversation_id: thread_id,
history: Vec::new(),
rollout_path: None,
}),
session_source,
AgentControl::default().with_session_id(parent_session_id),
)
.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 notify_request_permissions_response_ignores_unmatched_call_id() {
let (session, _turn_context) = make_session_and_context().await;
@@ -5072,7 +5311,7 @@ where
let mut config = build_test_config(codex_home.as_path()).await;
configure_config(&mut config);
let config = Arc::new(config);
let conversation_id = ThreadId::default();
let thread_id = ThreadId::default();
let auth_manager = AuthManager::from_auth_for_testing(auth);
let models_manager = models_manager_with_provider(
config.codex_home.to_path_buf(),
@@ -5138,7 +5377,7 @@ where
&per_turn_config.to_models_manager_config(),
);
let session_telemetry = session_telemetry(
conversation_id,
thread_id,
config.as_ref(),
&model_info,
session_configuration.session_source.clone(),
@@ -5211,7 +5450,8 @@ where
)),
model_client: ModelClient::new(
Some(Arc::clone(&auth_manager)),
conversation_id,
thread_id.into(),
thread_id,
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
session_configuration.provider.clone(),
session_configuration.session_source.clone(),
@@ -5240,7 +5480,8 @@ where
);
let turn_environments = turn_environments_for_tests(&environment, &session_configuration.cwd);
let turn_context = Arc::new(Session::make_turn_context(
conversation_id,
thread_id,
SessionId::from(thread_id),
Some(Arc::clone(&auth_manager)),
&session_telemetry,
session_configuration.provider.clone(),
@@ -5261,7 +5502,8 @@ where
let (mailbox, mailbox_rx) = crate::agent::Mailbox::new();
let session = Arc::new(Session {
conversation_id,
conversation_id: thread_id,
installation_id: "11111111-1111-4111-8111-111111111111".to_string(),
tx_event,
agent_status: agent_status_tx,
out_of_band_elicitation_paused: watch::channel(false).0,
@@ -7722,7 +7964,7 @@ async fn completed_goal_accounts_current_turn_tokens_before_tool_response() -> a
)
.await?;
let persisted_goal = state_db
.get_thread_goal(test.session_configured.session_id)
.get_thread_goal(test.session_configured.thread_id)
.await?
.expect("goal should be persisted");
assert_eq!(
@@ -741,6 +741,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs {
config,
installation_id: "11111111-1111-4111-8111-111111111111".to_string(),
auth_manager,
models_manager,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
+19 -5
View File
@@ -73,6 +73,7 @@ use codex_hooks::HookEventAfterAgent;
use codex_hooks::HookPayload;
use codex_hooks::HookResult;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::items::PlanItem;
@@ -693,7 +694,11 @@ async fn track_turn_resolved_config_analytics(
permission_profile_cwd: turn_context.cwd.to_path_buf(),
reasoning_effort: turn_context.reasoning_effort,
reasoning_summary: Some(turn_context.reasoning_summary),
service_tier: turn_context.config.service_tier,
service_tier: turn_context
.config
.service_tier
.as_deref()
.and_then(ServiceTier::from_request_value),
approval_policy: turn_context.approval_policy.value(),
approvals_reviewer: turn_context.config.approvals_reviewer,
sandbox_network_access: turn_context.network_sandbox_policy().is_enabled(),
@@ -1504,8 +1509,6 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
| EventMsg::DeprecationNotice(_)
| EventMsg::StreamError(_)
| EventMsg::TurnDiff(_)
| EventMsg::GetHistoryEntryResponse(_)
| EventMsg::McpListToolsResponse(_)
| EventMsg::RealtimeConversationListVoicesResponse(_)
| EventMsg::SkillsUpdateAvailable
| EventMsg::PlanUpdate(_)
@@ -1858,7 +1861,7 @@ async fn try_run_sampling_request(
&turn_context.session_telemetry,
turn_context.reasoning_effort,
turn_context.reasoning_summary,
turn_context.config.service_tier,
turn_context.config.service_tier.clone(),
turn_metadata_header,
&inference_trace,
)
@@ -1880,6 +1883,7 @@ async fn try_run_sampling_request(
let mut assistant_message_stream_parsers = AssistantMessageStreamParsers::new(plan_mode);
let mut plan_mode_state = plan_mode.then(|| PlanModeStreamState::new(&turn_context.sub_id));
let receiving_span = trace_span!("receiving_stream");
let mut completed_response_id: Option<String> = None;
let outcome: CodexResult<SamplingRequestResult> = loop {
let handle_responses = trace_span!(
parent: &receiving_span,
@@ -2107,7 +2111,7 @@ async fn try_run_sampling_request(
sess.services.models_manager.refresh_if_new_etag(etag).await;
}
ResponseEvent::Completed {
response_id: _,
response_id,
token_usage,
end_turn,
} => {
@@ -2124,6 +2128,7 @@ async fn try_run_sampling_request(
if let Some(false) = end_turn {
needs_follow_up = true;
}
completed_response_id = Some(response_id);
break Ok(SamplingRequestResult {
needs_follow_up,
last_agent_message,
@@ -2235,6 +2240,15 @@ async fn try_run_sampling_request(
)
.await;
if sess
.features
.enabled(Feature::ResponsesWebsocketResponseProcessed)
&& outcome.is_ok()
&& let Some(response_id) = completed_response_id.as_deref()
{
client_session.send_response_processed(response_id).await;
}
drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?;
if cancellation_token.is_cancelled() {
+8 -4
View File
@@ -4,6 +4,7 @@ use crate::config::GhostSnapshotConfig;
use crate::environment_selection::ResolvedTurnEnvironments;
use codex_model_provider::SharedModelProvider;
use codex_model_provider::create_model_provider;
use codex_protocol::SessionId;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TurnEnvironmentSelection;
@@ -414,7 +415,7 @@ impl Session {
per_turn_config.model_reasoning_effort =
session_configuration.collaboration_mode.reasoning_effort();
per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary;
per_turn_config.service_tier = session_configuration.service_tier;
per_turn_config.service_tier = session_configuration.service_tier.clone();
per_turn_config.personality = session_configuration.personality;
per_turn_config.approvals_reviewer = session_configuration.approvals_reviewer;
per_turn_config.permissions.permission_profile =
@@ -440,7 +441,8 @@ impl Session {
#[allow(clippy::too_many_arguments)]
pub(crate) fn make_turn_context(
conversation_id: ThreadId,
thread_id: ThreadId,
session_id: SessionId,
auth_manager: Option<Arc<AuthManager>>,
session_telemetry: &SessionTelemetry,
provider: ModelProviderInfo,
@@ -522,7 +524,8 @@ impl Session {
let per_turn_config = Arc::new(per_turn_config);
let turn_metadata_state = Arc::new(TurnMetadataState::new(
conversation_id.to_string(),
session_id.to_string(),
thread_id.to_string(),
session_configuration.thread_source,
sub_id.clone(),
cwd.clone(),
@@ -718,7 +721,8 @@ impl Session {
);
let goal_tools_supported = !per_turn_config.ephemeral && self.state_db().is_some();
let mut turn_context: TurnContext = Self::make_turn_context(
self.conversation_id,
self.thread_id(),
self.session_id(),
Some(Arc::clone(&self.services.auth_manager)),
&self.services.session_telemetry,
session_configuration.provider.clone(),
+1 -1
View File
@@ -232,7 +232,7 @@ async fn schedule_startup_prewarm_inner(
&startup_turn_context.session_telemetry,
startup_turn_context.reasoning_effort,
startup_turn_context.reasoning_summary,
startup_turn_context.config.service_tier,
startup_turn_context.config.service_tier.clone(),
startup_turn_metadata_header.as_deref(),
)
.await?;
+26 -26
View File
@@ -7,6 +7,7 @@ use crate::environment_selection::default_thread_environment_selections;
use crate::environment_selection::resolve_environment_selections;
use crate::file_watcher::FileWatcher;
use crate::mcp::McpManager;
use crate::resolve_installation_id;
use crate::rollout::RolloutRecorder;
use crate::rollout::truncation;
use crate::session::Codex;
@@ -42,7 +43,6 @@ use codex_protocol::openai_models::ModelPreset;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::McpServerRefreshConfig;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionConfiguredEvent;
@@ -254,6 +254,7 @@ pub(crate) struct ThreadManagerState {
state_db: StateDbHandle,
agent_graph_store: Arc<dyn AgentGraphStore>,
session_source: SessionSource,
installation_id: String,
analytics_events_client: Option<AnalyticsEventsClient>,
// Captures submitted ops for testing purpose when test mode is enabled.
ops_log: Option<SharedCapturedOps>,
@@ -317,6 +318,7 @@ impl ThreadManager {
state_db: StateDbHandle,
thread_store: Arc<dyn ThreadStore>,
agent_graph_store: Arc<dyn AgentGraphStore>,
installation_id: String,
) -> Self {
let codex_home = config.codex_home.clone();
let restriction_product = session_source.restriction_product();
@@ -347,6 +349,7 @@ impl ThreadManager {
agent_graph_store,
auth_manager,
session_source,
installation_id,
analytics_events_client,
ops_log: should_use_test_thread_manager_behavior()
.then(|| Arc::new(std::sync::Mutex::new(Vec::new()))),
@@ -374,12 +377,21 @@ impl ThreadManager {
OPENAI_PROVIDER_ID.to_string(),
)
.await;
let skills_codex_home = match AbsolutePathBuf::from_absolute_path_checked(&codex_home) {
Ok(codex_home) => codex_home,
Err(err) => panic!("test codex_home should be absolute: {err}"),
};
let installation_id = resolve_installation_id(&skills_codex_home)
.await
.unwrap_or_else(|err| panic!("resolve test installation id failed: {err}"));
let mut manager = Self::with_models_provider_and_home_and_state_db_for_tests(
auth,
provider,
codex_home.clone(),
Arc::new(EnvironmentManager::default_for_tests()),
state_db,
skills_codex_home,
installation_id,
);
manager._test_codex_home_guard = Some(TempCodexHomeGuard { path: codex_home });
manager
@@ -399,12 +411,21 @@ impl ThreadManager {
OPENAI_PROVIDER_ID.to_string(),
)
.await;
let skills_codex_home = match AbsolutePathBuf::from_absolute_path_checked(&codex_home) {
Ok(codex_home) => codex_home,
Err(err) => panic!("test codex_home should be absolute: {err}"),
};
let installation_id = resolve_installation_id(&skills_codex_home)
.await
.unwrap_or_else(|err| panic!("resolve test installation id failed: {err}"));
Self::with_models_provider_and_home_and_state_db_for_tests(
auth,
provider,
codex_home,
environment_manager,
state_db,
skills_codex_home,
installation_id,
)
}
@@ -414,13 +435,11 @@ impl ThreadManager {
codex_home: PathBuf,
environment_manager: Arc<EnvironmentManager>,
state_db: StateDbHandle,
skills_codex_home: AbsolutePathBuf,
installation_id: String,
) -> Self {
set_thread_manager_test_mode_for_tests(/*enabled*/ true);
let auth_manager = AuthManager::from_auth_for_testing(auth);
let skills_codex_home = match AbsolutePathBuf::from_absolute_path_checked(&codex_home) {
Ok(codex_home) => codex_home,
Err(err) => panic!("test codex_home should be absolute: {err}"),
};
let (thread_created_tx, _) = broadcast::channel(THREAD_CREATED_CHANNEL_CAPACITY);
let restriction_product = SessionSource::Exec.restriction_product();
let plugins_manager = Arc::new(PluginsManager::new_with_restriction_product(
@@ -460,6 +479,7 @@ impl ThreadManager {
agent_graph_store,
auth_manager,
session_source: SessionSource::Exec,
installation_id,
analytics_events_client: None,
ops_log: should_use_test_thread_manager_behavior()
.then(|| Arc::new(std::sync::Mutex::new(Vec::new()))),
@@ -526,27 +546,6 @@ impl ThreadManager {
self.state.list_thread_ids().await
}
pub async fn refresh_mcp_servers(&self, refresh_config: McpServerRefreshConfig) {
let threads = self
.state
.threads
.read()
.await
.values()
.cloned()
.collect::<Vec<_>>();
for thread in threads {
if let Err(err) = thread
.submit(Op::RefreshMcpServers {
config: refresh_config.clone(),
})
.await
{
warn!("failed to request MCP server refresh: {err}");
}
}
}
pub fn subscribe_thread_created(&self) -> broadcast::Receiver<ThreadId> {
self.state.thread_created_tx.subscribe()
}
@@ -1221,6 +1220,7 @@ impl ThreadManagerState {
codex, thread_id, ..
} = Codex::spawn(CodexSpawnArgs {
config,
installation_id: self.installation_id.clone(),
auth_manager,
models_manager: Arc::clone(&self.models_manager),
environment_manager: Arc::clone(&self.environment_manager),
+52
View File
@@ -1,5 +1,6 @@
use super::*;
use crate::config::test_config;
use crate::installation_id::INSTALLATION_ID_FILENAME;
use crate::rollout::RolloutRecorder;
use crate::session::session::SessionSettingsUpdate;
use crate::session::tests::make_session_and_context;
@@ -26,6 +27,8 @@ use std::time::Duration;
use tempfile::tempdir;
use wiremock::MockServer;
const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111";
fn user_msg(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
@@ -415,6 +418,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
state_db,
thread_store,
agent_graph_store,
TEST_INSTALLATION_ID.to_string(),
);
let selected_cwd =
AbsolutePathBuf::try_from(config.cwd.as_path().join("selected")).expect("absolute path");
@@ -509,6 +513,46 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
);
}
#[tokio::test]
async fn explicit_installation_id_skips_codex_home_file() {
let temp_dir = tempdir().expect("tempdir");
let mut config = test_config().await;
config.codex_home = temp_dir.path().join("codex-home").abs();
config.cwd = config.codex_home.abs();
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let installation_id = uuid::Uuid::new_v4().to_string();
let (state_db, thread_store, agent_graph_store) = state_backed_stores(&config).await;
let manager = ThreadManager::new(
&config,
auth_manager,
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
state_db,
thread_store,
agent_graph_store,
installation_id.clone(),
);
let thread = manager
.start_thread(config.clone())
.await
.expect("start thread with explicit installation id");
assert!(!config.codex_home.join(INSTALLATION_ID_FILENAME).exists());
assert_eq!(thread.thread.codex.session.installation_id, installation_id);
thread
.thread
.shutdown_and_wait()
.await
.expect("shutdown thread");
let _ = manager.remove_thread(&thread.thread_id).await;
}
#[tokio::test]
async fn resume_active_thread_from_rollout_returns_running_thread() {
let temp_dir = tempdir().expect("tempdir");
@@ -529,6 +573,7 @@ async fn resume_active_thread_from_rollout_returns_running_thread() {
state_db,
thread_store,
agent_graph_store,
TEST_INSTALLATION_ID.to_string(),
);
let source = manager
@@ -585,6 +630,7 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() {
state_db,
thread_store,
agent_graph_store,
TEST_INSTALLATION_ID.to_string(),
);
let source = manager
@@ -646,6 +692,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() {
state_db,
thread_store,
agent_graph_store,
TEST_INSTALLATION_ID.to_string(),
);
let source = manager
@@ -731,6 +778,7 @@ async fn new_uses_active_provider_for_model_refresh() {
state_db,
thread_store,
agent_graph_store,
TEST_INSTALLATION_ID.to_string(),
);
let _ = manager.list_models(RefreshStrategy::Online).await;
@@ -945,6 +993,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor
state_db,
thread_store,
agent_graph_store,
TEST_INSTALLATION_ID.to_string(),
);
let source = manager
@@ -1051,6 +1100,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
state_db,
thread_store,
agent_graph_store,
TEST_INSTALLATION_ID.to_string(),
);
let source = manager
@@ -1146,6 +1196,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_
state_db,
thread_store,
agent_graph_store,
TEST_INSTALLATION_ID.to_string(),
);
let source = manager
@@ -1287,6 +1338,7 @@ async fn resumed_thread_keeps_paused_goal_paused() -> anyhow::Result<()> {
state_db,
thread_store,
agent_graph_store,
TEST_INSTALLATION_ID.to_string(),
);
let source = manager
@@ -3171,6 +3171,7 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr
state_db.clone(),
thread_store_from_config(&config, state_db.clone()),
agent_graph_store_from_state_db(state_db.clone()),
"11111111-1111-4111-8111-111111111111".to_string(),
);
let parent = manager
+45
View File
@@ -89,6 +89,35 @@ fn shell_command_payload_command(payload: &ToolPayload) -> Option<String> {
.map(|params| params.command)
}
// Hooks expose legacy function shell tools as joined command strings, while
// their function payload stores argv. Split on the way back in to invert the
// hook-facing representation.
fn rewrite_shell_function_updated_hook_input(
mut invocation: ToolInvocation,
updated_input: JsonValue,
tool_name: &str,
) -> Result<ToolInvocation, FunctionCallError> {
let ToolPayload::Function { arguments } = invocation.payload else {
return Err(FunctionCallError::RespondToModel(format!(
"hook input rewrite received unsupported {tool_name} payload"
)));
};
let command = shlex::split(updated_hook_command(&updated_input)?).ok_or_else(|| {
FunctionCallError::RespondToModel(
"hook returned shell input with an invalid command string".to_string(),
)
})?;
invocation.payload = ToolPayload::Function {
arguments: rewrite_function_arguments(&arguments, tool_name, |arguments| {
arguments.insert(
"command".to_string(),
JsonValue::Array(command.into_iter().map(JsonValue::String).collect()),
);
})?,
};
Ok(invocation)
}
struct RunExecLikeArgs {
tool_name: String,
exec_params: ExecParams,
@@ -222,6 +251,14 @@ impl ToolHandler for ShellHandler {
shell_function_pre_tool_use_payload(invocation)
}
fn with_updated_hook_input(
&self,
invocation: ToolInvocation,
updated_input: JsonValue,
) -> Result<ToolInvocation, FunctionCallError> {
rewrite_shell_function_updated_hook_input(invocation, updated_input, "shell")
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
@@ -300,6 +337,14 @@ impl ToolHandler for ContainerExecHandler {
shell_function_pre_tool_use_payload(invocation)
}
fn with_updated_hook_input(
&self,
invocation: ToolInvocation,
updated_input: JsonValue,
) -> Result<ToolInvocation, FunctionCallError> {
rewrite_shell_function_updated_hook_input(invocation, updated_input, "container.exec")
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
@@ -2,6 +2,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use codex_protocol::models::ShellCommandToolCallParams;
use codex_protocol::models::ShellToolCallParams;
use core_test_support::PathBufExt;
use core_test_support::test_path_buf;
use pretty_assertions::assert_eq;
@@ -16,8 +17,10 @@ use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolCallSource;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::ContainerExecHandler;
use crate::tools::handlers::LocalShellHandler;
use crate::tools::handlers::ShellCommandHandler;
use crate::tools::handlers::ShellHandler;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::ToolHandler;
use crate::turn_diff_tracker::TurnDiffTracker;
@@ -269,6 +272,106 @@ async fn shell_command_pre_tool_use_payload_uses_raw_command() {
);
}
#[tokio::test]
async fn shell_handler_rewrites_hook_command_back_to_argv() {
let payload = ToolPayload::Function {
arguments: json!({
"command": ["bash", "-lc", "printf old"],
"workdir": "subdir",
})
.to_string(),
};
let (session, turn) = make_session_and_context().await;
let handler = ShellHandler;
let invocation = handler
.with_updated_hook_input(
ToolInvocation {
session: session.into(),
turn: turn.into(),
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
call_id: "call-43".to_string(),
tool_name: codex_tools::ToolName::plain("shell"),
source: ToolCallSource::Direct,
payload,
},
json!({ "command": "bash -lc 'printf new'" }),
)
.expect("shell rewrite should succeed");
let ToolPayload::Function { arguments } = invocation.payload else {
panic!("shell rewrite should preserve a function payload");
};
assert_eq!(
serde_json::from_str::<ShellToolCallParams>(&arguments)
.expect("rewritten shell arguments should parse"),
ShellToolCallParams {
command: vec![
"bash".to_string(),
"-lc".to_string(),
"printf new".to_string(),
],
workdir: Some("subdir".to_string()),
timeout_ms: None,
sandbox_permissions: None,
additional_permissions: None,
prefix_rule: None,
justification: None,
}
);
}
#[tokio::test]
async fn container_exec_handler_rewrites_hook_command_back_to_argv() {
let payload = ToolPayload::Function {
arguments: json!({
"command": ["bash", "-lc", "printf old"],
"workdir": "subdir",
})
.to_string(),
};
let (session, turn) = make_session_and_context().await;
let handler = ContainerExecHandler;
let invocation = handler
.with_updated_hook_input(
ToolInvocation {
session: session.into(),
turn: turn.into(),
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
call_id: "call-44".to_string(),
tool_name: codex_tools::ToolName::plain("container.exec"),
source: ToolCallSource::Direct,
payload,
},
json!({ "command": "bash -lc 'printf new'" }),
)
.expect("container.exec rewrite should succeed");
let ToolPayload::Function { arguments } = invocation.payload else {
panic!("container.exec rewrite should preserve a function payload");
};
assert_eq!(
serde_json::from_str::<ShellToolCallParams>(&arguments)
.expect("rewritten container.exec arguments should parse"),
ShellToolCallParams {
command: vec![
"bash".to_string(),
"-lc".to_string(),
"printf new".to_string(),
],
workdir: Some("subdir".to_string()),
timeout_ms: None,
sandbox_permissions: None,
additional_permissions: None,
prefix_rule: None,
justification: None,
}
);
}
#[tokio::test]
async fn build_post_tool_use_payload_uses_tool_output_wire_value() {
let payload = ToolPayload::Function {
+3 -2
View File
@@ -311,8 +311,6 @@ impl ToolRegistry {
let display_name = tool_name.display();
let call_id_owned = invocation.call_id.clone();
let otel = invocation.turn.session_telemetry.clone();
let payload_for_response = invocation.payload.clone();
let log_payload = payload_for_response.log_payload();
let metric_tags = [
(
"sandbox",
@@ -360,6 +358,7 @@ impl ToolRegistry {
Some(handler) => handler,
None => {
let message = unsupported_tool_call_message(&invocation.payload, &tool_name);
let log_payload = invocation.payload.log_payload();
otel.tool_result_with_tags(
&display_name,
&call_id_owned,
@@ -379,6 +378,7 @@ impl ToolRegistry {
if !handler.matches_kind(&invocation.payload) {
let message = format!("tool {display_name} invoked with incompatible payload");
let log_payload = invocation.payload.log_payload();
otel.tool_result_with_tags(
&display_name,
&call_id_owned,
@@ -426,6 +426,7 @@ impl ToolRegistry {
let invocation_for_tool = invocation.clone();
let started = Instant::now();
let log_payload = invocation.payload.log_payload();
let result = otel
.log_tool_result_with_tags(
&display_name,
+9
View File
@@ -69,6 +69,8 @@ pub(crate) struct TurnMetadataBag {
#[serde(default, skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
thread_source: Option<ThreadSource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
turn_id: Option<String>,
@@ -115,6 +117,7 @@ fn merge_turn_metadata(
fn build_turn_metadata_bag(
session_id: Option<String>,
thread_id: Option<String>,
thread_source: Option<ThreadSource>,
turn_id: Option<String>,
sandbox: Option<String>,
@@ -130,6 +133,7 @@ fn build_turn_metadata_bag(
TurnMetadataBag {
session_id,
thread_id,
thread_source,
turn_id,
workspaces,
@@ -159,6 +163,7 @@ pub async fn build_turn_metadata_header(
build_turn_metadata_bag(
/*session_id*/ None,
/*thread_id*/ None,
/*thread_source*/ None,
/*turn_id*/ None,
sandbox.map(ToString::to_string),
@@ -185,8 +190,10 @@ pub(crate) struct TurnMetadataState {
}
impl TurnMetadataState {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
session_id: String,
thread_id: String,
thread_source: Option<ThreadSource>,
turn_id: String,
cwd: AbsolutePathBuf,
@@ -205,6 +212,7 @@ impl TurnMetadataState {
);
let base_metadata = build_turn_metadata_bag(
Some(session_id),
Some(thread_id),
thread_source,
Some(turn_id),
sandbox,
@@ -320,6 +328,7 @@ impl TurnMetadataState {
let enriched_metadata = build_turn_metadata_bag(
state.base_metadata.session_id.clone(),
state.base_metadata.thread_id.clone(),
state.base_metadata.thread_source,
state.base_metadata.turn_id.clone(),
state.base_metadata.sandbox.clone(),
+10
View File
@@ -94,6 +94,7 @@ fn turn_metadata_state_uses_platform_sandbox_tag() {
let state = TurnMetadataState::new(
"session-a".to_string(),
"thread-a".to_string(),
Some(ThreadSource::User),
"turn-a".to_string(),
cwd,
@@ -106,11 +107,13 @@ fn turn_metadata_state_uses_platform_sandbox_tag() {
let json: Value = serde_json::from_str(&header).expect("json");
let sandbox_name = json.get("sandbox").and_then(Value::as_str);
let session_id = json.get("session_id").and_then(Value::as_str);
let thread_id = json.get("thread_id").and_then(Value::as_str);
let thread_source = json.get("thread_source").and_then(Value::as_str);
let expected_sandbox = sandbox_tag(&sandbox_policy, WindowsSandboxLevel::Disabled);
assert_eq!(sandbox_name, Some(expected_sandbox));
assert_eq!(session_id, Some("session-a"));
assert_eq!(thread_id, Some("thread-a"));
assert_eq!(thread_source, Some("user"));
assert!(json.get("session_source").is_none());
}
@@ -122,6 +125,7 @@ fn turn_metadata_state_uses_explicit_subagent_thread_source() {
let permission_profile = PermissionProfile::read_only();
let state = TurnMetadataState::new(
"session-a".to_string(),
"thread-a".to_string(),
Some(ThreadSource::Subagent),
"turn-a".to_string(),
cwd,
@@ -145,6 +149,7 @@ fn turn_metadata_state_includes_turn_started_at_unix_ms_after_start() {
let state = TurnMetadataState::new(
"session-a".to_string(),
"thread-a".to_string(),
Some(ThreadSource::User),
"turn-a".to_string(),
cwd,
@@ -171,6 +176,7 @@ fn turn_metadata_state_includes_model_and_reasoning_effort_only_in_request_meta(
let state = TurnMetadataState::new(
"session-a".to_string(),
"thread-a".to_string(),
/*thread_source*/ None,
"turn-a".to_string(),
cwd,
@@ -215,6 +221,7 @@ fn turn_metadata_state_ignores_client_turn_started_at_unix_ms_before_start() {
let state = TurnMetadataState::new(
"session-a".to_string(),
"thread-a".to_string(),
Some(ThreadSource::User),
"turn-a".to_string(),
cwd,
@@ -241,6 +248,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
let state = TurnMetadataState::new(
"session-a".to_string(),
"thread-a".to_string(),
Some(ThreadSource::User),
"turn-a".to_string(),
cwd,
@@ -257,6 +265,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
"client-supplied".to_string(),
),
("session_id".to_string(), "client-supplied".to_string()),
("thread_id".to_string(), "client-supplied".to_string()),
("thread_source".to_string(), "client-supplied".to_string()),
(
"turn_started_at_unix_ms".to_string(),
@@ -275,6 +284,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
assert_eq!(json["model"].as_str(), Some("client-supplied"));
assert_eq!(json["reasoning_effort"].as_str(), Some("client-supplied"));
assert_eq!(json["session_id"].as_str(), Some("session-a"));
assert_eq!(json["thread_id"].as_str(), Some("thread-a"));
assert_eq!(json["thread_source"].as_str(), Some("user"));
assert_eq!(json["turn_id"].as_str(), Some("turn-a"));
assert_eq!(
+3
View File
@@ -7,4 +7,7 @@ codex_rust_crate(
lib_data_extra = [
"//codex-rs/core:model_availability_nux_fixtures",
],
deps_extra = [
"@crates//:similar",
],
)
+1
View File
@@ -33,6 +33,7 @@ opentelemetry = { workspace = true }
opentelemetry_sdk = { workspace = true }
regex-lite = { workspace = true }
serde_json = { workspace = true }
similar = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["net", "time"] }
tokio-tungstenite = { workspace = true }
@@ -1,5 +1,7 @@
use regex_lite::Regex;
use serde_json::Value;
use similar::ChangeTag;
use similar::TextDiff;
use std::sync::OnceLock;
use crate::responses::ResponsesRequest;
@@ -242,6 +244,102 @@ pub fn format_labeled_items_snapshot(
format!("Scenario: {scenario}\n\n{sections}")
}
/// Render changed JSON lines between two captured `/responses` request bodies.
///
/// Request-parity tests use this to compare the entire JSON payload while showing only fields that
/// changed, with the same redactions as the other context snapshots.
pub fn format_request_body_diff_snapshot(
scenario: &str,
before_title: &str,
before_request: &ResponsesRequest,
after_title: &str,
after_request: &ResponsesRequest,
options: &ContextSnapshotOptions,
) -> String {
let before = format_request_body_snapshot(before_request, options);
let after = format_request_body_snapshot(after_request, options);
let diff = format_changed_lines_diff(before_title, &before, after_title, &after);
format!("Scenario: {scenario}\n\n{diff}")
}
fn format_request_body_snapshot(
request: &ResponsesRequest,
options: &ContextSnapshotOptions,
) -> String {
let mut body = request.body_json();
canonicalize_json_snapshot_value(&mut body, options);
serde_json::to_string_pretty(&body).expect("request body should serialize")
}
fn canonicalize_json_snapshot_value(value: &mut Value, options: &ContextSnapshotOptions) {
match value {
Value::Array(values) => {
for value in values {
canonicalize_json_snapshot_value(value, options);
}
}
Value::Object(map) => {
// Keep request-body snapshots stable when serde_json preserves insertion order.
let mut entries = std::mem::take(map).into_iter().collect::<Vec<_>>();
entries.sort_by(|(left_key, _), (right_key, _)| left_key.cmp(right_key));
for (key, mut value) in entries {
canonicalize_json_snapshot_value(&mut value, options);
map.insert(key, value);
}
}
Value::String(text) => {
*text = format_snapshot_json_string(text, options);
}
Value::Null | Value::Bool(_) | Value::Number(_) => {}
}
}
fn format_snapshot_json_string(text: &str, options: &ContextSnapshotOptions) -> String {
let normalized = match options.render_mode {
ContextSnapshotRenderMode::RedactedText
| ContextSnapshotRenderMode::KindWithTextPrefix { .. } => normalize_snapshot_uuids(
&normalize_snapshot_line_endings(&canonicalize_snapshot_text(text)),
),
ContextSnapshotRenderMode::FullText => normalize_snapshot_line_endings(text),
ContextSnapshotRenderMode::KindOnly => unreachable!(),
};
match options.render_mode {
ContextSnapshotRenderMode::KindWithTextPrefix { max_chars }
if normalized.chars().count() > max_chars =>
{
let prefix = normalized.chars().take(max_chars).collect::<String>();
format!("{prefix}...")
}
ContextSnapshotRenderMode::RedactedText
| ContextSnapshotRenderMode::FullText
| ContextSnapshotRenderMode::KindWithTextPrefix { .. } => normalized,
ContextSnapshotRenderMode::KindOnly => unreachable!(),
}
}
fn format_changed_lines_diff(
before_title: &str,
before: &str,
after_title: &str,
after: &str,
) -> String {
let mut diff = format!("--- {before_title}\n+++ {after_title}\n");
for change in TextDiff::from_lines(before, after).iter_all_changes() {
match change.tag() {
ChangeTag::Equal => {}
ChangeTag::Delete => {
diff.push('-');
diff.push_str(change.value());
}
ChangeTag::Insert => {
diff.push('+');
diff.push_str(change.value());
}
}
}
diff
}
fn format_snapshot_text(text: &str, options: &ContextSnapshotOptions) -> String {
match options.render_mode {
ContextSnapshotRenderMode::RedactedText => {
@@ -342,6 +440,17 @@ fn normalize_dynamic_snapshot_paths(text: &str) -> String {
.into_owned()
}
fn normalize_snapshot_uuids(text: &str) -> String {
static UUID_RE: OnceLock<Regex> = OnceLock::new();
let uuid_re = UUID_RE.get_or_init(|| {
Regex::new(
r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b",
)
.expect("uuid regex should compile")
});
uuid_re.replace_all(text, "<UUID>").into_owned()
}
#[cfg(test)]
mod tests {
use super::ContextSnapshotOptions;
+6 -3
View File
@@ -18,6 +18,7 @@ use codex_core::ThreadManager;
use codex_core::agent_graph_store_from_state_db;
use codex_core::config::Config;
use codex_core::init_state_db_from_config;
use codex_core::resolve_installation_id;
use codex_core::shell::Shell;
use codex_core::shell::get_shell_by_model_provided_path;
use codex_core::thread_store_from_config;
@@ -431,6 +432,7 @@ impl TestCodexBuilder {
.expect("test codex requires state db");
let thread_store = thread_store_from_config(&config, state_db.clone());
let agent_graph_store = agent_graph_store_from_state_db(state_db.clone());
let installation_id = resolve_installation_id(&config.codex_home).await?;
ThreadManager::new(
&config,
codex_core::test_support::auth_manager_from_auth(auth.clone()),
@@ -440,6 +442,7 @@ impl TestCodexBuilder {
state_db,
thread_store,
agent_graph_store,
installation_id,
)
} else {
codex_core::test_support::thread_manager_with_models_provider_and_home(
@@ -651,7 +654,7 @@ impl TestCodex {
prompt,
AskForApproval::Never,
PermissionProfile::Disabled,
Some(service_tier),
Some(service_tier.map(|service_tier| service_tier.request_value().to_string())),
/*environments*/ None,
)
.await
@@ -713,7 +716,7 @@ impl TestCodex {
prompt: &str,
approval_policy: AskForApproval,
permission_profile: PermissionProfile,
service_tier: Option<Option<ServiceTier>>,
service_tier: Option<Option<String>>,
environments: Option<Vec<TurnEnvironmentSelection>>,
) -> Result<()> {
self.submit_turn_with_context(
@@ -731,7 +734,7 @@ impl TestCodex {
prompt: &str,
approval_policy: AskForApproval,
permission_profile: PermissionProfile,
service_tier: Option<Option<ServiceTier>>,
service_tier: Option<Option<String>>,
environments: Option<Vec<TurnEnvironmentSelection>>,
) -> Result<()> {
let (sandbox_policy, permission_profile) =
+13 -10
View File
@@ -80,13 +80,13 @@ async fn responses_stream_includes_subagent_header_on_review() {
config.model = Some(model.clone());
let config = Arc::new(config);
let conversation_id = ThreadId::new();
let thread_id = ThreadId::new();
let auth_mode = TelemetryAuthMode::Chatgpt;
let session_source = SessionSource::SubAgent(SubAgentSource::Review);
let model_info =
codex_core::test_support::construct_model_info_offline(model.as_str(), &config);
let session_telemetry = SessionTelemetry::new(
conversation_id,
thread_id,
model.as_str(),
model_info.slug.as_str(),
/*account_id*/ None,
@@ -100,7 +100,8 @@ async fn responses_stream_includes_subagent_header_on_review() {
let client = ModelClient::new(
/*auth_manager*/ None,
conversation_id,
thread_id.into(),
thread_id,
/*installation_id*/ TEST_INSTALLATION_ID.to_string(),
provider.clone(),
session_source,
@@ -141,7 +142,7 @@ async fn responses_stream_includes_subagent_header_on_review() {
}
let request = request_recorder.single_request();
let expected_window_id = format!("{conversation_id}:0");
let expected_window_id = format!("{thread_id}:0");
assert_eq!(
request.header("x-openai-subagent").as_deref(),
Some("review")
@@ -205,14 +206,14 @@ async fn responses_stream_includes_subagent_header_on_other() {
config.model = Some(model.clone());
let config = Arc::new(config);
let conversation_id = ThreadId::new();
let thread_id = ThreadId::new();
let auth_mode = TelemetryAuthMode::Chatgpt;
let session_source = SessionSource::SubAgent(SubAgentSource::Other("my-task".to_string()));
let model_info =
codex_core::test_support::construct_model_info_offline(model.as_str(), &config);
let session_telemetry = SessionTelemetry::new(
conversation_id,
thread_id,
model.as_str(),
model_info.slug.as_str(),
/*account_id*/ None,
@@ -226,7 +227,8 @@ async fn responses_stream_includes_subagent_header_on_other() {
let client = ModelClient::new(
/*auth_manager*/ None,
conversation_id,
thread_id.into(),
thread_id,
/*installation_id*/ TEST_INSTALLATION_ID.to_string(),
provider.clone(),
session_source,
@@ -317,7 +319,7 @@ async fn responses_respects_model_info_overrides_from_config() {
let model = config.model.clone().expect("model configured");
let config = Arc::new(config);
let conversation_id = ThreadId::new();
let thread_id = ThreadId::new();
let auth_mode =
codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("Test API Key"))
.auth_mode()
@@ -327,7 +329,7 @@ async fn responses_respects_model_info_overrides_from_config() {
let model_info =
codex_core::test_support::construct_model_info_offline(model.as_str(), &config);
let session_telemetry = SessionTelemetry::new(
conversation_id,
thread_id,
model.as_str(),
model_info.slug.as_str(),
/*account_id*/ None,
@@ -341,7 +343,8 @@ async fn responses_respects_model_info_overrides_from_config() {
let client = ModelClient::new(
/*auth_manager*/ None,
conversation_id,
thread_id.into(),
thread_id,
/*installation_id*/ TEST_INSTALLATION_ID.to_string(),
provider.clone(),
session_source,
+1 -1
View File
@@ -313,7 +313,7 @@ async fn websocket_v2_first_turn_drops_fast_tier_after_startup_prewarm() -> Resu
.features
.enable(Feature::ResponsesWebsocketsV2)
.expect("test config should allow feature update");
config.service_tier = Some(ServiceTier::Fast);
config.service_tier = Some(ServiceTier::Fast.request_value().to_string());
});
let test = builder.build_with_websocket_server(&server).await?;
+1 -1
View File
@@ -807,7 +807,7 @@ async fn wait_for_spawned_thread(test: &TestCodex) -> Result<Arc<CodexThread>> {
let ids = test.thread_manager.list_thread_ids().await;
if let Some(thread_id) = ids
.iter()
.find(|id| **id != test.session_configured.session_id)
.find(|id| **id != test.session_configured.thread_id)
{
return test
.thread_manager
+30 -12
View File
@@ -7,6 +7,7 @@ use codex_core::ResponseEvent;
use codex_core::ThreadManager;
use codex_core::agent_graph_store_from_state_db;
use codex_core::init_state_db_from_config;
use codex_core::resolve_installation_id;
use codex_core::thread_store_from_config;
use codex_features::Feature;
use codex_login::AuthManager;
@@ -726,7 +727,7 @@ async fn resume_replays_image_tool_outputs_with_detail() {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn includes_conversation_id_and_model_headers_in_request() {
async fn includes_session_id_thread_id_and_model_headers_in_request() {
skip_if_no_network!();
// Mock server
@@ -744,7 +745,8 @@ async fn includes_conversation_id_and_model_headers_in_request() {
.await
.expect("create new conversation");
let codex = test.codex.clone();
let session_id = test.session_configured.session_id;
let expected_session_id = test.session_configured.session_id;
let expected_thread_id = test.session_configured.thread_id;
codex
.submit(Op::UserInput {
@@ -764,6 +766,7 @@ async fn includes_conversation_id_and_model_headers_in_request() {
let request = resp_mock.single_request();
assert_eq!(request.path(), "/v1/responses");
let request_session_id = request.header("session_id").expect("session_id header");
let request_thread_id = request.header("thread_id").expect("thread_id header");
let request_authorization = request
.header("authorization")
.expect("authorization header");
@@ -772,10 +775,16 @@ async fn includes_conversation_id_and_model_headers_in_request() {
let installation_id =
std::fs::read_to_string(test.codex_home_path().join(INSTALLATION_ID_FILENAME))
.expect("read installation id");
let thread_id_string = expected_thread_id.to_string();
assert_eq!(request_session_id, session_id.to_string());
assert_eq!(request_session_id, expected_session_id.to_string());
assert_eq!(request_thread_id, thread_id_string.as_str());
assert_eq!(request_originator, originator().value);
assert_eq!(request_authorization, "Bearer Test API Key");
assert_eq!(
request_body["prompt_cache_key"].as_str(),
Some(thread_id_string.as_str())
);
assert_eq!(
request_body["client_metadata"]["x-codex-installation-id"].as_str(),
Some(installation_id.as_str())
@@ -867,9 +876,9 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth
let config = Arc::new(config);
let model_info =
codex_core::test_support::construct_model_info_offline(model.as_str(), &config);
let conversation_id = ThreadId::new();
let thread_id = ThreadId::new();
let session_telemetry = SessionTelemetry::new(
conversation_id,
thread_id,
model.as_str(),
model_info.slug.as_str(),
/*account_id*/ None,
@@ -884,7 +893,8 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth
Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key(
"unused-api-key",
))),
conversation_id,
thread_id.into(),
thread_id,
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
provider,
SessionSource::Exec,
@@ -1000,7 +1010,8 @@ async fn chatgpt_auth_sends_correct_request() {
.await
.expect("create new conversation");
let codex = test.codex.clone();
let thread_id = test.session_configured.session_id;
let expected_session_id = test.session_configured.session_id;
let expected_thread_id = test.session_configured.thread_id;
codex
.submit(Op::UserInput {
@@ -1028,11 +1039,13 @@ async fn chatgpt_auth_sends_correct_request() {
.expect("chatgpt-account-id header");
let request_body = request.body_json();
let session_id = request.header("session_id").expect("session_id header");
let request_session_id = request.header("session_id").expect("session_id header");
let request_thread_id = request.header("thread_id").expect("thread_id header");
let installation_id =
std::fs::read_to_string(test.codex_home_path().join(INSTALLATION_ID_FILENAME))
.expect("read installation id");
assert_eq!(session_id, thread_id.to_string());
assert_eq!(request_session_id, expected_session_id.to_string());
assert_eq!(request_thread_id, expected_thread_id.to_string());
assert_eq!(request_originator, originator().value);
assert_eq!(request_authorization, "Bearer Access Token");
@@ -1108,6 +1121,9 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
.expect("client test requires state db");
let thread_store = thread_store_from_config(&config, state_db.clone());
let agent_graph_store = agent_graph_store_from_state_db(state_db.clone());
let installation_id = resolve_installation_id(&config.codex_home)
.await
.expect("resolve installation id");
let thread_manager = ThreadManager::new(
&config,
auth_manager,
@@ -1117,6 +1133,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
state_db,
thread_store,
agent_graph_store,
installation_id,
);
let NewThread { thread: codex, .. } = thread_manager
.start_thread(config.clone())
@@ -2277,11 +2294,11 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
let config = Arc::new(config);
let model_info =
codex_core::test_support::construct_model_info_offline(model.as_str(), &config);
let conversation_id = ThreadId::new();
let thread_id = ThreadId::new();
let auth_manager =
codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("Test API Key"));
let session_telemetry = SessionTelemetry::new(
conversation_id,
thread_id,
model.as_str(),
model_info.slug.as_str(),
/*account_id*/ None,
@@ -2295,7 +2312,8 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
let client = ModelClient::new(
/*auth_manager*/ None,
conversation_id,
thread_id.into(),
thread_id,
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
provider.clone(),
SessionSource::Exec,
+107 -7
View File
@@ -15,6 +15,7 @@ use codex_otel::MetricsConfig;
use codex_otel::SessionTelemetry;
use codex_otel::TelemetryAuthMode;
use codex_otel::current_span_w3c_trace_context;
use codex_protocol::SessionId;
use codex_protocol::ThreadId;
use codex_protocol::account::PlanType;
use codex_protocol::config_types::ReasoningSummary;
@@ -87,7 +88,8 @@ fn assert_request_trace_matches(body: &serde_json::Value, expected_trace: &W3cTr
struct WebsocketTestHarness {
_codex_home: TempDir,
client: ModelClient,
conversation_id: ThreadId,
session_id: SessionId,
thread_id: ThreadId,
model_info: ModelInfo,
effort: Option<ReasoningEffortConfig>,
summary: ReasoningSummary,
@@ -125,7 +127,15 @@ async fn responses_websocket_streams_request() {
);
assert_eq!(
handshake.header(X_CLIENT_REQUEST_ID_HEADER),
Some(harness.conversation_id.to_string())
Some(harness.thread_id.to_string())
);
assert_eq!(
handshake.header("session_id"),
Some(harness.session_id.to_string())
);
assert_eq!(
handshake.header("thread_id"),
Some(harness.thread_id.to_string())
);
assert_eq!(
handshake.header(USER_AGENT_HEADER),
@@ -161,6 +171,93 @@ async fn responses_websocket_streams_without_feature_flag_when_provider_supports
server.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn responses_websocket_sends_response_processed_when_feature_enabled() {
skip_if_no_network!();
let server = start_websocket_server(vec![vec![
vec![
ev_response_created("resp-prewarm"),
ev_completed("resp-prewarm"),
],
vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "hi"),
ev_completed("resp-1"),
],
vec![],
]])
.await;
let mut builder = test_codex().with_config(|config| {
config
.features
.enable(Feature::ResponsesWebsocketResponseProcessed)
.expect("test config should allow feature update");
});
let test = builder
.build_with_websocket_server(&server)
.await
.expect("build websocket codex");
test.submit_turn("hello")
.await
.expect("submission should send response.processed after processing");
let processed = server
.wait_for_request(/*connection_index*/ 0, /*request_index*/ 2)
.await;
assert_eq!(
processed.body_json(),
json!({
"type": "response.processed",
"response_id": "resp-1",
})
);
let connection = server.single_connection();
assert_eq!(connection.len(), 3);
assert_eq!(
connection[1].body_json()["type"].as_str(),
Some("response.create")
);
server.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn responses_websocket_omits_response_processed_without_feature() {
skip_if_no_network!();
let server = start_websocket_server(vec![vec![
vec![
ev_response_created("resp-prewarm"),
ev_completed("resp-prewarm"),
],
vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "hi"),
ev_completed("resp-1"),
],
vec![],
]])
.await;
let mut builder = test_codex();
let test = builder
.build_with_websocket_server(&server)
.await
.expect("build websocket codex");
test.submit_turn("hello")
.await
.expect("submission should complete without response.processed");
let connection = server.single_connection();
assert_eq!(connection.len(), 2);
server.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn responses_websocket_reuses_connection_with_per_turn_trace_payloads() {
skip_if_no_network!();
@@ -1827,7 +1924,8 @@ async fn websocket_harness_with_provider_options(
}
let config = Arc::new(config);
let model_info = codex_core::test_support::construct_model_info_offline(MODEL, &config);
let conversation_id = ThreadId::new();
let thread_id = ThreadId::new();
let session_id = SessionId::new();
let auth_manager =
codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("Test API Key"));
let exporter = InMemoryMetricExporter::default();
@@ -1837,7 +1935,7 @@ async fn websocket_harness_with_provider_options(
)
.expect("in-memory metrics client");
let session_telemetry = SessionTelemetry::new(
conversation_id,
thread_id,
MODEL,
model_info.slug.as_str(),
/*account_id*/ None,
@@ -1853,7 +1951,8 @@ async fn websocket_harness_with_provider_options(
let summary = ReasoningSummary::Auto;
let client = ModelClient::new(
/*auth_manager*/ None,
conversation_id,
session_id,
thread_id,
/*installation_id*/ TEST_INSTALLATION_ID.to_string(),
provider.clone(),
SessionSource::Exec,
@@ -1866,7 +1965,8 @@ async fn websocket_harness_with_provider_options(
WebsocketTestHarness {
_codex_home: codex_home,
client,
conversation_id,
session_id,
thread_id,
model_info,
effort,
summary,
@@ -1935,7 +2035,7 @@ async fn stream_until_complete_with_request_metadata(
&harness.session_telemetry,
harness.effort,
harness.summary,
service_tier,
service_tier.map(|service_tier| service_tier.request_value().to_string()),
turn_metadata_header,
&codex_rollout_trace::InferenceTraceContext::disabled(),
)
+289
View File
@@ -7,6 +7,7 @@ use anyhow::Result;
use codex_core::compact::SUMMARY_PREFIX;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ContentItem;
@@ -105,6 +106,23 @@ fn contains_defer_loading(value: &Value) -> bool {
}
}
fn canonical_json(value: &Value) -> Value {
match value {
Value::Object(map) => {
let mut entries = map.iter().collect::<Vec<_>>();
entries.sort_by(|(left_key, _), (right_key, _)| left_key.cmp(right_key));
Value::Object(
entries
.into_iter()
.map(|(key, value)| (key.clone(), canonical_json(value)))
.collect(),
)
}
Value::Array(values) => Value::Array(values.iter().map(canonical_json).collect()),
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => value.clone(),
}
}
const PRETURN_CONTEXT_DIFF_CWD: &str = "/tmp/PRETURN_CONTEXT_DIFF_CWD";
const DUMMY_FUNCTION_NAME: &str = "test_tool";
const REMOTE_COMPACT_TURN_COMPLETE_TIMEOUT: Duration = Duration::from_secs(30);
@@ -273,6 +291,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> {
.await?;
let codex = harness.test().codex.clone();
let session_id = harness.test().session_configured.session_id.to_string();
let thread_id = harness.test().session_configured.thread_id.to_string();
let responses_mock = responses::mount_sse_sequence(
harness.server(),
@@ -341,6 +360,10 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> {
compact_request.header("session_id").as_deref(),
Some(session_id.as_str())
);
assert_eq!(
compact_request.header("thread_id").as_deref(),
Some(thread_id.as_str())
);
let compact_body = compact_request.body_json();
assert_eq!(
compact_body.get("model").and_then(|v| v.as_str()),
@@ -412,6 +435,267 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> {
Ok(())
}
async fn assert_remote_manual_compact_request_parity(
auth: CodexAuth,
configured_service_tier: Option<ServiceTier>,
expected_service_tier: Option<&str>,
snapshot_name: &str,
scenario: &str,
) -> Result<()> {
let mut builder = test_codex().with_auth(auth);
if let Some(service_tier) = configured_service_tier {
builder = builder.with_config(move |config| {
config.service_tier = Some(service_tier.request_value().to_string());
});
}
let harness = TestCodexHarness::with_builder(builder).await?;
let codex = harness.test().codex.clone();
let image_url =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII="
.to_string();
let responses_mock = responses::mount_sse_sequence(
harness.server(),
vec![
responses::sse(vec![
responses::ev_assistant_message("turn-one-assistant", "TURN_ONE_ASSISTANT"),
responses::ev_completed("turn-one-response"),
]),
responses::sse(vec![
responses::ev_reasoning_item(
"turn-two-reasoning",
&["TURN_TWO_REASONING"],
&["turn two raw content"],
),
responses::ev_assistant_message("turn-two-assistant", "TURN_TWO_ASSISTANT"),
responses::ev_completed("turn-two-response"),
]),
responses::sse(vec![
responses::ev_function_call("turn-three-call", DUMMY_FUNCTION_NAME, "{}"),
responses::ev_completed("turn-three-call-response"),
]),
responses::sse(vec![
responses::ev_assistant_message("turn-three-assistant", "TURN_THREE_ASSISTANT"),
responses::ev_completed("turn-three-final-response"),
]),
responses::sse(vec![
responses::ev_local_shell_call(
"turn-four-local-shell",
"completed",
vec!["/bin/echo", "TURN_FOUR_LOCAL_SHELL"],
),
responses::ev_completed("turn-four-local-shell-response"),
]),
responses::sse(vec![
responses::ev_assistant_message("turn-four-assistant", "TURN_FOUR_ASSISTANT"),
responses::ev_completed("turn-four-final-response"),
]),
responses::sse(vec![
responses::ev_reasoning_item(
"turn-five-reasoning",
&["TURN_FIVE_REASONING"],
&["turn five raw content"],
),
responses::ev_assistant_message("turn-five-assistant", "TURN_FIVE_ASSISTANT"),
responses::ev_completed("turn-five-response"),
]),
],
)
.await;
let compact_mock = responses::mount_compact_user_history_with_summary_once(
harness.server(),
"REMOTE_CACHE_TIER_SUMMARY",
)
.await;
codex
.submit(Op::UserInput {
environments: None,
items: vec![UserInput::Text {
text: "TURN_ONE_USER".to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
})
.await?;
wait_for_turn_complete(&codex).await;
codex
.submit(Op::UserInput {
environments: None,
items: vec![
UserInput::Text {
text: "TURN_TWO_PREFIX".to_string(),
text_elements: Vec::new(),
},
UserInput::Text {
text: "TURN_TWO_SUFFIX".to_string(),
text_elements: Vec::new(),
},
],
final_output_json_schema: None,
responsesapi_client_metadata: None,
})
.await?;
wait_for_turn_complete(&codex).await;
codex
.submit(Op::UserInput {
environments: None,
items: vec![UserInput::Text {
text: "TURN_THREE_TOOL_USER".to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
})
.await?;
wait_for_turn_complete(&codex).await;
codex
.submit(Op::UserInput {
environments: None,
items: vec![
UserInput::Image { image_url },
UserInput::Text {
text: "TURN_FOUR_IMAGE_USER".to_string(),
text_elements: Vec::new(),
},
],
final_output_json_schema: None,
responsesapi_client_metadata: None,
})
.await?;
wait_for_turn_complete(&codex).await;
codex
.submit(Op::UserInput {
environments: None,
items: vec![UserInput::Text {
text: "TURN_FIVE_USER".to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
})
.await?;
wait_for_turn_complete(&codex).await;
codex.submit(Op::Compact).await?;
wait_for_turn_complete(&codex).await;
let response_requests = responses_mock.requests();
assert_eq!(
response_requests.len(),
7,
"expected five turns with one unsupported tool continuation and one local shell continuation"
);
assert_eq!(
compact_mock.requests().len(),
1,
"expected exactly one remote compact request"
);
let normal_request = response_requests
.last()
.cloned()
.expect("last turn request missing");
let compact_request = compact_mock.single_request();
let normal_body = normal_request.body_json();
let compact_body = compact_request.body_json();
let mut expected_compact_body_without_input = normal_body.clone();
let expected_compact_object = expected_compact_body_without_input
.as_object_mut()
.expect("responses request body should be an object");
for field in [
"input",
"client_metadata",
"include",
"store",
"stream",
"tool_choice",
] {
expected_compact_object.remove(field);
}
if expected_service_tier.is_none() {
expected_compact_object.remove("service_tier");
}
let mut compact_body_without_input = compact_body.clone();
compact_body_without_input
.as_object_mut()
.expect("compact request body should be an object")
.remove("input");
let canonical_compact_body_without_input = canonical_json(&compact_body_without_input);
let canonical_expected_compact_body_without_input =
canonical_json(&expected_compact_body_without_input);
assert_eq!(
json!({
"compact_body_without_input": canonical_compact_body_without_input,
"expected_compact_body_without_input": canonical_expected_compact_body_without_input,
"prompt_cache_key_matches_responses": compact_body["prompt_cache_key"] == normal_body["prompt_cache_key"],
"prompt_cache_key_present": compact_body["prompt_cache_key"].is_string(),
"service_tier": compact_body.get("service_tier").and_then(Value::as_str),
}),
json!({
"compact_body_without_input": canonical_expected_compact_body_without_input,
"expected_compact_body_without_input": canonical_expected_compact_body_without_input,
"prompt_cache_key_matches_responses": true,
"prompt_cache_key_present": true,
"service_tier": expected_service_tier,
}),
"compact requests should carry the same shared request fields as /responses"
);
insta::assert_snapshot!(
snapshot_name,
context_snapshot::format_request_body_diff_snapshot(
scenario,
"Last Normal /responses Request",
&normal_request,
"Remote /responses/compact Request",
&compact_request,
&ContextSnapshotOptions::default(),
)
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_manual_compact_api_auth_reuses_prompt_cache_key() -> Result<()> {
skip_if_no_network!(Ok(()));
assert_remote_manual_compact_request_parity(
CodexAuth::from_api_key("dummy"),
Some(ServiceTier::Fast),
Some("priority"),
"remote_manual_compact_api_auth_prompt_cache_key_request_diff",
"After five varied API-key-auth turns, remote manual compaction reuses the normal responses service_tier and prompt_cache_key while omitting responses-only fields.",
)
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_manual_compact_chatgpt_auth_reuses_service_tier_and_prompt_cache_key() -> Result<()>
{
skip_if_no_network!(Ok(()));
assert_remote_manual_compact_request_parity(
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
Some(ServiceTier::Fast),
Some("priority"),
"remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff",
"After five varied ChatGPT-auth turns, remote manual compaction reuses the normal responses service_tier and prompt_cache_key while omitting responses-only fields.",
)
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_compact_v2_reuses_context_compaction_for_followups() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -713,6 +997,7 @@ async fn remote_compact_runs_automatically() -> Result<()> {
.await?;
let codex = harness.test().codex.clone();
let session_id = harness.test().session_configured.session_id.to_string();
let thread_id = harness.test().session_configured.thread_id.to_string();
mount_sse_once(
harness.server(),
@@ -764,6 +1049,10 @@ async fn remote_compact_runs_automatically() -> Result<()> {
.as_deref(),
Some(session_id.as_str())
);
assert_eq!(
compact_mock.single_request().header("thread_id").as_deref(),
Some(thread_id.as_str())
);
let follow_up_request = responses_mock.single_request();
let follow_up_body = follow_up_request.body_json().to_string();
assert!(follow_up_body.contains("REMOTE_COMPACTED_SUMMARY"));
+1 -1
View File
@@ -195,7 +195,7 @@ async fn fork_thread_from_history_does_not_require_source_rollout_path() {
ForkSnapshot::Interrupted,
test.config.clone(),
InitialHistory::Resumed(ResumedHistory {
conversation_id: test.session_configured.session_id,
conversation_id: test.session_configured.thread_id,
history: source_items.clone(),
rollout_path: None,
}),
+5 -5
View File
@@ -358,7 +358,7 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> {
let call_id = "ig_image_saved_to_temp_dir_default";
let expected_saved_path = image_generation_artifact_path(
config.codex_home.as_path(),
&session_configured.session_id.to_string(),
&session_configured.thread_id.to_string(),
call_id,
);
let _ = std::fs::remove_file(&expected_saved_path);
@@ -444,7 +444,7 @@ async fn image_generation_call_event_is_emitted_when_image_save_fails() -> anyho
} = test_codex().build(&server).await?;
let expected_saved_path = image_generation_artifact_path(
config.codex_home.as_path(),
&session_configured.session_id.to_string(),
&session_configured.thread_id.to_string(),
"ig_invalid",
);
let _ = std::fs::remove_file(&expected_saved_path);
@@ -547,8 +547,8 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> {
})
.await;
let session_id = session_configured.session_id.to_string();
assert_eq!(delta_event.thread_id, session_id);
let thread_id = session_configured.thread_id.to_string();
assert_eq!(delta_event.thread_id, thread_id);
assert_eq!(delta_event.turn_id, started_turn_id);
assert_eq!(delta_event.item_id, started_item.id);
assert_eq!(delta_event.delta, "streamed response");
@@ -614,7 +614,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result<
assert_eq!(
plan_delta.thread_id,
session_configured.session_id.to_string()
session_configured.thread_id.to_string()
);
assert_eq!(plan_delta.delta, "- Step 1\n- Step 2\n");
assert_eq!(plan_completed.text, "- Step 1\n- Step 2\n");
+3 -3
View File
@@ -487,7 +487,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> {
let test = builder.build(&server).await?;
let saved_path = image_generation_artifact_path(
test.codex_home_path(),
&test.session_configured.session_id.to_string(),
&test.session_configured.thread_id.to_string(),
"ig_123",
);
let _ = std::fs::remove_file(&saved_path);
@@ -601,7 +601,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima
let test = builder.build(&server).await?;
let saved_path = image_generation_artifact_path(
test.codex_home_path(),
&test.session_configured.session_id.to_string(),
&test.session_configured.thread_id.to_string(),
"ig_123",
);
let _ = std::fs::remove_file(&saved_path);
@@ -717,7 +717,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history()
let test = builder.build(&server).await?;
let saved_path = image_generation_artifact_path(
test.codex_home_path(),
&test.session_configured.session_id.to_string(),
&test.session_configured.thread_id.to_string(),
"ig_rollback",
);
let _ = std::fs::remove_file(&saved_path);
+3 -3
View File
@@ -164,11 +164,11 @@ async fn submit_queue_only_agent_mail(codex: &CodexThread, text: &str) {
.await
.unwrap_or_else(|err| panic!("submit queue-only agent mail: {err}"));
codex
.submit(Op::ListMcpTools)
.submit(Op::RealtimeConversationListVoices)
.await
.unwrap_or_else(|err| panic!("submit list-mcp-tools barrier: {err}"));
.unwrap_or_else(|err| panic!("submit list-voices barrier: {err}"));
wait_for_event(codex, |event| {
matches!(event, EventMsg::McpListToolsResponse(_))
matches!(event, EventMsg::RealtimeConversationListVoicesResponse(_))
})
.await;
}
+1 -45
View File
@@ -74,6 +74,7 @@ fn write_plugin_mcp_plugin(home: &TempDir, command: &str) {
"mcpServers": {{
"sample": {{
"command": "{command}",
"cwd": ".",
"startup_timeout_sec": 60.0
}}
}}
@@ -98,20 +99,6 @@ fn write_plugin_app_plugin(home: &TempDir) {
.expect("write plugin app config");
}
async fn build_plugin_test_codex(
server: &MockServer,
codex_home: Arc<TempDir>,
) -> Result<Arc<codex_core::CodexThread>> {
let mut builder = test_codex()
.with_home(codex_home)
.with_auth(CodexAuth::from_api_key("Test API Key"));
Ok(builder
.build(server)
.await
.expect("create new conversation")
.codex)
}
async fn build_analytics_plugin_test_codex(
server: &MockServer,
codex_home: Arc<TempDir>,
@@ -447,34 +434,3 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn plugin_mcp_tools_are_listed() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let codex_home = Arc::new(TempDir::new()?);
let rmcp_test_server_bin = stdio_server_bin()?;
write_plugin_mcp_plugin(codex_home.as_ref(), &rmcp_test_server_bin);
let codex = build_plugin_test_codex(&server, codex_home).await?;
wait_for_sample_mcp_ready(&codex).await?;
codex.submit(Op::ListMcpTools).await?;
let list_event = wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::McpListToolsResponse(_)),
Duration::from_secs(10),
)
.await;
let EventMsg::McpListToolsResponse(tool_list) = list_event else {
unreachable!("event guard guarantees McpListToolsResponse");
};
let mut available_tools: Vec<&str> = tool_list.tools.keys().map(String::as_str).collect();
available_tools.sort_unstable();
assert!(
tool_list.tools.contains_key("mcp__sample__echo")
&& tool_list.tools.contains_key("mcp__sample__image"),
"expected plugin MCP tools to be listed; discovered tools: {available_tools:?}"
);
Ok(())
}
+41 -57
View File
@@ -242,31 +242,42 @@ fn copy_binary_to_remote_env(
Ok(remote_path)
}
async fn wait_for_mcp_tool(fixture: &TestCodex, tool_name: &str) -> anyhow::Result<()> {
let tools_ready_deadline = Instant::now() + Duration::from_secs(30);
loop {
fixture.codex.submit(Op::ListMcpTools).await?;
let list_event = wait_for_event_with_timeout(
&fixture.codex,
|ev| matches!(ev, EventMsg::McpListToolsResponse(_)),
Duration::from_secs(10),
)
.await;
let EventMsg::McpListToolsResponse(tool_list) = list_event else {
unreachable!("event guard guarantees McpListToolsResponse");
};
if tool_list.tools.contains_key(tool_name) {
return Ok(());
}
let available_tools: Vec<&str> = tool_list.tools.keys().map(String::as_str).collect();
if Instant::now() >= tools_ready_deadline {
panic!(
"timed out waiting for MCP tool {tool_name} to become available; discovered tools: {available_tools:?}"
);
}
sleep(Duration::from_millis(200)).await;
async fn wait_for_mcp_server(fixture: &TestCodex, server_name: &str) -> anyhow::Result<()> {
let startup_event = wait_for_event_with_timeout(
&fixture.codex,
|ev| match ev {
EventMsg::McpStartupComplete(summary) => {
summary.ready.iter().any(|server| server == server_name)
|| summary
.failed
.iter()
.any(|failure| failure.server == server_name)
|| summary.cancelled.iter().any(|server| server == server_name)
}
_ => false,
},
Duration::from_secs(70),
)
.await;
let EventMsg::McpStartupComplete(summary) = startup_event else {
unreachable!("event guard guarantees McpStartupComplete");
};
if let Some(failure) = summary
.failed
.iter()
.find(|failure| failure.server == server_name)
{
let error = &failure.error;
anyhow::bail!("MCP server {server_name} failed to start: {error}");
}
if summary.cancelled.iter().any(|server| server == server_name) {
anyhow::bail!("MCP server {server_name} startup was cancelled");
}
ensure!(
summary.ready.iter().any(|server| server == server_name),
"expected MCP server {server_name} to be ready; startup summary: {summary:?}"
);
Ok(())
}
#[derive(Default)]
@@ -731,7 +742,6 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()>
let call_id = "sandbox-meta-call";
let server_name = "rmcp";
let namespace = format!("mcp__{server_name}__");
let tool_name = format!("{namespace}sandbox_meta");
let call_mock = mount_sse_once(
&server,
@@ -767,30 +777,7 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()>
.build_remote_aware(&server)
.await?;
let tools_ready_deadline = Instant::now() + Duration::from_secs(30);
loop {
fixture.codex.submit(Op::ListMcpTools).await?;
let list_event = wait_for_event_with_timeout(
&fixture.codex,
|ev| matches!(ev, EventMsg::McpListToolsResponse(_)),
Duration::from_secs(10),
)
.await;
let EventMsg::McpListToolsResponse(tool_list) = list_event else {
unreachable!("event guard guarantees McpListToolsResponse");
};
if tool_list.tools.contains_key(&tool_name) {
break;
}
let available_tools: Vec<&str> = tool_list.tools.keys().map(String::as_str).collect();
if Instant::now() >= tools_ready_deadline {
panic!(
"timed out waiting for MCP tool {tool_name} to become available; discovered tools: {available_tools:?}"
);
}
sleep(Duration::from_millis(200)).await;
}
wait_for_mcp_server(&fixture, server_name).await?;
fixture
.submit_turn_with_permission_profile(
@@ -1039,7 +1026,6 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
let call_id = "img-1";
let server_name = "rmcp";
let tool_name = format!("mcp__{server_name}__image");
let namespace = format!("mcp__{server_name}__");
// First stream: model decides to call the image tool.
@@ -1086,7 +1072,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
})
.build_remote_aware(&server)
.await?;
wait_for_mcp_tool(&fixture, &tool_name).await?;
wait_for_mcp_server(&fixture, server_name).await?;
fixture
.codex
@@ -1176,7 +1162,6 @@ async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Re
let call_id = "img-original-detail-1";
let server_name = "rmcp";
let tool_name = format!("mcp__{server_name}__image_scenario");
let namespace = format!("mcp__{server_name}__");
mount_sse_once(
@@ -1219,7 +1204,7 @@ async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Re
})
.build_remote_aware(&server)
.await?;
wait_for_mcp_tool(&fixture, &tool_name).await?;
wait_for_mcp_server(&fixture, server_name).await?;
fixture
.codex
@@ -1955,7 +1940,6 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> {
let call_id = "call-789";
let server_name = "rmcp_http_oauth";
let tool_name = format!("mcp__{server_name}__echo");
let namespace = format!("mcp__{server_name}__");
mount_sse_once(
@@ -2036,9 +2020,9 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> {
})
.build_remote_aware(&server)
.await?;
// Phase 5: wait for MCP discovery to publish the expected tool before the
// turn is submitted, which keeps failures tied to server startup/discovery.
wait_for_mcp_tool(&fixture, &tool_name).await?;
// Phase 5: wait for MCP startup before the turn is submitted, which keeps
// failures tied to server startup/discovery.
wait_for_mcp_server(&fixture, server_name).await?;
// Phase 6: submit the user turn that should invoke the OAuth-backed tool.
fixture
+4
View File
@@ -598,6 +598,10 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() -
apps_tool_call.pointer("/params/_meta/x-codex-turn-metadata/session_id"),
Some(&json!(test.session_configured.session_id.to_string()))
);
assert_eq!(
apps_tool_call.pointer("/params/_meta/x-codex-turn-metadata/thread_id"),
Some(&json!(test.session_configured.thread_id.to_string()))
);
assert!(
apps_tool_call
.pointer("/params/_meta/x-codex-turn-metadata/turn_id")
@@ -0,0 +1,43 @@
---
source: core/tests/suite/compact_remote.rs
expression: "context_snapshot::format_request_body_diff_snapshot(scenario,\n\"Last Normal /responses Request\", &normal_request,\n\"Remote /responses/compact Request\", &compact_request,\n&ContextSnapshotOptions::default(),)"
---
Scenario: After five varied API-key-auth turns, remote manual compaction reuses the normal responses service_tier and prompt_cache_key while omitting responses-only fields.
--- Last Normal /responses Request
+++ Remote /responses/compact Request
- "client_metadata": {
- "x-codex-installation-id": "<UUID>"
- },
- "include": [
- "reasoning.encrypted_content"
- ],
+ },
+ {
+ "content": [
+ {
+ "text": "turn five raw content",
+ "type": "reasoning_text"
+ }
+ ],
+ "encrypted_content": "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYnR1cm4gZml2ZSByYXcgY29udGVudA==",
+ "summary": [
+ {
+ "text": "TURN_FIVE_REASONING",
+ "type": "summary_text"
+ }
+ ],
+ "type": "reasoning"
+ },
+ {
+ "content": [
+ {
+ "text": "TURN_FIVE_ASSISTANT",
+ "type": "output_text"
+ }
+ ],
+ "role": "assistant",
+ "type": "message"
- "store": false,
- "stream": true,
- "tool_choice": "auto",
@@ -0,0 +1,43 @@
---
source: core/tests/suite/compact_remote.rs
expression: "context_snapshot::format_request_body_diff_snapshot(scenario,\n\"Last Normal /responses Request\", &normal_request,\n\"Remote /responses/compact Request\", &compact_request,\n&ContextSnapshotOptions::default(),)"
---
Scenario: After five varied ChatGPT-auth turns, remote manual compaction reuses the normal responses service_tier and prompt_cache_key while omitting responses-only fields.
--- Last Normal /responses Request
+++ Remote /responses/compact Request
- "client_metadata": {
- "x-codex-installation-id": "<UUID>"
- },
- "include": [
- "reasoning.encrypted_content"
- ],
+ },
+ {
+ "content": [
+ {
+ "text": "turn five raw content",
+ "type": "reasoning_text"
+ }
+ ],
+ "encrypted_content": "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYnR1cm4gZml2ZSByYXcgY29udGVudA==",
+ "summary": [
+ {
+ "text": "TURN_FIVE_REASONING",
+ "type": "summary_text"
+ }
+ ],
+ "type": "reasoning"
+ },
+ {
+ "content": [
+ {
+ "text": "TURN_FIVE_ASSISTANT",
+ "type": "output_text"
+ }
+ ],
+ "role": "assistant",
+ "type": "message"
- "store": false,
- "stream": true,
- "tool_choice": "auto",
+5 -5
View File
@@ -48,7 +48,7 @@ async fn new_thread_is_recorded_in_state_db() -> Result<()> {
});
let test = builder.build(&server).await?;
let thread_id = test.session_configured.session_id;
let thread_id = test.session_configured.thread_id;
let rollout_path = test.codex.rollout_path().expect("rollout path");
let db_path = codex_state::state_db_path(test.config.sqlite_home.as_path());
@@ -262,7 +262,7 @@ async fn user_messages_persist_in_state_db() -> Result<()> {
test.submit_turn("another message").await?;
let db = test.codex.state_db().expect("state db enabled");
let thread_id = test.session_configured.session_id;
let thread_id = test.session_configured.thread_id;
let mut metadata = None;
for _ in 0..100 {
@@ -305,7 +305,7 @@ async fn web_search_marks_thread_memory_mode_polluted_when_configured() -> Resul
});
let test = builder.build(&server).await?;
let db = test.codex.state_db().expect("state db enabled");
let thread_id = test.session_configured.session_id;
let thread_id = test.session_configured.thread_id;
test.submit_turn("search the web").await?;
@@ -397,7 +397,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result<
});
let test = builder.build(&server).await?;
let db = test.codex.state_db().expect("state db enabled");
let thread_id = test.session_configured.session_id;
let thread_id = test.session_configured.thread_id;
let cwd = test.cwd_path().to_path_buf();
let (sandbox_policy, permission_profile) =
turn_permission_fields(PermissionProfile::read_only(), cwd.as_path());
@@ -478,7 +478,7 @@ async fn tool_call_logs_include_thread_id() -> Result<()> {
});
let test = builder.build(&server).await?;
let db = test.codex.state_db().expect("state db enabled");
let expected_thread_id = test.session_configured.session_id.to_string();
let expected_thread_id = test.session_configured.thread_id.to_string();
test.submit_turn("run a shell command").await?;
@@ -116,7 +116,7 @@ async fn wait_for_spawned_thread_id(test: &TestCodex) -> Result<String> {
let ids = test.thread_manager.list_thread_ids().await;
if let Some(spawned_id) = ids
.iter()
.find(|id| **id != test.session_configured.session_id)
.find(|id| **id != test.session_configured.thread_id)
{
return Ok(spawned_id.to_string());
}