mirror of
https://github.com/openai/codex.git
synced 2026-11-09 20:50:33 +00:00
Merge branch 'abhinav/hooks-updated-input' into abhinav/hooks-updated-input-permission-request
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -7,4 +7,7 @@ codex_rust_crate(
|
||||
lib_data_extra = [
|
||||
"//codex-rs/core:model_availability_nux_fixtures",
|
||||
],
|
||||
deps_extra = [
|
||||
"@crates//:similar",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
+43
@@ -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",
|
||||
+43
@@ -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",
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user