Merge remote-tracking branch 'origin/main' into jif/pr-27179-cleanup

# Conflicts:
#	codex-rs/tui/src/app_server_session.rs
This commit is contained in:
jif-oai
2026-06-11 14:30:57 +01:00
438 changed files with 18136 additions and 4969 deletions
+47 -25
View File
@@ -226,18 +226,6 @@
},
"type": "object"
},
"AppsMcpPathOverrideConfigToml": {
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"path": {
"type": "string"
}
},
"type": "object"
},
"AskForApproval": {
"description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.",
"oneOf": [
@@ -408,7 +396,23 @@
"type": "boolean"
},
"apps_mcp_path_override": {
"$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml"
"anyOf": [
{
"type": "boolean"
},
{
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"path": {
"type": "string"
}
},
"type": "object"
}
]
},
"auth_elicitation": {
"type": "boolean"
@@ -572,6 +576,9 @@
"request_rule": {
"type": "boolean"
},
"resize_all_images": {
"type": "boolean"
},
"responses_websockets": {
"type": "boolean"
},
@@ -617,6 +624,9 @@
"terminal_visualization_instructions": {
"type": "boolean"
},
"token_budget": {
"type": "boolean"
},
"tool_call_mcp_elicitation": {
"type": "boolean"
},
@@ -836,16 +846,6 @@
},
"type": "object"
},
"FeatureToml_for_AppsMcpPathOverrideConfigToml": {
"anyOf": [
{
"type": "boolean"
},
{
"$ref": "#/definitions/AppsMcpPathOverrideConfigToml"
}
]
},
"FeatureToml_for_CodeModeConfigToml": {
"anyOf": [
{
@@ -4534,7 +4534,23 @@
"type": "boolean"
},
"apps_mcp_path_override": {
"$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml"
"anyOf": [
{
"type": "boolean"
},
{
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"path": {
"type": "string"
}
},
"type": "object"
}
]
},
"auth_elicitation": {
"type": "boolean"
@@ -4698,6 +4714,9 @@
"request_rule": {
"type": "boolean"
},
"resize_all_images": {
"type": "boolean"
},
"responses_websockets": {
"type": "boolean"
},
@@ -4743,6 +4762,9 @@
"terminal_visualization_instructions": {
"type": "boolean"
},
"token_budget": {
"type": "boolean"
},
"tool_call_mcp_elicitation": {
"type": "boolean"
},
@@ -5224,4 +5246,4 @@
},
"title": "ConfigToml",
"type": "object"
}
}
+4 -1
View File
@@ -1097,6 +1097,7 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() {
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(replacement_history),
window_id: None,
}),
RolloutItem::TurnContext(turn_context.to_turn_context_item()),
RolloutItem::ResponseItem(spawn_agent_call(&parent_spawn_call_id)),
@@ -2490,7 +2491,7 @@ async fn list_agent_subtree_thread_ids_includes_anonymous_and_closed_descendants
}
#[tokio::test]
async fn list_agent_subtree_thread_ids_includes_live_descendants_without_state_db() {
async fn list_agent_subtree_thread_ids_finds_live_descendants_of_unloaded_root() {
let (_home, config) = test_config().await;
let manager = ThreadManager::with_models_provider_home_and_state_for_tests(
CodexAuth::from_api_key("dummy"),
@@ -2535,6 +2536,8 @@ async fn list_agent_subtree_thread_ids_includes_live_descendants_without_state_d
.await
.expect("grandchild spawn should succeed");
manager.remove_thread(&parent_thread_id).await;
let mut subtree_thread_ids = manager
.list_agent_subtree_thread_ids(parent_thread_id)
.await
+33 -37
View File
@@ -28,7 +28,6 @@ use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::sync::OnceLock;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use codex_api::ApiError;
@@ -172,7 +171,6 @@ pub(crate) struct CompactConversationRequestSettings {
struct ModelClientState {
session_id: SessionId,
thread_id: ThreadId,
window_generation: AtomicU64,
installation_id: String,
provider: SharedModelProvider,
auth_env_telemetry: AuthEnvTelemetry,
@@ -345,7 +343,6 @@ impl ModelClient {
state: Arc::new(ModelClientState {
session_id,
thread_id,
window_generation: AtomicU64::new(0),
installation_id,
provider: model_provider,
auth_env_telemetry,
@@ -394,24 +391,6 @@ impl ModelClient {
self.state.provider.auth_manager()
}
pub(crate) fn set_window_generation(&self, window_generation: u64) {
self.state
.window_generation
.store(window_generation, Ordering::Relaxed);
self.store_cached_websocket_session(WebsocketSession::default());
}
pub(crate) fn advance_window_generation(&self) {
self.state.window_generation.fetch_add(1, Ordering::Relaxed);
self.store_cached_websocket_session(WebsocketSession::default());
}
pub(crate) fn current_window_id(&self) -> String {
let thread_id = self.state.thread_id;
let window_generation = self.state.window_generation.load(Ordering::Relaxed);
format!("{thread_id}:{window_generation}")
}
fn take_cached_websocket_session(&self) -> WebsocketSession {
let mut cached_websocket_session = self
.state
@@ -457,6 +436,7 @@ impl ModelClient {
///
/// The model selection and telemetry context are passed explicitly to keep `ModelClient`
/// session-scoped.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn compact_conversation_history(
&self,
prompt: &Prompt,
@@ -464,6 +444,7 @@ impl ModelClient {
settings: CompactConversationRequestSettings,
session_telemetry: &SessionTelemetry,
compaction_trace: &CompactionTraceContext,
window_id: &str,
turn_metadata_header: Option<&str>,
) -> Result<Vec<ResponseItem>> {
if prompt.input.is_empty() {
@@ -488,6 +469,7 @@ impl ModelClient {
settings.effort,
settings.summary,
settings.service_tier,
window_id,
)?;
let ResponsesApiRequest {
model,
@@ -522,7 +504,7 @@ impl ModelClient {
/*turn_state*/ None,
parse_turn_metadata_header(turn_metadata_header).as_ref(),
));
extra_headers.extend(self.build_responses_identity_headers());
extra_headers.extend(self.build_responses_identity_headers(Some(window_id)));
extra_headers.extend(build_session_headers(
Some(self.state.session_id.to_string()),
Some(self.state.thread_id.to_string()),
@@ -644,14 +626,16 @@ impl ModelClient {
extra_headers
}
fn build_responses_identity_headers(&self) -> ApiHeaderMap {
fn build_responses_identity_headers(&self, window_id: Option<&str>) -> ApiHeaderMap {
let mut extra_headers = self.build_subagent_headers();
if let Some(parent_thread_id) = parent_thread_id_header_value(self.state.parent_thread_id)
&& let Ok(val) = HeaderValue::from_str(&parent_thread_id)
{
extra_headers.insert(X_CODEX_PARENT_THREAD_ID_HEADER, val);
}
if let Ok(val) = HeaderValue::from_str(&self.current_window_id()) {
if let Some(window_id) = window_id
&& let Ok(val) = HeaderValue::from_str(window_id)
{
extra_headers.insert(X_CODEX_WINDOW_ID_HEADER, val);
}
extra_headers
@@ -659,6 +643,7 @@ impl ModelClient {
fn build_ws_client_metadata(
&self,
window_id: &str,
turn_metadata_header: Option<&str>,
use_responses_lite: bool,
) -> HashMap<String, String> {
@@ -667,10 +652,7 @@ impl ModelClient {
X_CODEX_INSTALLATION_ID_HEADER.to_string(),
self.state.installation_id.clone(),
);
client_metadata.insert(
X_CODEX_WINDOW_ID_HEADER.to_string(),
self.current_window_id(),
);
client_metadata.insert(X_CODEX_WINDOW_ID_HEADER.to_string(), window_id.to_string());
if let Some(subagent) = subagent_header_value(&self.state.session_source) {
client_metadata.insert(X_OPENAI_SUBAGENT_HEADER.to_string(), subagent);
}
@@ -752,6 +734,7 @@ impl ModelClient {
}
}
#[allow(clippy::too_many_arguments)]
fn build_responses_request(
&self,
provider: &codex_api::Provider,
@@ -760,9 +743,10 @@ impl ModelClient {
effort: Option<ReasoningEffortConfig>,
summary: ReasoningSummaryConfig,
service_tier: Option<String>,
window_id: &str,
) -> Result<ResponsesApiRequest> {
let instructions = &prompt.base_instructions.text;
let input = prompt.get_formatted_input();
let input = prompt.get_formatted_input_for_request(model_info.use_responses_lite);
let tools = create_tools_json_for_responses_api(&prompt.tools)?;
let reasoning = Self::build_reasoning(model_info, effort, summary);
let include = if reasoning.is_some() {
@@ -807,10 +791,7 @@ impl ModelClient {
X_CODEX_INSTALLATION_ID_HEADER.to_string(),
self.state.installation_id.clone(),
),
(
X_CODEX_WINDOW_ID_HEADER.to_string(),
self.current_window_id(),
),
(X_CODEX_WINDOW_ID_HEADER.to_string(), window_id.to_string()),
])),
};
Ok(request)
@@ -955,7 +936,7 @@ impl ModelClient {
headers.insert("x-client-request-id", header_value);
}
headers.extend(build_session_headers(Some(session_id), Some(thread_id)));
headers.extend(self.build_responses_identity_headers());
headers.extend(self.build_responses_identity_headers(/*window_id*/ None));
if let Some(header_value) = self.generate_attestation_header_for().await {
headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
}
@@ -998,6 +979,7 @@ impl ModelClientSession {
/// regardless of transport choice.
async fn build_responses_options(
&self,
window_id: &str,
turn_metadata_header: Option<&str>,
compression: Compression,
use_responses_lite: bool,
@@ -1015,7 +997,10 @@ impl ModelClientSession {
Some(&self.turn_state),
turn_metadata_header.as_ref(),
);
headers.extend(self.client.build_responses_identity_headers());
headers.extend(
self.client
.build_responses_identity_headers(Some(window_id)),
);
if let Some(header_value) = self.client.generate_attestation_header_for().await {
headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
}
@@ -1115,7 +1100,6 @@ impl ModelClientSession {
pub async fn preconnect_websocket(
&mut self,
session_telemetry: &SessionTelemetry,
_model_info: &ModelInfo,
) -> std::result::Result<(), ApiError> {
if !self.client.responses_websocket_enabled() {
return Ok(());
@@ -1257,6 +1241,7 @@ impl ModelClientSession {
)]
async fn stream_responses_api(
&self,
window_id: &str,
prompt: &Prompt,
model_info: &ModelInfo,
session_telemetry: &SessionTelemetry,
@@ -1288,6 +1273,7 @@ impl ModelClientSession {
let compression = self.responses_request_compression(client_setup.auth.as_ref());
let mut options = self
.build_responses_options(
window_id,
turn_metadata_header,
compression,
model_info.use_responses_lite,
@@ -1301,6 +1287,7 @@ impl ModelClientSession {
effort.clone(),
summary,
service_tier.clone(),
window_id,
)?;
let inference_trace_attempt = inference_trace.start_attempt();
inference_trace_attempt.add_request_headers(&mut options.extra_headers);
@@ -1374,6 +1361,7 @@ impl ModelClientSession {
)]
async fn stream_responses_websocket(
&mut self,
window_id: &str,
prompt: &Prompt,
model_info: &ModelInfo,
session_telemetry: &SessionTelemetry,
@@ -1402,6 +1390,7 @@ impl ModelClientSession {
let options = self
.build_responses_options(
window_id,
turn_metadata_header,
compression,
model_info.use_responses_lite,
@@ -1414,10 +1403,12 @@ impl ModelClientSession {
effort.clone(),
summary,
service_tier.clone(),
window_id,
)?;
let mut ws_payload = ResponseCreateWsRequest {
client_metadata: response_create_client_metadata(
Some(self.client.build_ws_client_metadata(
window_id,
turn_metadata_header,
model_info.use_responses_lite,
)),
@@ -1553,6 +1544,7 @@ impl ModelClientSession {
#[allow(clippy::too_many_arguments)]
pub async fn prewarm_websocket(
&mut self,
window_id: &str,
prompt: &Prompt,
model_info: &ModelInfo,
session_telemetry: &SessionTelemetry,
@@ -1571,6 +1563,7 @@ impl ModelClientSession {
let disabled_trace = InferenceTraceContext::disabled();
match self
.stream_responses_websocket(
window_id,
prompt,
model_info,
session_telemetry,
@@ -1614,6 +1607,7 @@ impl ModelClientSession {
/// branches.
pub async fn stream(
&mut self,
window_id: &str,
prompt: &Prompt,
model_info: &ModelInfo,
session_telemetry: &SessionTelemetry,
@@ -1630,6 +1624,7 @@ impl ModelClientSession {
let request_trace = current_span_w3c_trace_context();
match self
.stream_responses_websocket(
window_id,
prompt,
model_info,
session_telemetry,
@@ -1651,6 +1646,7 @@ impl ModelClientSession {
}
self.stream_responses_api(
window_id,
prompt,
model_info,
session_telemetry,
@@ -1989,7 +1985,7 @@ impl AuthRequestTelemetryContext {
let auth_telemetry = auth_header_telemetry(api_auth);
Self {
auth_mode: auth_mode.map(|mode| match mode {
AuthMode::ApiKey => "ApiKey",
AuthMode::ApiKey | AuthMode::BedrockApiKey => "ApiKey",
AuthMode::Chatgpt
| AuthMode::ChatgptAuthTokens
| AuthMode::AgentIdentity
+52
View File
@@ -2,6 +2,8 @@ pub use codex_api::ResponseEvent;
use codex_config::types::Personality;
use codex_protocol::error::Result;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::InterAgentCommunication;
use codex_tools::ToolSpec;
@@ -71,6 +73,56 @@ impl Prompt {
})
.collect()
}
pub(crate) fn get_formatted_input_for_request(
&self,
use_responses_lite: bool,
) -> Vec<ResponseItem> {
let mut input = self.get_formatted_input();
if use_responses_lite {
strip_image_details(&mut input);
}
input
}
}
fn strip_image_details(items: &mut [ResponseItem]) {
for item in items {
match item {
ResponseItem::Message { content, .. } => {
for content_item in content {
if let ContentItem::InputImage { detail, .. } = content_item {
*detail = None;
}
}
}
ResponseItem::FunctionCallOutput { output, .. }
| ResponseItem::CustomToolCallOutput { output, .. } => {
if let Some(content) = output.content_items_mut() {
for content_item in content {
if let FunctionCallOutputContentItem::InputImage { detail, .. } =
content_item
{
*detail = None;
}
}
}
}
ResponseItem::Reasoning { .. }
| ResponseItem::AgentMessage { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::CompactionTrigger
| ResponseItem::ContextCompaction { .. }
| ResponseItem::Other => {}
}
}
}
pub struct ResponseStream {
+85
View File
@@ -3,10 +3,95 @@ use codex_api::ResponsesApiRequest;
use codex_api::TextControls;
use codex_api::create_text_param_for_request;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ImageDetail;
use pretty_assertions::assert_eq;
use super::*;
fn prompt_with_image_outputs() -> Prompt {
Prompt {
input: vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputImage {
image_url: "https://example.com/image.png".to_string(),
detail: Some(ImageDetail::Original),
}],
phase: None,
},
ResponseItem::FunctionCallOutput {
call_id: "function-call".to_string(),
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,function".to_string(),
detail: Some(ImageDetail::High),
},
]),
},
ResponseItem::CustomToolCallOutput {
call_id: "custom-call".to_string(),
name: None,
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,custom".to_string(),
detail: Some(ImageDetail::Auto),
},
]),
},
],
..Default::default()
}
}
#[test]
fn responses_lite_request_copies_strip_image_details() {
let prompt = prompt_with_image_outputs();
let original = prompt.input.clone();
let stripped = prompt.get_formatted_input_for_request(/*use_responses_lite*/ true);
assert_eq!(
stripped,
vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputImage {
image_url: "https://example.com/image.png".to_string(),
detail: None,
}],
phase: None,
},
ResponseItem::FunctionCallOutput {
call_id: "function-call".to_string(),
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,function".to_string(),
detail: None,
},
]),
},
ResponseItem::CustomToolCallOutput {
call_id: "custom-call".to_string(),
name: None,
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,custom".to_string(),
detail: None,
},
]),
},
]
);
assert_eq!(prompt.input, original);
assert_eq!(
prompt.get_formatted_input_for_request(/*use_responses_lite*/ false),
original
);
}
#[test]
fn serializes_text_verbosity_when_set() {
let input: Vec<ResponseItem> = vec![];
+3 -3
View File
@@ -291,13 +291,13 @@ fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() {
Some(parent_thread_id),
);
client.advance_window_generation();
let thread_id = client.state.thread_id;
let window_id = format!("{thread_id}:1");
let client_metadata = client.build_ws_client_metadata(
&window_id,
Some(r#"{"turn_id":"turn-123"}"#),
/*use_responses_lite*/ false,
);
let thread_id = client.state.thread_id;
assert_eq!(
client_metadata,
std::collections::HashMap::from([
+4 -36
View File
@@ -452,7 +452,7 @@ async fn delegated_mcp_guardian_abort_returns_synthetic_decline_answer() {
}
#[tokio::test]
async fn delegated_mcp_user_reviewer_waits_for_metadata_lookup() {
async fn delegated_mcp_user_reviewer_returns_none_without_metadata() {
let (parent_session, parent_ctx, _rx_events) =
crate::session::tests::make_session_and_context_with_rx().await;
let pending_mcp_invocations = Arc::new(Mutex::new(HashMap::from([(
@@ -464,21 +464,6 @@ async fn delegated_mcp_user_reviewer_waits_for_metadata_lookup() {
},
)])));
let cancel_token = CancellationToken::new();
let manager = Arc::clone(&parent_session.services.mcp_connection_manager);
let (manager_locked_tx, manager_locked_rx) = std::sync::mpsc::sync_channel(0);
let (release_manager_tx, release_manager_rx) = std::sync::mpsc::sync_channel(0);
let manager_lock = tokio::task::spawn_blocking(move || {
let _manager_guard = manager.blocking_write();
manager_locked_tx
.send(())
.expect("manager lock receiver should remain open");
release_manager_rx
.recv()
.expect("manager lock release sender should remain open");
});
manager_locked_rx
.recv_timeout(Duration::from_secs(1))
.expect("manager write lock should be acquired");
let event = RequestUserInputEvent {
call_id: "call-1".to_string(),
@@ -498,24 +483,7 @@ async fn delegated_mcp_user_reviewer_waits_for_metadata_lookup() {
&pending_mcp_invocations,
&event,
&cancel_token,
);
tokio::pin!(response);
assert!(
timeout(Duration::from_millis(100), &mut response)
.await
.is_err(),
"manual reviewer should wait for MCP metadata"
);
release_manager_tx
.send(())
.expect("manager lock holder should remain open");
manager_lock
.await
.expect("manager lock task should not panic");
assert_eq!(
timeout(Duration::from_secs(1), response)
.await
.expect("manual reviewer should finish after MCP metadata lookup"),
None
);
)
.await;
assert_eq!(response, None);
}
+19
View File
@@ -159,6 +159,14 @@ pub struct CodexThread {
out_of_band_elicitation_count: Mutex<u64>,
}
#[derive(Debug, Eq, PartialEq)]
pub struct BackgroundTerminalInfo {
pub item_id: String,
pub process_id: String,
pub command: String,
pub cwd: AbsolutePathBuf,
}
/// Conduit for the bidirectional stream of messages that compose a thread
/// (formerly called a conversation) in Codex.
impl CodexThread {
@@ -396,6 +404,17 @@ impl CodexThread {
self.codex.agent_status().await
}
pub async fn list_background_terminals(&self) -> Vec<BackgroundTerminalInfo> {
self.codex.session.list_background_terminals().await
}
pub async fn terminate_background_terminal(&self, process_id: i32) -> bool {
self.codex
.session
.terminate_background_terminal(process_id)
.await
}
pub(crate) fn subscribe_status(&self) -> watch::Receiver<AgentStatus> {
self.codex.agent_status.clone()
}
+20 -10
View File
@@ -143,17 +143,17 @@ async fn run_compact_task_inner(
let pre_compact_outcome = run_pre_compact_hooks(&sess, &turn_context, trigger).await;
match pre_compact_outcome {
PreCompactHookOutcome::Continue => {}
PreCompactHookOutcome::Stopped { reason } => {
let error = reason.unwrap_or_else(|| "PreCompact hook stopped execution".to_string());
PreCompactHookOutcome::Stopped => {
let error = CodexErr::TurnAborted;
attempt
.track(
sess.as_ref(),
CompactionStatus::Interrupted,
Some(error),
Some(&error),
CompactionAnalyticsDetails::default(),
)
.await;
return Err(CodexErr::TurnAborted);
return Err(error);
}
}
let result = run_compact_task_inner_impl(
@@ -165,7 +165,7 @@ async fn run_compact_task_inner(
)
.await;
let status = compaction_status_from_result(&result);
let error = result.as_ref().err().map(ToString::to_string);
let codex_error = result.as_ref().err();
if result.is_ok() {
let post_compact_outcome = run_post_compact_hooks(&sess, &turn_context, trigger).await;
if let PostCompactHookOutcome::Stopped = post_compact_outcome {
@@ -173,7 +173,7 @@ async fn run_compact_task_inner(
.track(
sess.as_ref(),
status,
error,
codex_error,
CompactionAnalyticsDetails::default(),
)
.await;
@@ -184,7 +184,7 @@ async fn run_compact_task_inner(
.track(
sess.as_ref(),
status,
error,
codex_error,
CompactionAnalyticsDetails::default(),
)
.await;
@@ -228,7 +228,7 @@ async fn run_compact_task_inner_impl(
personality: turn_context.personality,
..Default::default()
};
let window_id = sess.services.model_client.current_window_id();
let window_id = sess.current_window_id().await;
let turn_metadata_header = turn_context
.turn_metadata_state
.current_header_value_for_compaction(&window_id, compaction_metadata);
@@ -236,6 +236,7 @@ async fn run_compact_task_inner_impl(
&sess,
turn_context.as_ref(),
&mut client_session,
&window_id,
turn_metadata_header.as_deref(),
&prompt,
)
@@ -293,6 +294,7 @@ async fn run_compact_task_inner_impl(
let user_messages = collect_user_messages(history_items);
let mut new_history = build_compacted_history(Vec::new(), &user_messages, &summary_text);
let window_id = sess.advance_auto_compact_window_id().await;
if matches!(
initial_context_injection,
@@ -309,6 +311,7 @@ async fn run_compact_task_inner_impl(
let compacted_item = CompactedItem {
message: summary_text.clone(),
replacement_history: Some(new_history.clone()),
window_id: Some(window_id),
};
sess.replace_compacted_history(new_history, reference_context_item, compacted_item)
.await;
@@ -340,6 +343,7 @@ pub(crate) struct CompactionAnalyticsDetails {
pub(crate) active_context_tokens_before: Option<i64>,
pub(crate) retained_image_count: Option<usize>,
pub(crate) compaction_summary_tokens: Option<i64>,
pub(crate) cached_input_tokens: Option<i64>,
}
impl CompactionAnalyticsAttempt {
@@ -369,13 +373,14 @@ impl CompactionAnalyticsAttempt {
self,
sess: &Session,
status: CompactionStatus,
error: Option<String>,
codex_error: Option<&CodexErr>,
details: CompactionAnalyticsDetails,
) {
let CompactionAnalyticsDetails {
active_context_tokens_before,
retained_image_count,
compaction_summary_tokens,
cached_input_tokens,
} = details;
let active_context_tokens_before =
active_context_tokens_before.unwrap_or(self.active_context_tokens_before);
@@ -391,11 +396,14 @@ impl CompactionAnalyticsAttempt {
phase: self.phase,
strategy: CompactionStrategy::Memento,
status,
error,
codex_error_kind: codex_error.map(Into::into),
codex_error_http_status_code: codex_error
.and_then(CodexErr::http_status_code_value),
active_context_tokens_before,
active_context_tokens_after,
retained_image_count,
compaction_summary_tokens,
cached_input_tokens,
started_at: self.started_at,
completed_at: now_unix_seconds(),
duration_ms: Some(
@@ -579,11 +587,13 @@ async fn drain_to_completed(
sess: &Session,
turn_context: &TurnContext,
client_session: &mut ModelClientSession,
window_id: &str,
turn_metadata_header: Option<&str>,
prompt: &Prompt,
) -> CodexResult<()> {
let mut stream = client_session
.stream(
window_id,
prompt,
&turn_context.model_info,
&turn_context.session_telemetry,
+11 -8
View File
@@ -113,17 +113,17 @@ async fn run_remote_compact_task_inner(
let pre_compact_outcome = run_pre_compact_hooks(sess, turn_context, trigger).await;
match pre_compact_outcome {
PreCompactHookOutcome::Continue => {}
PreCompactHookOutcome::Stopped { reason } => {
let error = reason.unwrap_or_else(|| "PreCompact hook stopped execution".to_string());
PreCompactHookOutcome::Stopped => {
let error = CodexErr::TurnAborted;
attempt
.track(
sess.as_ref(),
codex_analytics::CompactionStatus::Interrupted,
Some(error),
Some(&error),
analytics_details,
)
.await;
return Err(CodexErr::TurnAborted);
return Err(error);
}
}
let result = run_remote_compact_task_inner_impl(
@@ -135,18 +135,18 @@ async fn run_remote_compact_task_inner(
)
.await;
let status = compaction_status_from_result(&result);
let error = result.as_ref().err().map(ToString::to_string);
let codex_error = result.as_ref().err();
if result.is_ok() {
let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await;
if let PostCompactHookOutcome::Stopped = post_compact_outcome {
attempt
.track(sess.as_ref(), status, error, analytics_details)
.track(sess.as_ref(), status, codex_error, analytics_details)
.await;
return Err(CodexErr::TurnAborted);
}
}
attempt
.track(sess.as_ref(), status, error.clone(), analytics_details)
.track(sess.as_ref(), status, codex_error, analytics_details)
.await;
if let Err(err) = result {
sess.track_turn_codex_error(turn_context, &err);
@@ -224,7 +224,7 @@ async fn run_remote_compact_task_inner_impl(
output_schema: None,
output_schema_strict: true,
};
let window_id = sess.services.model_client.current_window_id();
let window_id = sess.current_window_id().await;
let turn_metadata_header = turn_context
.turn_metadata_state
.current_header_value_for_compaction(&window_id, compaction_metadata);
@@ -245,9 +245,11 @@ async fn run_remote_compact_task_inner_impl(
},
&turn_context.session_telemetry,
&compaction_trace,
&window_id,
turn_metadata_header.as_deref(),
)
.await?;
let new_window_id = sess.advance_auto_compact_window_id().await;
new_history = process_compacted_history(
sess.as_ref(),
turn_context.as_ref(),
@@ -263,6 +265,7 @@ async fn run_remote_compact_task_inner_impl(
let compacted_item = CompactedItem {
message: String::new(),
replacement_history: Some(new_history.clone()),
window_id: Some(new_window_id),
};
// Install is the semantic boundary where the compact endpoint's output becomes live
// thread history. Keep it distinct from the later inference request so the reducer can
+14 -8
View File
@@ -127,17 +127,17 @@ async fn run_remote_compact_task_inner(
let pre_compact_outcome = run_pre_compact_hooks(sess, turn_context, trigger).await;
match pre_compact_outcome {
PreCompactHookOutcome::Continue => {}
PreCompactHookOutcome::Stopped { reason } => {
let error = reason.unwrap_or_else(|| "PreCompact hook stopped execution".to_string());
PreCompactHookOutcome::Stopped => {
let error = CodexErr::TurnAborted;
attempt
.track(
sess.as_ref(),
codex_analytics::CompactionStatus::Interrupted,
Some(error),
Some(&error),
analytics_details,
)
.await;
return Err(CodexErr::TurnAborted);
return Err(error);
}
}
let result = run_remote_compact_task_inner_impl(
@@ -150,18 +150,18 @@ async fn run_remote_compact_task_inner(
)
.await;
let status = compaction_status_from_result(&result);
let error = result.as_ref().err().map(ToString::to_string);
let codex_error = result.as_ref().err();
if result.is_ok() {
let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await;
if let PostCompactHookOutcome::Stopped = post_compact_outcome {
attempt
.track(sess.as_ref(), status, error, analytics_details)
.track(sess.as_ref(), status, codex_error, analytics_details)
.await;
return Err(CodexErr::TurnAborted);
}
}
attempt
.track(sess.as_ref(), status, error.clone(), analytics_details)
.track(sess.as_ref(), status, codex_error, analytics_details)
.await;
if let Err(err) = result {
sess.track_turn_codex_error(turn_context, &err);
@@ -240,7 +240,7 @@ async fn run_remote_compact_task_inner_impl(
output_schema_strict: true,
};
let window_id = sess.services.model_client.current_window_id();
let window_id = sess.current_window_id().await;
let turn_metadata_header = turn_context
.turn_metadata_state
.current_header_value_for_compaction(&window_id, compaction_metadata);
@@ -264,6 +264,7 @@ async fn run_remote_compact_task_inner_impl(
turn_context,
client_session,
&prompt,
&window_id,
turn_metadata_header.as_deref(),
)
.await;
@@ -280,10 +281,12 @@ async fn run_remote_compact_task_inner_impl(
if let Some(token_usage) = token_usage {
analytics_details.active_context_tokens_before = Some(token_usage.input_tokens);
analytics_details.compaction_summary_tokens = Some(token_usage.output_tokens);
analytics_details.cached_input_tokens = Some(token_usage.cached_input_tokens);
}
let (compacted_history, retained_images) =
build_v2_compacted_history(&prompt_input, compaction_output);
analytics_details.retained_image_count = Some(retained_images);
let new_window_id = sess.advance_auto_compact_window_id().await;
let new_history = process_compacted_history(
sess.as_ref(),
turn_context.as_ref(),
@@ -299,6 +302,7 @@ async fn run_remote_compact_task_inner_impl(
let compacted_item = CompactedItem {
message: String::new(),
replacement_history: Some(new_history.clone()),
window_id: Some(new_window_id),
};
compaction_trace.record_installed(&CompactionCheckpointTracePayload {
input_history: &trace_input_history,
@@ -323,6 +327,7 @@ async fn run_remote_compaction_request_v2(
turn_context: &TurnContext,
client_session: &mut ModelClientSession,
prompt: &Prompt,
window_id: &str,
turn_metadata_header: Option<&str>,
) -> CodexResult<RemoteCompactionV2Output> {
let max_retries = turn_context
@@ -334,6 +339,7 @@ async fn run_remote_compaction_request_v2(
loop {
let result = match client_session
.stream(
window_id,
prompt,
&turn_context.model_info,
&turn_context.session_telemetry,
+1
View File
@@ -483,6 +483,7 @@ async fn process_compacted_history_reinjects_model_switch_message() {
}];
let previous_turn_settings = PreviousTurnSettings {
model: "previous-regular-model".to_string(),
comp_hash: None,
realtime_active: None,
};
+23 -113
View File
@@ -5500,14 +5500,9 @@ async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<(
.await?;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
config.apps_mcp_path_override = Some("/custom/mcp".to_string());
config.apps_mcp_product_sku = Some("tpp".to_string());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert!(mcp_config.apps_enabled);
assert_eq!(
mcp_config.apps_mcp_path_override.as_deref(),
Some("/custom/mcp")
);
assert_eq!(mcp_config.apps_mcp_product_sku.as_deref(), Some("tpp"));
let _ = config.features.disable(Feature::Apps);
@@ -8862,84 +8857,6 @@ allow_login_shell = false
Ok(())
}
#[tokio::test]
async fn config_loads_apps_mcp_path_override_from_feature_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let toml = r#"
model = "gpt-5.4"
[features.apps_mcp_path_override]
path = "/custom/mcp"
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP feature");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.apps_mcp_path_override.as_deref(),
Some("/custom/mcp")
);
Ok(())
}
#[tokio::test]
async fn config_defaults_enabled_apps_mcp_path_override_to_plugin_service() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let toml = r#"
model = "gpt-5.4"
[features]
apps_mcp_path_override = true
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP feature");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert!(config.features.enabled(Feature::AppsMcpPathOverride));
assert_eq!(config.apps_mcp_path_override.as_deref(), Some("/ps/mcp"));
Ok(())
}
#[tokio::test]
async fn config_preserves_explicit_apps_mcp_path_override_path() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let toml = r#"
model = "gpt-5.4"
[features.apps_mcp_path_override]
enabled = true
path = "/custom/mcp"
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP feature");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.apps_mcp_path_override.as_deref(),
Some("/custom/mcp")
);
assert!(config.features.enabled(Feature::AppsMcpPathOverride));
Ok(())
}
#[tokio::test]
async fn config_loads_apps_mcp_product_sku_from_toml() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -9914,10 +9831,7 @@ enabled = true
.build()
.await?;
assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 4);
assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 10_000);
assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 3_600_000);
assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 30_000);
assert_eq!(config.multi_agent_v2, MultiAgentV2Config::default());
assert_eq!(
(
config.agent_max_threads,
@@ -9925,36 +9839,32 @@ enabled = true
),
(None, Some(3))
);
assert_eq!(
config.multi_agent_v2.root_agent_usage_hint_text.as_deref(),
Some(DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT)
);
assert!(
!config
.multi_agent_v2
.root_agent_usage_hint_text
.as_deref()
.unwrap_or_default()
.contains("maximum concurrency"),
);
assert_eq!(
config.multi_agent_v2.subagent_usage_hint_text.as_deref(),
Some(DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT)
);
assert!(
!config
.multi_agent_v2
.subagent_usage_hint_text
.as_deref()
.unwrap_or_default()
.contains("maximum concurrency"),
);
assert!(config.multi_agent_v2.hide_spawn_agent_metadata);
assert!(config.multi_agent_v2.non_code_mode_only);
Ok(())
}
#[test]
fn multi_agent_v2_default_usage_hints_use_configured_thread_cap() {
let config_toml = toml::from_str(
r#"[features.multi_agent_v2]
enabled = true
max_concurrent_threads_per_session = 17
"#,
)
.expect("multi-agent v2 config should parse");
let config = resolve_multi_agent_v2_config(&config_toml);
let concurrency_guidance = "There are 17 available concurrency slots, meaning that up to 17 agents can be active at once, including you.";
assert!(
[
config.root_agent_usage_hint_text,
config.subagent_usage_hint_text,
]
.into_iter()
.all(|hint| hint.is_some_and(|hint| hint.ends_with(concurrency_guidance)))
);
}
#[tokio::test]
async fn multi_agent_v2_empty_usage_hint_overrides_clear_default_hints() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
+29 -37
View File
@@ -58,7 +58,6 @@ use codex_config::types::WindowsSandboxModeToml;
use codex_core_plugins::PluginsConfigInput;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::LOCAL_FS;
use codex_features::AppsMcpPathOverrideConfigToml;
use codex_features::CodeModeConfigToml;
use codex_features::Feature;
use codex_features::FeatureConfigSource;
@@ -234,6 +233,13 @@ Payload:
```
You may also see them addressed as to=/root/..., which indicates your identity is /root/...
"#;
fn default_multi_agent_v2_usage_hint_text(usage_hint_text: &str, max_concurrency: usize) -> String {
format!(
"{usage_hint_text}\nThere are {max_concurrency} available concurrency slots, meaning that up to {max_concurrency} agents can be active at once, including you."
)
}
pub(crate) const HARD_MIN_MULTI_AGENT_V2_TIMEOUT_MS: i64 = 0;
pub(crate) const HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS: i64 =
DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS;
@@ -931,9 +937,6 @@ pub struct Config {
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
pub chatgpt_base_url: String,
/// Optional path override for the host-owned apps MCP server.
pub apps_mcp_path_override: Option<String>,
/// Optional product SKU forwarded to the host-owned apps MCP server.
pub apps_mcp_product_sku: Option<String>,
@@ -1059,22 +1062,23 @@ pub struct MultiAgentV2Config {
pub non_code_mode_only: bool,
}
impl Default for MultiAgentV2Config {
fn default() -> Self {
impl MultiAgentV2Config {
fn defaults_for_max_concurrency(max_concurrent_threads_per_session: usize) -> Self {
Self {
max_concurrent_threads_per_session:
DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION,
max_concurrent_threads_per_session,
min_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS,
max_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS,
default_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS,
usage_hint_enabled: true,
usage_hint_text: None,
root_agent_usage_hint_text: Some(
DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT.to_string(),
),
subagent_usage_hint_text: Some(
DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT.to_string(),
),
root_agent_usage_hint_text: Some(default_multi_agent_v2_usage_hint_text(
DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT,
max_concurrent_threads_per_session,
)),
subagent_usage_hint_text: Some(default_multi_agent_v2_usage_hint_text(
DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT,
max_concurrent_threads_per_session,
)),
tool_namespace: None,
hide_spawn_agent_metadata: true,
non_code_mode_only: true,
@@ -1082,6 +1086,14 @@ impl Default for MultiAgentV2Config {
}
}
impl Default for MultiAgentV2Config {
fn default() -> Self {
Self::defaults_for_max_concurrency(
DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION,
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TerminalResizeReflowMaxRows {
/// Use the runtime terminal detector to choose a scrollback-sized cap.
@@ -1415,7 +1427,6 @@ impl Config {
McpConfig {
chatgpt_base_url: self.chatgpt_base_url.clone(),
apps_mcp_path_override: self.apps_mcp_path_override.clone(),
apps_mcp_product_sku: self.apps_mcp_product_sku.clone(),
codex_home: self.codex_home.to_path_buf(),
mcp_oauth_credentials_store_mode: self.mcp_oauth_credentials_store_mode,
@@ -1428,7 +1439,6 @@ impl Config {
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(),
use_legacy_landlock: self.features.use_legacy_landlock(),
apps_enabled: self.features.enabled(Feature::Apps),
legacy_apps_mcp_loader_enabled: true,
prefix_mcp_tool_names: self.prefix_mcp_tool_names(),
client_elicitation_capability: if self.features.enabled(Feature::AuthElicitation) {
ElicitationCapability {
@@ -2319,11 +2329,11 @@ fn resolve_code_mode_config(config_toml: &ConfigToml) -> CodeModeConfig {
fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config {
let base = multi_agent_v2_toml_config(config_toml.features.as_ref());
let default = MultiAgentV2Config::default();
let max_concurrent_threads_per_session = base
.and_then(|config| config.max_concurrent_threads_per_session)
.unwrap_or(default.max_concurrent_threads_per_session);
.unwrap_or(DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION);
let default =
MultiAgentV2Config::defaults_for_max_concurrency(max_concurrent_threads_per_session);
let min_wait_timeout_ms = base
.and_then(|config| config.min_wait_timeout_ms)
.unwrap_or(default.min_wait_timeout_ms);
@@ -2413,15 +2423,6 @@ fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiA
}
}
fn apps_mcp_path_override_toml_config(
features: Option<&FeaturesToml>,
) -> Option<&AppsMcpPathOverrideConfigToml> {
match features?.apps_mcp_path_override.as_ref()? {
FeatureToml::Enabled(_) => None,
FeatureToml::Config(config) => Some(config),
}
}
fn network_proxy_toml_config(features: Option<&FeaturesToml>) -> Option<&NetworkProxyConfigToml> {
match features?.network_proxy.as_ref()? {
FeatureToml::Enabled(_) => None,
@@ -3043,14 +3044,6 @@ impl Config {
resolve_experimental_request_user_input_enabled(&cfg);
let code_mode = resolve_code_mode_config(&cfg);
let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg);
let apps_mcp_path_override = if features.enabled(Feature::AppsMcpPathOverride) {
let base = apps_mcp_path_override_toml_config(cfg.features.as_ref());
base.and_then(|config| config.path.as_ref())
.cloned()
.or_else(|| Some("/ps/mcp".to_string()))
} else {
None
};
let terminal_resize_reflow = resolve_terminal_resize_reflow_config(&cfg);
let agent_roles =
@@ -3553,7 +3546,6 @@ impl Config {
chatgpt_base_url: cfg
.chatgpt_base_url
.unwrap_or("https://chatgpt.com/backend-api/".to_string()),
apps_mcp_path_override,
apps_mcp_product_sku: cfg.apps_mcp_product_sku.clone(),
realtime_audio: cfg
.audio
+3
View File
@@ -125,6 +125,9 @@ fn config_lock_for_comparison(
) -> ConfigLockfileToml {
let mut lockfile = lockfile.clone();
clear_config_lock_debug_controls(&mut lockfile.config);
if let Some(features) = lockfile.config.features.as_mut() {
features.clear_removed_compatibility_entries();
}
if options.allow_codex_version_mismatch {
lockfile.codex_version.clear();
}
+4 -1
View File
@@ -17,6 +17,7 @@ use codex_protocol::models::PermissionProfile;
use codex_tools::DiscoverableTool;
use rmcp::model::ToolAnnotations;
use serde::Deserialize;
use tokio_util::sync::CancellationToken;
use tracing::warn;
use crate::config::Config;
@@ -285,13 +286,15 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
let (tx_event, rx_event) = unbounded();
drop(rx_event);
let (mut mcp_connection_manager, cancel_token) = McpConnectionManager::new(
let cancel_token = CancellationToken::new();
let mcp_connection_manager = McpConnectionManager::new(
&mcp_servers,
config.mcp_oauth_credentials_store_mode,
auth_status_entries,
&config.permissions.approval_policy,
INITIAL_SUBMIT_ID.to_owned(),
tx_event,
cancel_token.clone(),
PermissionProfile::default(),
// Connector discovery is threadless. Use an actually configured env if
// one exists, but do not reintroduce the old hidden-local fallback.
@@ -5,12 +5,23 @@ use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
use super::ContextualUserFragment;
/// Model-context fragment describing the skills available to Codex.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AvailableSkillsInstructions {
pub struct AvailableSkillsInstructions {
skill_root_lines: Vec<String>,
skill_lines: Vec<String>,
}
impl AvailableSkillsInstructions {
/// Creates a skills context fragment from pre-rendered catalog lines.
pub fn from_skill_lines(skill_lines: Vec<String>) -> Self {
Self {
skill_root_lines: Vec::new(),
skill_lines,
}
}
}
impl From<AvailableSkills> for AvailableSkillsInstructions {
fn from(available_skills: AvailableSkills) -> Self {
Self {
@@ -178,6 +178,7 @@ fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() {
network: None,
file_system_sandbox_policy: None,
model: "gpt-5".to_string(),
comp_hash: None,
personality: None,
collaboration_mode: None,
multi_agent_version: None,
+4 -1
View File
@@ -23,6 +23,7 @@ mod realtime_end_instructions;
mod realtime_start_instructions;
mod realtime_start_with_instructions;
mod subagent_notification;
mod token_budget_context;
mod turn_aborted;
mod user_instructions;
mod user_shell_command;
@@ -30,7 +31,7 @@ mod user_shell_command;
pub(crate) use approved_command_prefix_saved::ApprovedCommandPrefixSaved;
pub(crate) use apps_instructions::AppsInstructions;
pub(crate) use available_plugins_instructions::AvailablePluginsInstructions;
pub(crate) use available_skills_instructions::AvailableSkillsInstructions;
pub use available_skills_instructions::AvailableSkillsInstructions;
pub(crate) use codex_context_fragments::AdditionalContextDeveloperFragment;
pub(crate) use codex_context_fragments::AdditionalContextUserFragment;
pub use codex_context_fragments::ContextualUserFragment;
@@ -60,6 +61,8 @@ pub(crate) use realtime_end_instructions::RealtimeEndInstructions;
pub(crate) use realtime_start_instructions::RealtimeStartInstructions;
pub(crate) use realtime_start_with_instructions::RealtimeStartWithInstructions;
pub(crate) use subagent_notification::SubagentNotification;
pub(crate) use token_budget_context::TokenBudgetContext;
pub(crate) use token_budget_context::TokenBudgetRemainingContext;
pub(crate) use turn_aborted::TurnAborted;
pub(crate) use user_instructions::UserInstructions;
pub(crate) use user_shell_command::UserShellCommand;
@@ -0,0 +1,78 @@
use super::ContextualUserFragment;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TokenBudgetContext {
window_id: u64,
tokens_left: i64,
}
impl TokenBudgetContext {
pub(crate) fn new(window_id: u64, tokens_left: i64) -> Self {
Self {
window_id,
tokens_left,
}
}
}
impl ContextualUserFragment for TokenBudgetContext {
fn role(&self) -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("<token_budget>\n", "\n</token_budget>")
}
fn body(&self) -> String {
let window_id = self.window_id;
let tokens_left = self.tokens_left;
format!(
"Current context window {window_id}.\nYou have {tokens_left} tokens left in this context window."
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TokenBudgetRemainingContext {
tokens_left: Option<i64>,
}
impl TokenBudgetRemainingContext {
pub(crate) fn new(tokens_left: i64) -> Self {
Self {
tokens_left: Some(tokens_left),
}
}
pub(crate) fn unknown() -> Self {
Self { tokens_left: None }
}
}
impl ContextualUserFragment for TokenBudgetRemainingContext {
fn role(&self) -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("<token_budget>\n", "\n</token_budget>")
}
fn body(&self) -> String {
match self.tokens_left {
Some(tokens_left) => {
format!("You have {tokens_left} tokens left in this context window.")
}
None => "You have unknown tokens left in this context window.".to_string(),
}
}
}
@@ -130,6 +130,7 @@ fn reference_context_item() -> TurnContextItem {
network: None,
file_system_sandbox_policy: None,
model: "gpt-test".to_string(),
comp_hash: None,
personality: None,
collaboration_mode: None,
multi_agent_version: None,
+3
View File
@@ -16,6 +16,7 @@ use codex_protocol::models::is_local_image_close_tag_text;
use codex_protocol::models::is_local_image_open_tag_text;
use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG;
use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
use codex_protocol::user_input::UserInput;
use tracing::warn;
use uuid::Uuid;
@@ -29,7 +30,9 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[
"<model_switch>",
COLLABORATION_MODE_OPEN_TAG,
REALTIME_CONVERSATION_OPEN_TAG,
SKILLS_INSTRUCTIONS_OPEN_TAG,
"<personality_spec>",
"<token_budget>",
];
pub(crate) fn is_contextual_user_message_content(message: &[ContentItem]) -> bool {
+23
View File
@@ -1,3 +1,5 @@
use super::has_non_contextual_dev_message_content;
use super::is_contextual_dev_message_content;
use super::parse_turn_item;
use crate::context::ContextualUserFragment;
use crate::context::InternalContextSource;
@@ -13,9 +15,30 @@ use codex_protocol::models::ReasoningItemContent;
use codex_protocol::models::ReasoningItemReasoningSummary;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::WebSearchAction;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
use codex_protocol::user_input::UserInput;
use pretty_assertions::assert_eq;
#[test]
fn recognizes_skills_instructions_as_contextual_developer_content() {
assert!(is_contextual_dev_message_content(&[
ContentItem::InputText {
text: format!("{SKILLS_INSTRUCTIONS_OPEN_TAG}\n## Skills"),
},
]));
}
#[test]
fn recognizes_token_budget_as_contextual_developer_content() {
let content = vec![ContentItem::InputText {
text: "<token_budget>\nYou have 710 tokens left in this context window.\n</token_budget>"
.to_string(),
}];
assert!(is_contextual_dev_message_content(&content));
assert!(!has_non_contextual_dev_message_content(&content));
}
#[test]
fn parses_user_message_with_text_and_two_images() {
let img1 = "https://example.com/one.png".to_string();
+1 -1
View File
@@ -168,7 +168,7 @@ use prompt::render_guardian_transcript_entries;
#[cfg(test)]
use review::GuardianReviewOutcome;
#[cfg(test)]
use review::run_guardian_review_session as run_guardian_review_session_for_test;
use review::run_guardian_review_session_with_retry as run_guardian_review_session_for_test;
#[cfg(test)]
use review_session::build_guardian_review_session_config as build_guardian_review_session_config_for_test;
+224 -10
View File
@@ -7,6 +7,7 @@ use codex_analytics::GuardianReviewTrackContext;
use codex_analytics::GuardianReviewedAction;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::GuardianAssessmentDecisionSource;
use codex_protocol::protocol::GuardianAssessmentEvent;
@@ -19,11 +20,14 @@ use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::WarningEvent;
use std::sync::Arc;
use tokio::sync::oneshot;
use tokio::time::Instant;
use tokio::time::sleep_until;
use tokio_util::sync::CancellationToken;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::turn_timing::now_unix_timestamp_ms;
use crate::util::backoff;
use super::AUTO_REVIEW_DENIAL_WINDOW_SIZE;
use super::GUARDIAN_REVIEW_TIMEOUT;
@@ -58,6 +62,8 @@ const GUARDIAN_TIMEOUT_INSTRUCTIONS: &str = concat!(
"You may retry once, or ask the user for guidance or explicit approval.",
);
const GUARDIAN_REVIEW_MAX_ATTEMPTS: i64 = 3;
pub(crate) fn new_guardian_review_id() -> String {
uuid::Uuid::new_v4().to_string()
}
@@ -95,9 +101,16 @@ pub(super) enum GuardianReviewOutcome {
#[derive(Debug)]
pub(super) enum GuardianReviewError {
PromptBuild { message: String },
Session { message: String },
Parse { message: String },
PromptBuild {
message: String,
},
Session {
message: String,
error_info: Option<CodexErrorInfo>,
},
Parse {
message: String,
},
Timeout,
Cancelled,
}
@@ -112,6 +125,14 @@ impl GuardianReviewError {
fn session(err: anyhow::Error) -> Self {
Self::Session {
message: err.to_string(),
error_info: None,
}
}
fn session_with_error_info(err: anyhow::Error, error_info: CodexErrorInfo) -> Self {
Self::Session {
message: err.to_string(),
error_info: Some(error_info),
}
}
@@ -335,13 +356,14 @@ async fn run_guardian_review(
let schema = guardian_output_schema();
let terminal_action = action_summary.clone();
let (outcome, analytics_result) = Box::pin(run_guardian_review_session(
let (outcome, analytics_result) = Box::pin(run_guardian_review_session_with_retry(
session.clone(),
turn.clone(),
request,
retry_reason.clone(),
schema,
external_cancel,
GUARDIAN_REVIEW_MAX_ATTEMPTS,
))
.await;
@@ -464,7 +486,7 @@ async fn run_guardian_review(
| GuardianReviewError::Parse { .. } => {
let message = match &error {
GuardianReviewError::PromptBuild { message }
| GuardianReviewError::Session { message }
| GuardianReviewError::Session { message, .. }
| GuardianReviewError::Parse { message } => message,
GuardianReviewError::Timeout | GuardianReviewError::Cancelled => {
"guardian review failed"
@@ -657,13 +679,14 @@ pub(crate) fn spawn_approval_request_review(
/// context. It may still reuse the parent's managed-network allowlist for
/// read-only checks, but it intentionally runs without inherited exec-policy
/// rules.
pub(super) async fn run_guardian_review_session(
async fn run_guardian_review_session_before_deadline(
session: Arc<Session>,
turn: Arc<TurnContext>,
request: GuardianApprovalRequest,
retry_reason: Option<String>,
schema: serde_json::Value,
external_cancel: Option<CancellationToken>,
deadline: Instant,
) -> (GuardianReviewOutcome, GuardianReviewAnalyticsResult) {
let network_proxy = session.services.network_proxy.load_full();
let live_network_config = match network_proxy.as_ref() {
@@ -753,6 +776,7 @@ pub(super) async fn run_guardian_review_session(
reasoning_summary: turn.reasoning_summary,
personality: turn.personality,
external_cancel,
deadline,
}),
)
.await;
@@ -787,10 +811,16 @@ pub(super) async fn run_guardian_review_session(
GuardianReviewOutcome::Error(GuardianReviewError::prompt_build(err)),
session_analytics_result,
),
GuardianReviewSessionOutcome::SessionFailed(err) => (
GuardianReviewOutcome::Error(GuardianReviewError::session(err)),
session_analytics_result,
),
GuardianReviewSessionOutcome::SessionFailed { error, error_info } => {
let error = match error_info {
Some(error_info) => GuardianReviewError::session_with_error_info(error, error_info),
None => GuardianReviewError::session(error),
};
(
GuardianReviewOutcome::Error(error),
session_analytics_result,
)
}
GuardianReviewSessionOutcome::TimedOut => (
GuardianReviewOutcome::Error(GuardianReviewError::Timeout),
session_analytics_result,
@@ -802,9 +832,85 @@ pub(super) async fn run_guardian_review_session(
}
}
pub(super) async fn run_guardian_review_session_with_retry(
session: Arc<Session>,
turn: Arc<TurnContext>,
request: GuardianApprovalRequest,
retry_reason: Option<String>,
schema: serde_json::Value,
external_cancel: Option<CancellationToken>,
max_attempts: i64,
) -> (GuardianReviewOutcome, GuardianReviewAnalyticsResult) {
assert!(max_attempts > 0, "guardian review must run at least once");
let deadline = Instant::now() + GUARDIAN_REVIEW_TIMEOUT;
let mut attempt_count = 1;
loop {
let (outcome, mut analytics_result) = run_guardian_review_session_before_deadline(
Arc::clone(&session),
Arc::clone(&turn),
request.clone(),
retry_reason.clone(),
schema.clone(),
external_cancel.clone(),
deadline,
)
.await;
analytics_result.attempt_count = attempt_count;
if attempt_count >= max_attempts || !should_retry_guardian_review(&outcome) {
return (outcome, analytics_result);
}
if let Some(error) =
wait_before_guardian_retry(attempt_count, deadline, external_cancel.as_ref()).await
{
return (GuardianReviewOutcome::Error(error), analytics_result);
}
attempt_count += 1;
}
}
async fn wait_before_guardian_retry(
attempt_count: i64,
deadline: Instant,
external_cancel: Option<&CancellationToken>,
) -> Option<GuardianReviewError> {
let retry_delay = backoff(attempt_count as u64);
let retry_at = (Instant::now() + retry_delay).min(deadline);
tokio::select! {
_ = sleep_until(retry_at) => {
(Instant::now() >= deadline).then_some(GuardianReviewError::Timeout)
}
_ = async {
if let Some(cancel_token) = external_cancel {
cancel_token.cancelled().await;
} else {
std::future::pending::<()>().await;
}
} => Some(GuardianReviewError::Cancelled),
}
}
fn should_retry_guardian_review(outcome: &GuardianReviewOutcome) -> bool {
matches!(
outcome,
GuardianReviewOutcome::Error(
GuardianReviewError::Session {
error_info: Some(
CodexErrorInfo::ServerOverloaded
| CodexErrorInfo::HttpConnectionFailed { .. }
| CodexErrorInfo::ResponseStreamConnectionFailed { .. }
| CodexErrorInfo::InternalServerError
| CodexErrorInfo::ResponseStreamDisconnected { .. }
),
..
} | GuardianReviewError::Parse { .. }
)
)
}
#[cfg(test)]
mod review_tests {
use super::*;
use std::time::Duration;
#[test]
fn guardian_review_error_reason_distinguishes_error_kinds() {
@@ -812,6 +918,10 @@ mod review_tests {
let prompt_error = GuardianReviewError::prompt_build(anyhow::anyhow!("bad prompt/config"));
let session_error =
GuardianReviewError::session(anyhow::anyhow!("guardian runtime failed"));
let structured_session_error = GuardianReviewError::session_with_error_info(
anyhow::anyhow!("temporary guardian failure"),
CodexErrorInfo::ServerOverloaded,
);
assert!(matches!(
parse_error.failure_reason(),
@@ -825,5 +935,109 @@ mod review_tests {
session_error.failure_reason(),
GuardianReviewFailureReason::SessionError
));
assert!(matches!(
structured_session_error.failure_reason(),
GuardianReviewFailureReason::SessionError
));
}
#[test]
fn guardian_review_retry_only_retries_transient_session_and_parse_errors() {
let assessment = GuardianAssessment {
risk_level: GuardianRiskLevel::High,
user_authorization: GuardianUserAuthorization::Unknown,
outcome: GuardianAssessmentOutcome::Deny,
rationale: "deny".to_string(),
};
let transient_error_info = [
CodexErrorInfo::ServerOverloaded,
CodexErrorInfo::HttpConnectionFailed {
http_status_code: Some(502),
},
CodexErrorInfo::ResponseStreamConnectionFailed {
http_status_code: Some(503),
},
CodexErrorInfo::InternalServerError,
CodexErrorInfo::ResponseStreamDisconnected {
http_status_code: None,
},
];
let mut outcomes = transient_error_info
.into_iter()
.map(|error_info| {
(
GuardianReviewOutcome::Error(GuardianReviewError::session_with_error_info(
anyhow::anyhow!("transient session"),
error_info,
)),
true,
)
})
.collect::<Vec<_>>();
outcomes.extend([
(GuardianReviewOutcome::Completed(assessment), false),
(
GuardianReviewOutcome::Error(GuardianReviewError::prompt_build(anyhow::anyhow!(
"prompt"
))),
false,
),
(
GuardianReviewOutcome::Error(GuardianReviewError::session(anyhow::anyhow!(
"session"
))),
false,
),
(
GuardianReviewOutcome::Error(GuardianReviewError::session_with_error_info(
anyhow::anyhow!("bad request"),
CodexErrorInfo::BadRequest,
)),
false,
),
(
GuardianReviewOutcome::Error(GuardianReviewError::parse(anyhow::anyhow!("parse"))),
true,
),
(
GuardianReviewOutcome::Error(GuardianReviewError::Timeout),
false,
),
(
GuardianReviewOutcome::Error(GuardianReviewError::Cancelled),
false,
),
]);
for (outcome, expected) in outcomes {
assert_eq!(should_retry_guardian_review(&outcome), expected);
}
}
#[tokio::test]
async fn guardian_review_retry_wait_honors_cancellation() {
let cancel_token = CancellationToken::new();
cancel_token.cancel();
let error = wait_before_guardian_retry(
/*attempt_count*/ 1,
Instant::now() + Duration::from_secs(/*secs*/ 1),
Some(&cancel_token),
)
.await;
assert!(matches!(error, Some(GuardianReviewError::Cancelled)));
}
#[tokio::test]
async fn guardian_review_retry_wait_honors_deadline() {
let error = wait_before_guardian_retry(
/*attempt_count*/ 1,
Instant::now(),
/*external_cancel*/ None,
)
.await;
assert!(matches!(error, Some(GuardianReviewError::Timeout)));
}
}
+92 -10
View File
@@ -15,6 +15,8 @@ use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InitialHistory;
@@ -46,7 +48,6 @@ use codex_features::Feature;
use codex_model_provider_info::ModelProviderInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
use super::GUARDIAN_REVIEW_TIMEOUT;
use super::GUARDIAN_REVIEWER_NAME;
use super::GuardianApprovalRequest;
use super::prompt::GuardianPromptMode;
@@ -60,7 +61,10 @@ const GUARDIAN_INTERRUPT_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
pub(crate) enum GuardianReviewSessionOutcome {
Completed(anyhow::Result<Option<String>>),
PromptBuildFailed(anyhow::Error),
SessionFailed(anyhow::Error),
SessionFailed {
error: anyhow::Error,
error_info: Option<CodexErrorInfo>,
},
TimedOut,
Aborted,
}
@@ -77,6 +81,7 @@ pub(crate) struct GuardianReviewSessionParams {
pub(crate) reasoning_summary: ReasoningSummaryConfig,
pub(crate) personality: Option<Personality>,
pub(crate) external_cancel: Option<CancellationToken>,
pub(crate) deadline: tokio::time::Instant,
}
#[derive(Default)]
@@ -312,7 +317,7 @@ impl GuardianReviewSessionManager {
&self,
params: GuardianReviewSessionParams,
) -> (GuardianReviewSessionOutcome, GuardianReviewAnalyticsResult) {
let deadline = tokio::time::Instant::now() + GUARDIAN_REVIEW_TIMEOUT;
let deadline = params.deadline;
let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(&params.spawn_config);
let mut stale_trunk_to_shutdown = None;
let mut spawned_trunk = false;
@@ -364,7 +369,9 @@ impl GuardianReviewSessionManager {
state.trunk.as_ref().cloned()
}
Err(outcome) => return (outcome, GuardianReviewAnalyticsResult::without_session()),
Err(outcome) => {
return (outcome, GuardianReviewAnalyticsResult::without_session());
}
};
if let Some(review_session) = stale_trunk_to_shutdown {
@@ -560,7 +567,9 @@ impl GuardianReviewSessionManager {
GuardianReviewAnalyticsResult::without_session(),
);
}
Err(outcome) => return (outcome, GuardianReviewAnalyticsResult::without_session()),
Err(outcome) => {
return (outcome, GuardianReviewAnalyticsResult::without_session());
}
};
self.register_active_ephemeral(Arc::clone(&review_session))
.await;
@@ -764,7 +773,10 @@ async fn run_review_on_session(
Ok(Ok(child_turn_id)) => child_turn_id,
Ok(Err(err)) => {
return (
GuardianReviewSessionOutcome::SessionFailed(err.into()),
GuardianReviewSessionOutcome::SessionFailed {
error: err.into(),
error_info: None,
},
false,
analytics_result,
);
@@ -825,7 +837,7 @@ async fn wait_for_guardian_review(
) -> (GuardianReviewSessionOutcome, bool, bool) {
let timeout = tokio::time::sleep_until(deadline);
tokio::pin!(timeout);
let mut last_error_message: Option<String> = None;
let mut last_error: Option<ErrorEvent> = None;
loop {
tokio::select! {
@@ -862,10 +874,13 @@ async fn wait_for_guardian_review(
.time_to_first_token_ms
.and_then(|ms| u64::try_from(ms).ok());
if turn_complete.last_agent_message.is_none()
&& let Some(error_message) = last_error_message
&& let Some(error) = last_error
{
return (
GuardianReviewSessionOutcome::Completed(Err(anyhow!(error_message))),
GuardianReviewSessionOutcome::SessionFailed {
error: anyhow!(error.message),
error_info: error.codex_error_info,
},
true,
true,
);
@@ -877,7 +892,7 @@ async fn wait_for_guardian_review(
);
}
EventMsg::Error(error) => {
last_error_message = Some(error.message);
last_error = Some(error);
}
EventMsg::TurnAborted(_) => {
return (GuardianReviewSessionOutcome::Aborted, true, false);
@@ -920,6 +935,8 @@ pub(crate) fn build_guardian_review_session_config(
let mut guardian_config = parent_config.clone();
guardian_config.model = Some(active_model.to_string());
guardian_config.model_reasoning_effort = reasoning_effort;
guardian_config.model_provider.request_max_retries = Some(1);
guardian_config.model_provider.stream_max_retries = Some(1);
guardian_config.include_skill_instructions = false;
guardian_config.base_instructions = Some(
parent_config
@@ -1148,6 +1165,7 @@ mod tests {
reasoning_summary,
personality,
external_cancel: None,
deadline: tokio::time::Instant::now() + Duration::from_secs(30),
}
}
@@ -1473,6 +1491,29 @@ mod tests {
assert!(keep_review_session);
}
#[tokio::test]
async fn run_review_removes_trunk_when_event_stream_is_broken() {
let (mut review_session, tx_event, _rx_sub) = test_review_session().await;
let params = test_review_params().await;
review_session.reuse_key =
GuardianReviewSessionReuseKey::from_spawn_config(&params.spawn_config);
let manager = GuardianReviewSessionManager {
state: Arc::new(Mutex::new(GuardianReviewSessionState {
trunk: Some(Arc::new(review_session)),
ephemeral_reviews: Vec::new(),
})),
};
drop(tx_event);
let (outcome, _) = manager.run_review(params).await;
assert!(matches!(
outcome,
GuardianReviewSessionOutcome::Completed(Err(_))
));
assert!(manager.state.lock().await.trunk.is_none());
}
#[tokio::test]
async fn wait_for_guardian_review_ignores_prior_turn_completion() {
let (review_session, tx_event, _rx_sub) = test_review_session().await;
@@ -1545,6 +1586,47 @@ mod tests {
assert!(capture_token_usage);
}
#[tokio::test]
async fn wait_for_guardian_review_preserves_structured_session_error() {
let (review_session, tx_event, _rx_sub) = test_review_session().await;
tx_event
.send(Event {
id: "current-turn".to_string(),
msg: EventMsg::Error(ErrorEvent {
message: "temporary failure".to_string(),
codex_error_info: Some(CodexErrorInfo::ServerOverloaded),
}),
})
.await
.expect("queue guardian error");
tx_event
.send(turn_complete_event(
"current-turn",
/*last_agent_message*/ None,
Some(42),
))
.await
.expect("queue current turn completion");
let mut analytics_result = GuardianReviewAnalyticsResult::without_session();
let (outcome, keep_review_session, capture_token_usage) = wait_for_guardian_review(
&review_session,
"current-turn",
tokio::time::Instant::now() + Duration::from_secs(1),
/*external_cancel*/ None,
&mut analytics_result,
)
.await;
let GuardianReviewSessionOutcome::SessionFailed { error, error_info } = outcome else {
panic!("expected structured session failure");
};
assert_eq!(error.to_string(), "temporary failure");
assert_eq!(error_info, Some(CodexErrorInfo::ServerOverloaded));
assert!(keep_review_session);
assert!(capture_token_usage);
}
#[tokio::test]
async fn wait_for_guardian_review_ignores_prior_turn_aborts() {
let (review_session, tx_event, _rx_sub) = test_review_session().await;
+337 -19
View File
@@ -55,10 +55,11 @@ use core_test_support::context_snapshot::ContextSnapshotOptions;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_response_once;
use core_test_support::responses::mount_response_sequence;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::sse_failed;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::streaming_sse::StreamingSseChunk;
@@ -172,6 +173,51 @@ async fn guardian_test_session_and_turn(
guardian_test_session_and_turn_with_base_url(server.uri().as_str()).await
}
async fn guardian_test_session_turn_and_rx(
server: &wiremock::MockServer,
) -> (
Arc<Session>,
Arc<TurnContext>,
async_channel::Receiver<Event>,
) {
let (mut session, mut turn, rx) =
crate::session::tests::make_session_and_context_with_rx().await;
Arc::get_mut(&mut session)
.expect("session should be uniquely owned")
.thread_id = fixed_guardian_parent_session_id();
let mut config = (*turn.config).clone();
config.model_provider.base_url = Some(format!("{}/v1", server.uri()));
config.user_instructions = None;
let config = Arc::new(config);
let models_manager = test_support::models_manager_with_provider(
config.codex_home.to_path_buf(),
Arc::clone(&session.services.auth_manager),
config.model_provider.clone(),
);
Arc::get_mut(&mut session)
.expect("session should be uniquely owned")
.services
.models_manager = models_manager;
let turn_mut = Arc::get_mut(&mut turn).expect("turn should be uniquely owned");
turn_mut.config = Arc::clone(&config);
turn_mut.provider =
create_model_provider(config.model_provider.clone(), turn_mut.auth_manager.clone());
turn_mut.user_instructions = None;
(session, turn, rx)
}
fn guardian_shell_request(id: &str) -> GuardianApprovalRequest {
GuardianApprovalRequest::Shell {
id: id.to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the reviewed docs fix.".to_string()),
}
}
async fn guardian_test_session_and_turn_with_base_url(
base_url: &str,
) -> (Arc<Session>, Arc<TurnContext>) {
@@ -1343,6 +1389,7 @@ async fn guardian_request_model_for_auto_review_override(
Some("Sandbox denied outbound git push to github.com.".to_string()),
guardian_output_schema(),
/*external_cancel*/ None,
/*max_attempts*/ 1,
)
.await;
let (GuardianReviewOutcome::Completed(_), _) = outcome else {
@@ -1454,6 +1501,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot()
Some("Sandbox denied outbound git push to github.com.".to_string()),
guardian_output_schema(),
/*external_cancel*/ None,
/*max_attempts*/ 1,
)
.await;
let (GuardianReviewOutcome::Completed(assessment), metadata) = outcome else {
@@ -1632,6 +1680,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
Some("First retry reason".to_string()),
guardian_output_schema(),
/*external_cancel*/ None,
/*max_attempts*/ 1,
)
.await;
session
@@ -1676,6 +1725,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
Some("Second retry reason".to_string()),
guardian_output_schema(),
/*external_cancel*/ None,
/*max_attempts*/ 1,
)
.await;
session
@@ -1716,6 +1766,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
Some("Third retry reason".to_string()),
guardian_output_schema(),
/*external_cancel*/ None,
/*max_attempts*/ 1,
)
.await;
@@ -1907,6 +1958,7 @@ async fn guardian_reused_trunk_ignores_stale_prior_turn_completion() -> anyhow::
/*retry_reason*/ None,
guardian_output_schema(),
/*external_cancel*/ None,
/*max_attempts*/ 1,
)
.await;
let (GuardianReviewOutcome::Completed(first_assessment), first_metadata) = first_outcome else {
@@ -1949,6 +2001,7 @@ async fn guardian_reused_trunk_ignores_stale_prior_turn_completion() -> anyhow::
/*retry_reason*/ None,
guardian_output_schema(),
/*external_cancel*/ None,
/*max_attempts*/ 1,
)
.await;
let (GuardianReviewOutcome::Completed(second_assessment), second_metadata) = second_outcome
@@ -1978,15 +2031,17 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() ->
let server = start_mock_server().await;
let error_message =
"Item 'rs_test' of type 'reasoning' was provided without its required following item.";
let _request_log = mount_response_once(
let request_log = mount_response_sequence(
&server,
wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
"error": {
"message": error_message,
"type": "invalid_request_error",
"param": "input"
}
})),
vec![
wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
"error": {
"message": error_message,
"type": "invalid_request_error",
"param": "input"
}
})),
],
)
.await;
@@ -2030,6 +2085,7 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() ->
.await;
assert_eq!(decision, ReviewDecision::Denied);
assert_eq!(request_log.requests().len(), 1);
let mut warnings = Vec::new();
let mut denial_rationales = Vec::new();
@@ -2080,13 +2136,255 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() ->
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_review_retries_transient_session_failure_then_approves() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let approval = serde_json::json!({
"risk_level": "low",
"user_authorization": "high",
"outcome": "allow",
"rationale": "retry succeeded",
})
.to_string();
let request_log = mount_sse_sequence(
&server,
vec![
sse_failed(
"resp-session-failure",
"server_is_overloaded",
"temporary reviewer overload",
),
sse(vec![
ev_response_created("resp-approved"),
ev_assistant_message("msg-approved", &approval),
ev_completed("resp-approved"),
]),
],
)
.await;
let (session, turn) = guardian_test_session_and_turn(&server).await;
seed_guardian_parent_history(&session, &turn).await;
let (outcome, metadata) = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
guardian_shell_request("shell-session-retry"),
/*retry_reason*/ None,
guardian_output_schema(),
/*external_cancel*/ None,
/*max_attempts*/ 3,
)
.await;
let GuardianReviewOutcome::Completed(assessment) = outcome else {
panic!("expected guardian assessment");
};
assert_eq!(assessment.outcome, GuardianAssessmentOutcome::Allow);
assert_eq!(assessment.rationale, "retry succeeded");
assert_eq!(metadata.attempt_count, 2);
assert!(matches!(
metadata.guardian_session_kind,
Some(codex_analytics::GuardianReviewSessionKind::TrunkReused)
));
assert_eq!(request_log.requests().len(), 2);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_review_does_not_retry_missing_assessment_payload() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let request_log = mount_sse_sequence(
&server,
vec![sse(vec![
ev_response_created("resp-missing-assessment"),
ev_completed("resp-missing-assessment"),
])],
)
.await;
let (session, turn) = guardian_test_session_and_turn(&server).await;
seed_guardian_parent_history(&session, &turn).await;
let decision = review_approval_request(
&session,
&turn,
"review-missing-assessment".to_string(),
guardian_shell_request("shell-missing-assessment"),
/*retry_reason*/ None,
)
.await;
assert_eq!(decision, ReviewDecision::Denied);
assert_eq!(request_log.requests().len(), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_review_retries_two_parse_failures_then_approves() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let approval = serde_json::json!({
"risk_level": "low",
"user_authorization": "high",
"outcome": "allow",
"rationale": "retry succeeded",
})
.to_string();
let request_log = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-parse-failure-1"),
ev_assistant_message("msg-parse-failure-1", "not valid guardian json"),
ev_completed("resp-parse-failure-1"),
]),
sse(vec![
ev_response_created("resp-parse-failure-2"),
ev_assistant_message("msg-parse-failure-2", "still not valid guardian json"),
ev_completed("resp-parse-failure-2"),
]),
sse(vec![
ev_response_created("resp-approved"),
ev_assistant_message("msg-approved", &approval),
ev_completed("resp-approved"),
]),
],
)
.await;
let (session, turn) = guardian_test_session_and_turn(&server).await;
seed_guardian_parent_history(&session, &turn).await;
let (outcome, metadata) = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
guardian_shell_request("shell-parse-retry"),
/*retry_reason*/ None,
guardian_output_schema(),
/*external_cancel*/ None,
/*max_attempts*/ 3,
)
.await;
let GuardianReviewOutcome::Completed(assessment) = outcome else {
panic!("expected guardian assessment");
};
assert_eq!(assessment.outcome, GuardianAssessmentOutcome::Allow);
assert_eq!(assessment.rationale, "retry succeeded");
assert_eq!(metadata.attempt_count, 3);
assert!(matches!(
metadata.guardian_session_kind,
Some(codex_analytics::GuardianReviewSessionKind::TrunkReused)
));
assert_eq!(request_log.requests().len(), 3);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_review_exhausts_three_failures_with_one_terminal_event() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let request_log = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-parse-failure-1"),
ev_assistant_message("msg-parse-failure-1", "invalid one"),
ev_completed("resp-parse-failure-1"),
]),
sse(vec![
ev_response_created("resp-parse-failure-2"),
ev_assistant_message("msg-parse-failure-2", "invalid two"),
ev_completed("resp-parse-failure-2"),
]),
sse(vec![
ev_response_created("resp-parse-failure-3"),
ev_assistant_message("msg-parse-failure-3", "invalid three"),
ev_completed("resp-parse-failure-3"),
]),
],
)
.await;
let (session, turn, rx) = guardian_test_session_turn_and_rx(&server).await;
seed_guardian_parent_history(&session, &turn).await;
let decision = review_approval_request(
&session,
&turn,
"review-exhausted-retry".to_string(),
guardian_shell_request("shell-exhausted-retry"),
/*retry_reason*/ None,
)
.await;
assert_eq!(decision, ReviewDecision::Denied);
assert_eq!(request_log.requests().len(), 3);
let mut statuses = Vec::new();
while let Ok(event) = rx.try_recv() {
if let EventMsg::GuardianAssessment(event) = event.msg {
statuses.push(event.status);
}
}
assert_eq!(
statuses,
vec![
GuardianAssessmentStatus::InProgress,
GuardianAssessmentStatus::Denied,
]
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_review_does_not_retry_valid_denial() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let denial = serde_json::json!({
"risk_level": "high",
"user_authorization": "unknown",
"outcome": "deny",
"rationale": "unsafe",
})
.to_string();
let request_log = mount_sse_sequence(
&server,
vec![sse(vec![
ev_response_created("resp-denied"),
ev_assistant_message("msg-denied", &denial),
ev_completed("resp-denied"),
])],
)
.await;
let (session, turn) = guardian_test_session_and_turn(&server).await;
seed_guardian_parent_history(&session, &turn).await;
let decision = review_approval_request(
&session,
&turn,
"review-valid-denial".to_string(),
guardian_shell_request("shell-valid-denial"),
/*retry_reason*/ None,
)
.await;
assert_eq!(decision, ReviewDecision::Denied);
assert_eq!(request_log.requests().len(), 1);
Ok(())
}
#[tokio::test]
async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> anyhow::Result<()> {
async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() -> anyhow::Result<()>
{
const TEST_STACK_SIZE_BYTES: usize = 4 * 1024 * 1024;
let handle =
std::thread::Builder::new()
.name("guardian_parallel_reviews_fork_from_last_committed_trunk_history".to_string())
.name("guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history".to_string())
.stack_size(TEST_STACK_SIZE_BYTES)
.spawn(|| -> anyhow::Result<()> {
let runtime = tokio::runtime::Builder::new_current_thread()
@@ -2141,10 +2439,18 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a
gate: None,
body: sse(vec![
ev_response_created("resp-guardian-3"),
ev_assistant_message("msg-guardian-3", &third_assessment),
ev_assistant_message("msg-guardian-3", "not valid guardian json"),
ev_completed("resp-guardian-3"),
]),
}],
vec![StreamingSseChunk {
gate: None,
body: sse(vec![
ev_response_created("resp-guardian-4"),
ev_assistant_message("msg-guardian-4", &third_assessment),
ev_completed("resp-guardian-4"),
]),
}],
])
.await;
@@ -2271,20 +2577,28 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a
.await;
assert_eq!(third_decision, ReviewDecision::Approved);
let requests = server.requests().await;
assert_eq!(requests.len(), 3);
assert_eq!(requests.len(), 4);
let second_request_body = serde_json::from_slice::<serde_json::Value>(&requests[1])?;
let third_request_body = serde_json::from_slice::<serde_json::Value>(&requests[2])?;
let failed_ephemeral_request_body =
serde_json::from_slice::<serde_json::Value>(&requests[2])?;
let retried_ephemeral_request_body =
serde_json::from_slice::<serde_json::Value>(&requests[3])?;
assert_eq!(
second_request_body["prompt_cache_key"],
third_request_body["prompt_cache_key"],
failed_ephemeral_request_body["prompt_cache_key"],
"forked guardian review should reuse the trunk guardian prompt cache key"
);
let third_request_body_text = third_request_body.to_string();
assert_eq!(
failed_ephemeral_request_body["prompt_cache_key"],
retried_ephemeral_request_body["prompt_cache_key"],
"retried ephemeral review should preserve the guardian prompt cache key"
);
let third_request_body_text = retried_ephemeral_request_body.to_string();
assert!(
third_request_body_text.contains("first guardian rationale"),
"forked guardian review should include the last committed trunk assessment"
);
let third_user_message = last_user_message_text_from_body(&third_request_body);
let third_user_message = last_user_message_text_from_body(&retried_ephemeral_request_body);
assert!(third_user_message.contains(">>> TRANSCRIPT DELTA START\n"));
assert!(
third_user_message.contains("[5] user: Please inspect pending changes before pushing.")
@@ -2315,7 +2629,7 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a
match handle.join() {
Ok(result) => result,
Err(_) => Err(anyhow::anyhow!(
"guardian_parallel_reviews_fork_from_last_committed_trunk_history thread panicked"
"guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history thread panicked"
)),
}
}
@@ -2541,6 +2855,10 @@ async fn guardian_review_session_config_keeps_bedrock_provider_for_bedrock_gpt_5
)
.expect("guardian config");
let mut expected_model_provider =
ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None);
expected_model_provider.request_max_retries = Some(1);
expected_model_provider.stream_max_retries = Some(1);
assert_eq!(
(
guardian_config.model,
@@ -2550,7 +2868,7 @@ async fn guardian_review_session_config_keeps_bedrock_provider_for_bedrock_gpt_5
(
Some(AMAZON_BEDROCK_GPT_5_4_MODEL_ID.to_string()),
AMAZON_BEDROCK_PROVIDER_ID.to_string(),
ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None),
expected_model_provider,
)
);
}
+6 -4
View File
@@ -36,6 +36,7 @@ use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_thread_store::ReadThreadParams;
use serde_json::Value;
use tracing::instrument;
use crate::context::ContextualUserFragment;
use crate::context::HookAdditionalContext;
@@ -97,6 +98,7 @@ impl From<UserPromptSubmitOutcome> for ContextInjectingHookOutcome {
}
}
#[instrument(level = "trace", skip_all)]
pub(crate) async fn run_pending_session_start_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
@@ -291,6 +293,7 @@ pub(crate) async fn run_post_tool_use_hooks(
outcome
}
#[instrument(level = "trace", skip_all)]
pub(crate) async fn run_turn_stop_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
@@ -382,9 +385,7 @@ pub(crate) async fn run_pre_compact_hooks(
let outcome = sess.hooks().run_pre_compact(request).await;
emit_hook_completed_events(sess, turn_context, outcome.hook_events).await;
if outcome.should_stop {
PreCompactHookOutcome::Stopped {
reason: outcome.stop_reason,
}
PreCompactHookOutcome::Stopped
} else {
PreCompactHookOutcome::Continue
}
@@ -392,7 +393,7 @@ pub(crate) async fn run_pre_compact_hooks(
pub(crate) enum PreCompactHookOutcome {
Continue,
Stopped { reason: Option<String> },
Stopped,
}
pub(crate) enum PostCompactHookOutcome {
@@ -427,6 +428,7 @@ pub(crate) async fn run_post_compact_hooks(
}
}
#[instrument(level = "trace", skip_all)]
pub(crate) async fn run_legacy_after_agent_hook(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
+122
View File
@@ -0,0 +1,122 @@
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::ImageDetail;
use codex_protocol::models::ResponseItem;
use codex_utils_image::ImageProcessingError;
use codex_utils_image::PromptImageMode;
use codex_utils_image::PromptImageResizeLimits;
use codex_utils_image::load_data_url_for_prompt;
use tracing::warn;
pub(crate) const IMAGE_PROCESSING_ERROR_PLACEHOLDER: &str =
"image content omitted because it could not be processed";
const IMAGE_TOO_LARGE_PLACEHOLDER: &str =
"image content omitted because it exceeded the supported size limit; use a smaller image";
const UNSUPPORTED_LOW_DETAIL_PLACEHOLDER: &str = "image content omitted because detail 'low' is not supported; use 'high', 'original', or 'auto'";
const HIGH_DETAIL_LIMITS: PromptImageResizeLimits = PromptImageResizeLimits {
max_dimension: 2048,
max_patches: 2_500,
};
const ORIGINAL_DETAIL_LIMITS: PromptImageResizeLimits = PromptImageResizeLimits {
max_dimension: 6000,
max_patches: 10_000,
};
#[derive(Debug, thiserror::Error)]
enum ImagePreparationError {
#[error("image detail `low` is not supported")]
UnsupportedLowDetail,
#[error(transparent)]
Processing(#[from] ImageProcessingError),
}
impl ImagePreparationError {
fn placeholder(&self) -> &'static str {
match self {
ImagePreparationError::UnsupportedLowDetail => UNSUPPORTED_LOW_DETAIL_PLACEHOLDER,
ImagePreparationError::Processing(ImageProcessingError::ImageTooLarge { .. }) => {
IMAGE_TOO_LARGE_PLACEHOLDER
}
ImagePreparationError::Processing(_) => IMAGE_PROCESSING_ERROR_PLACEHOLDER,
}
}
}
pub(crate) fn prepare_response_items(items: &mut [ResponseItem]) {
for item in items {
match item {
ResponseItem::Message { content, .. } => prepare_message_content(content),
ResponseItem::FunctionCallOutput { output, .. }
| ResponseItem::CustomToolCallOutput { output, .. } => {
if let Some(content) = output.content_items_mut() {
prepare_tool_output_content(content);
}
}
ResponseItem::Reasoning { .. }
| ResponseItem::AgentMessage { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::CompactionTrigger
| ResponseItem::ContextCompaction { .. }
| ResponseItem::Other => {}
}
}
}
fn prepare_message_content(items: &mut [ContentItem]) {
for item in items {
if let ContentItem::InputImage { image_url, detail } = item
&& is_data_url(image_url)
&& let Err(error) = prepare_image(image_url, *detail)
{
warn!(%error, "failed to prepare message image");
*item = ContentItem::InputText {
text: error.placeholder().to_string(),
};
}
}
}
fn prepare_tool_output_content(items: &mut [FunctionCallOutputContentItem]) {
for item in items {
if let FunctionCallOutputContentItem::InputImage { image_url, detail } = item
&& is_data_url(image_url)
&& let Err(error) = prepare_image(image_url, *detail)
{
warn!(%error, "failed to prepare tool output image");
*item = FunctionCallOutputContentItem::InputText {
text: error.placeholder().to_string(),
};
}
}
}
fn is_data_url(image_url: &str) -> bool {
image_url
.get(.."data:".len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:"))
}
fn prepare_image(
image_url: &mut String,
detail: Option<ImageDetail>,
) -> Result<(), ImagePreparationError> {
let limits = match detail {
None | Some(ImageDetail::Auto | ImageDetail::High) => HIGH_DETAIL_LIMITS,
Some(ImageDetail::Original) => ORIGINAL_DETAIL_LIMITS,
Some(ImageDetail::Low) => return Err(ImagePreparationError::UnsupportedLowDetail),
};
let image = load_data_url_for_prompt(image_url, PromptImageMode::ResizeWithLimits(limits))?;
*image_url = image.into_data_url();
Ok(())
}
#[cfg(test)]
#[path = "image_preparation_tests.rs"]
mod tests;
@@ -0,0 +1,193 @@
use std::io::Cursor;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_utils_image::data_url_from_bytes;
use image::DynamicImage;
use image::GenericImageView;
use image::ImageBuffer;
use image::ImageFormat;
use image::Rgba;
use pretty_assertions::assert_eq;
use super::*;
fn png_data_url(width: u32, height: u32) -> (String, Vec<u8>) {
let image = ImageBuffer::from_pixel(width, height, Rgba([10u8, 20, 30, 255]));
let mut encoded = Cursor::new(Vec::new());
DynamicImage::ImageRgba8(image)
.write_to(&mut encoded, ImageFormat::Png)
.expect("encode PNG");
let bytes = encoded.into_inner();
(data_url_from_bytes("image/png", &bytes), bytes)
}
fn decoded_image(image_url: &str) -> (Vec<u8>, DynamicImage) {
let (_, payload) = image_url.split_once(',').expect("data URL payload");
let bytes = BASE64_STANDARD.decode(payload).expect("decode image URL");
let image = image::load_from_memory(&bytes).expect("decode processed image");
(bytes, image)
}
#[test]
fn preparation_preserves_small_image_bytes_and_non_data_urls() {
let (data_url, original_bytes) = png_data_url(/*width*/ 64, /*height*/ 32);
let http_url = "https://example.com/image.png".to_string();
let mut items = vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![
ContentItem::InputImage {
image_url: data_url,
detail: Some(ImageDetail::High),
},
ContentItem::InputImage {
image_url: http_url.clone(),
detail: Some(ImageDetail::Low),
},
],
phase: None,
}];
prepare_response_items(&mut items);
let ResponseItem::Message { content, .. } = &items[0] else {
panic!("expected message");
};
let [
ContentItem::InputImage { image_url, .. },
ContentItem::InputImage {
image_url: preserved_http_url,
detail: Some(ImageDetail::Low),
},
] = content.as_slice()
else {
panic!("expected two images");
};
assert_eq!(decoded_image(image_url).0, original_bytes);
assert_eq!(preserved_http_url, &http_url);
}
#[test]
fn detail_policies_apply_the_expected_budgets() {
for (detail, input_dimensions, expected_dimensions) in [
(Some(ImageDetail::High), (2048, 2048), (1600, 1600)),
(Some(ImageDetail::Original), (6401, 100), (6000, 94)),
(Some(ImageDetail::Original), (3201, 3201), (3200, 3200)),
(Some(ImageDetail::Auto), (2048, 2048), (1600, 1600)),
(None, (2048, 2048), (1600, 1600)),
] {
let (image_url, _) = png_data_url(input_dimensions.0, input_dimensions.1);
let mut items = vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputImage { image_url, detail }],
phase: None,
}];
prepare_response_items(&mut items);
let ResponseItem::Message { content, .. } = &items[0] else {
panic!("expected message");
};
let [ContentItem::InputImage { image_url, .. }] = content.as_slice() else {
panic!("expected image");
};
assert_eq!(decoded_image(image_url).1.dimensions(), expected_dimensions);
}
}
#[test]
fn preparation_replaces_only_failed_tool_images_and_preserves_metadata() {
let (valid_image_url, _) = png_data_url(/*width*/ 64, /*height*/ 32);
let expected_valid_image_url = valid_image_url.clone();
let mut items = vec![ResponseItem::CustomToolCallOutput {
call_id: "call-1".to_string(),
name: None,
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(vec![
FunctionCallOutputContentItem::InputText {
text: "before".to_string(),
},
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,%%%".to_string(),
detail: Some(ImageDetail::High),
},
FunctionCallOutputContentItem::InputImage {
image_url: data_url_from_bytes("image/png", b"not an image"),
detail: Some(ImageDetail::High),
},
FunctionCallOutputContentItem::InputImage {
image_url: valid_image_url.clone(),
detail: Some(ImageDetail::Low),
},
FunctionCallOutputContentItem::InputImage {
image_url: valid_image_url,
detail: Some(ImageDetail::High),
},
]),
success: Some(true),
},
}];
prepare_response_items(&mut items);
assert_eq!(
items,
vec![ResponseItem::CustomToolCallOutput {
call_id: "call-1".to_string(),
name: None,
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(vec![
FunctionCallOutputContentItem::InputText {
text: "before".to_string(),
},
FunctionCallOutputContentItem::InputText {
text: IMAGE_PROCESSING_ERROR_PLACEHOLDER.to_string(),
},
FunctionCallOutputContentItem::InputText {
text: IMAGE_PROCESSING_ERROR_PLACEHOLDER.to_string(),
},
FunctionCallOutputContentItem::InputText {
text: UNSUPPORTED_LOW_DETAIL_PLACEHOLDER.to_string(),
},
FunctionCallOutputContentItem::InputImage {
image_url: expected_valid_image_url,
detail: Some(ImageDetail::High),
},
]),
success: Some(true),
},
}]
);
}
#[test]
fn preparation_errors_use_bounded_actionable_placeholders() {
let cases = [
(
ImagePreparationError::UnsupportedLowDetail,
UNSUPPORTED_LOW_DETAIL_PLACEHOLDER,
),
(
ImagePreparationError::Processing(ImageProcessingError::ImageTooLarge {
representation: "decoded input",
size: 2,
max: 1,
}),
IMAGE_TOO_LARGE_PLACEHOLDER,
),
(
ImagePreparationError::Processing(ImageProcessingError::InvalidDataUrl {
reason: "details remain in logs".to_string(),
}),
IMAGE_PROCESSING_ERROR_PLACEHOLDER,
),
];
for (error, expected) in cases {
assert_eq!(error.placeholder(), expected);
}
}
+2
View File
@@ -19,6 +19,7 @@ mod codex_thread;
mod compact_remote;
mod compact_remote_v2;
mod config_lock;
pub use codex_thread::BackgroundTerminalInfo;
pub use codex_thread::CodexThread;
pub use codex_thread::CodexThreadSettingsOverrides;
pub use codex_thread::ThreadConfigSnapshot;
@@ -41,6 +42,7 @@ mod exec_policy;
mod git_info_tests;
mod guardian;
mod hook_runtime;
mod image_preparation;
mod installation_id;
pub(crate) mod landlock;
pub use landlock::spawn_command_under_linux_sandbox;
+26 -6
View File
@@ -11,6 +11,7 @@ use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::EffectiveMcpServer;
use codex_mcp::McpConfig;
use codex_mcp::ToolPluginProvenance;
use codex_mcp::codex_apps_mcp_server_config;
use codex_mcp::configured_mcp_servers;
use codex_mcp::effective_mcp_servers;
use codex_mcp::tool_plugin_provenance as collect_tool_plugin_provenance;
@@ -40,17 +41,36 @@ impl McpManager {
}
}
/// Returns the MCP config after applying runtime-only extension overlays.
/// Returns the MCP config after applying compatibility built-ins and
/// runtime-only extension overlays.
pub async fn runtime_config(&self, config: &Config) -> McpConfig {
let mut mcp_config = config.to_mcp_config(self.plugins_manager.as_ref()).await;
let contributions = self.contributions(config).await;
if contributions
let disabled_server_names = mcp_config
.configured_mcp_servers
.iter()
.any(|contribution| contribution.name() == CODEX_APPS_MCP_SERVER_NAME)
{
mcp_config.legacy_apps_mcp_loader_enabled = false;
.filter(|(_, server)| !server.enabled)
.map(|(name, _)| name.clone())
.collect::<Vec<_>>();
if mcp_config.apps_enabled {
mcp_config.configured_mcp_servers.insert(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
codex_apps_mcp_server_config(
&mcp_config.chatgpt_base_url,
mcp_config.apps_mcp_product_sku.as_deref(),
),
);
} else {
mcp_config
.configured_mcp_servers
.remove(CODEX_APPS_MCP_SERVER_NAME);
}
let contributions = self.contributions(config).await;
Self::apply_to_configured_servers(&contributions, &mut mcp_config.configured_mcp_servers);
for name in disabled_server_names {
if let Some(server) = mcp_config.configured_mcp_servers.get_mut(&name) {
server.enabled = false;
}
}
mcp_config
}
+7 -28
View File
@@ -319,8 +319,7 @@ async fn handle_approved_mcp_tool_call(
let server_origin = sess
.services
.mcp_connection_manager
.read()
.await
.load_full()
.server_origin(&server)
.map(str::to_string);
@@ -606,8 +605,7 @@ async fn maybe_request_codex_apps_auth_elicitation(
if !sess
.services
.mcp_connection_manager
.read()
.await
.load_full()
.is_host_owned_codex_apps_server(server)
{
return result;
@@ -669,13 +667,9 @@ async fn maybe_request_codex_apps_auth_elicitation(
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;
let manager = sess.services.mcp_connection_manager.load_full();
manager.hard_refresh_codex_apps_tools_cache().await
};
@@ -694,10 +688,6 @@ async fn refresh_codex_apps_after_connector_auth(sess: &Session, turn_context: &
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP sandbox metadata reads through the session-owned manager guard"
)]
async fn augment_mcp_tool_request_meta_with_sandbox_state(
sess: &Session,
turn_context: &TurnContext,
@@ -707,8 +697,7 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state(
let supports_sandbox_state_meta = sess
.services
.mcp_connection_manager
.read()
.await
.load_full()
.server_supports_sandbox_state_meta_capability(server)
.await
.unwrap_or(false);
@@ -757,8 +746,7 @@ async fn maybe_mark_thread_memory_mode_polluted(
let pollutes_memory = sess
.services
.mcp_connection_manager
.read()
.await
.load_full()
.server_pollutes_memory(server);
if !pollutes_memory {
return;
@@ -1414,17 +1402,13 @@ async fn mcp_tool_approval_decision_from_guardian(
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP approval metadata reads through the session-owned manager guard"
)]
pub(crate) async fn lookup_mcp_tool_metadata(
sess: &Session,
turn_context: &TurnContext,
server: &str,
tool_name: &str,
) -> Option<McpToolApprovalMetadata> {
let manager = sess.services.mcp_connection_manager.read().await;
let manager = sess.services.mcp_connection_manager.load_full();
let plugin_id = manager
.plugin_id_for_mcp_server_name(server)
.map(str::to_string);
@@ -1509,10 +1493,6 @@ fn get_mcp_app_resource_uri(
})
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP app metadata reads through the session-owned manager guard"
)]
async fn lookup_mcp_app_usage_metadata(
sess: &Session,
server: &str,
@@ -1521,8 +1501,7 @@ async fn lookup_mcp_app_usage_metadata(
let tools = sess
.services
.mcp_connection_manager
.read()
.await
.load_full()
.list_all_tools()
.await;
+7 -2
View File
@@ -46,6 +46,7 @@ use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use tempfile::tempdir;
use tokio_util::sync::CancellationToken;
use tracing::Instrument;
use tracing::Level;
use tracing_subscriber::fmt::format::FmtSpan;
@@ -1256,13 +1257,14 @@ fn codex_apps_auth_failure_metadata() -> McpToolApprovalMetadata {
async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: &TurnContext) {
let auth = session.services.auth_manager.auth().await;
let (manager, _cancel_token) = codex_mcp::McpConnectionManager::new(
let manager = 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(),
CancellationToken::new(),
turn_context.permission_profile(),
codex_mcp::McpRuntimeContext::new(Arc::clone(&session.services.environment_manager), {
#[allow(deprecated)]
@@ -1278,7 +1280,10 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context:
/*elicitation_reviewer*/ None,
)
.await;
*session.services.mcp_connection_manager.write().await = manager;
session
.services
.mcp_connection_manager
.store(Arc::new(manager));
}
#[tokio::test]
+2
View File
@@ -4,6 +4,7 @@ use codex_features::Feature;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::ToolInfo as McpToolInfo;
use codex_mcp::tool_is_model_visible;
use tracing::instrument;
use crate::config::Config;
use crate::connectors;
@@ -15,6 +16,7 @@ pub(crate) struct McpToolExposure {
pub(crate) deferred_tools: Option<Vec<McpToolInfo>>,
}
#[instrument(level = "trace", skip_all)]
pub(crate) fn build_mcp_tool_exposure(
all_mcp_tools: &[McpToolInfo],
connectors: Option<&[connectors::AppInfo]>,
@@ -41,6 +41,7 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins(
.into_iter()
.map(|plugin| DiscoverablePluginInfo {
id: plugin.id,
remote_plugin_id: plugin.remote_plugin_id,
name: plugin.name,
description: plugin.description,
has_skills: plugin.has_skills,
@@ -393,6 +393,7 @@ remote_plugin = true
remote_plugins,
vec![DiscoverablePluginInfo {
id: "github@openai-curated-remote".to_string(),
remote_plugin_id: Some("plugins~Plugin_remote_github".to_string()),
name: "Remote GitHub".to_string(),
description: Some("Remote GitHub short".to_string()),
has_skills: true,
@@ -680,6 +681,7 @@ async fn list_tool_suggest_discoverable_plugins_normalizes_description() {
discoverable_plugins,
vec![DiscoverablePluginInfo {
id: "slack@openai-curated".to_string(),
remote_plugin_id: None,
name: "slack".to_string(),
description: Some("Plugin with extra spacing".to_string()),
has_skills: true,
@@ -816,6 +818,7 @@ discoverables = [{ type = "plugin", id = "sample@openai-curated" }]
discoverable_plugins,
vec![DiscoverablePluginInfo {
id: "sample@openai-curated".to_string(),
remote_plugin_id: None,
name: "sample".to_string(),
description: Some(
"Plugin that includes skills, MCP servers, and app connectors".to_string(),
+1 -3
View File
@@ -5,7 +5,6 @@ use codex_exec_server::ExecServerRuntimePaths;
use codex_login::AuthManager;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::SessionSource;
use codex_protocol::user_input::UserInput;
@@ -78,8 +77,7 @@ pub(crate) async fn build_prompt_input_from_session(
.await;
if !input.is_empty() {
let input_item = ResponseInputItem::from(input);
let response_item = ResponseItem::from(input_item);
let response_item = sess.response_item_from_user_input(turn_context.as_ref(), input);
sess.record_conversation_items(turn_context.as_ref(), std::slice::from_ref(&response_item))
.await;
}
+64 -35
View File
@@ -1,5 +1,6 @@
use crate::client::ModelClient;
use crate::realtime_context::build_realtime_startup_context;
use crate::realtime_context::truncate_realtime_text_to_token_budget;
use crate::realtime_prompt::prepare_realtime_backend_prompt;
use crate::session::session::Session;
use anyhow::Context;
@@ -64,6 +65,8 @@ const USER_TEXT_IN_QUEUE_CAPACITY: usize = 64;
const HANDOFF_OUT_QUEUE_CAPACITY: usize = 64;
const OUTPUT_EVENTS_QUEUE_CAPACITY: usize = 256;
const REALTIME_STARTUP_CONTEXT_TOKEN_BUDGET: usize = 5_300;
const REALTIME_ASSISTANT_OUTPUT_TOKEN_BUDGET: usize = 1_000;
const STANDALONE_HANDOFF_ID: &str = "codex";
const DEFAULT_REALTIME_MODEL: &str = "gpt-realtime-1.5";
pub(crate) const REALTIME_USER_TEXT_PREFIX: &str = "[USER] ";
pub(crate) const REALTIME_BACKEND_TEXT_PREFIX: &str = "[BACKEND] ";
@@ -106,6 +109,9 @@ struct RealtimeHandoffState {
#[derive(Debug, PartialEq, Eq)]
enum HandoffOutput {
StandaloneAssistantOutput {
output_text: String,
},
ProgressUpdate {
handoff_id: String,
output_text: String,
@@ -460,22 +466,38 @@ impl RealtimeConversationManager {
state.handoff.clone()
};
let Some(handoff_id) = handoff.active_handoff.lock().await.clone() else {
return Ok(());
let active_handoff = handoff.active_handoff.lock().await.clone();
let output = match active_handoff {
Some(handoff_id) => {
let output_text = prefix_realtime_text(
output_text,
REALTIME_BACKEND_TEXT_PREFIX,
handoff.session_kind,
);
*handoff.last_output_text.lock().await = Some(output_text.clone());
HandoffOutput::ProgressUpdate {
handoff_id,
output_text,
}
}
None if output_text.trim().is_empty() => return Ok(()),
None => {
let output_text = prefix_realtime_text(
output_text,
REALTIME_BACKEND_TEXT_PREFIX,
handoff.session_kind,
);
HandoffOutput::StandaloneAssistantOutput {
output_text: truncate_realtime_text_to_token_budget(
&output_text,
REALTIME_ASSISTANT_OUTPUT_TOKEN_BUDGET,
),
}
}
};
let output_text = prefix_realtime_text(
output_text,
REALTIME_BACKEND_TEXT_PREFIX,
handoff.session_kind,
);
*handoff.last_output_text.lock().await = Some(output_text.clone());
handoff
.output_tx
.send(HandoffOutput::ProgressUpdate {
handoff_id,
output_text,
})
.send(output)
.await
.map_err(|_| CodexErr::InvalidRequest("conversation is not running".to_string()))?;
Ok(())
@@ -511,14 +533,6 @@ impl RealtimeConversationManager {
.map_err(|_| CodexErr::InvalidRequest("conversation is not running".to_string()))
}
pub(crate) async fn active_handoff_id(&self) -> Option<String> {
let handoff = {
let guard = self.state.lock().await;
guard.as_ref().map(|state| state.handoff.clone())
}?;
handoff.active_handoff.lock().await.clone()
}
pub(crate) async fn clear_active_handoff(&self) {
let handoff = {
let guard = self.state.lock().await;
@@ -625,12 +639,14 @@ async fn prepare_realtime_start(
if let Some(realtime_ws_base_url) = &config.experimental_realtime_ws_base_url {
api_provider.base_url = realtime_ws_base_url.clone();
}
let version = config.realtime.version;
let version = params.version.unwrap_or(config.realtime.version);
let session_config = build_realtime_session_config(
sess,
params.model,
params.prompt,
params.realtime_session_id,
params.output_modality,
version,
params.voice,
)
.await?;
@@ -664,9 +680,11 @@ async fn prepare_realtime_start(
pub(crate) async fn build_realtime_session_config(
sess: &Arc<Session>,
model: Option<String>,
prompt: Option<Option<String>>,
realtime_session_id: Option<String>,
output_modality: RealtimeOutputModality,
version: RealtimeWsVersion,
voice: Option<RealtimeVoice>,
) -> CodexResult<RealtimeSessionConfig> {
let config = sess.get_config().await;
@@ -689,18 +707,15 @@ pub(crate) async fn build_realtime_session_config(
(false, false) => format!("{prompt}\n\n{startup_context}"),
};
let model = Some(
config
.experimental_realtime_ws_model
.clone()
model
.or_else(|| config.experimental_realtime_ws_model.clone())
.unwrap_or_else(|| DEFAULT_REALTIME_MODEL.to_string()),
);
let event_parser = match config.realtime.version {
let event_parser = match version {
RealtimeWsVersion::V1 => RealtimeEventParser::V1,
RealtimeWsVersion::V2 => RealtimeEventParser::RealtimeV2,
};
if config.realtime.version == RealtimeWsVersion::V1
&& matches!(output_modality, RealtimeOutputModality::Text)
{
if version == RealtimeWsVersion::V1 && matches!(output_modality, RealtimeOutputModality::Text) {
return Err(CodexErr::InvalidRequest(
"text realtime output modality requires realtime v2".to_string(),
));
@@ -711,8 +726,8 @@ pub(crate) async fn build_realtime_session_config(
};
let voice = voice
.or(config.realtime.voice)
.unwrap_or_else(|| default_realtime_voice(config.realtime.version));
validate_realtime_voice(config.realtime.version, voice)?;
.unwrap_or_else(|| default_realtime_voice(version));
validate_realtime_voice(version, voice)?;
Ok(RealtimeSessionConfig {
instructions: prompt,
model,
@@ -739,10 +754,6 @@ fn prefix_realtime_text(text: String, prefix: &str, session_kind: RealtimeSessio
format!("{prefix}{text}")
}
pub(crate) fn prefix_realtime_v2_text(text: String, prefix: &str) -> String {
prefix_realtime_text(text, prefix, RealtimeSessionKind::V2)
}
fn validate_realtime_voice(version: RealtimeWsVersion, voice: RealtimeVoice) -> CodexResult<()> {
let voices = RealtimeVoicesList::builtin();
let allowed = match version {
@@ -1191,6 +1202,15 @@ async fn handle_handoff_output(
let result = match event_parser {
RealtimeEventParser::V1 => match handoff_output {
HandoffOutput::StandaloneAssistantOutput { output_text } => {
// TODO(guinness): Use the new client event for standalone handoffs once the API changes are complete.
writer
.send_conversation_handoff_append(
STANDALONE_HANDOFF_ID.to_string(),
output_text,
)
.await
}
HandoffOutput::ProgressUpdate {
handoff_id,
output_text,
@@ -1205,6 +1225,15 @@ async fn handle_handoff_output(
}
},
RealtimeEventParser::RealtimeV2 => match handoff_output {
HandoffOutput::StandaloneAssistantOutput { output_text } => {
if let Err(err) = writer.send_conversation_item_create(output_text).await {
Err(err)
} else {
return response_create_queue
.request_create(writer, events_tx, "standalone assistant output")
.await;
}
}
HandoffOutput::ProgressUpdate {
handoff_id,
output_text,
+26 -5
View File
@@ -2,7 +2,6 @@ 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;
@@ -149,10 +148,6 @@ fn save_config_resolved_fields(
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",
@@ -325,6 +320,32 @@ mod tests {
assert!(message.contains("model = "), "{message}");
}
#[tokio::test]
async fn lock_validation_ignores_removed_apps_mcp_path_override() {
let sc = crate::session::tests::make_session_configuration_for_tests().await;
let actual = sc.to_config_lockfile_toml().expect("lock should serialize");
let mut expected_value = toml::Value::try_from(&actual).expect("lock should become TOML");
expected_value["config"]["features"]
.as_table_mut()
.expect("features should be a table")
.insert(
"apps_mcp_path_override".to_string(),
toml::Value::Table(toml::Table::from_iter([
("enabled".to_string(), toml::Value::Boolean(true)),
(
"path".to_string(),
toml::Value::String("/custom/mcp".to_string()),
),
])),
);
let expected: ConfigLockfileToml = expected_value
.try_into()
.expect("lock with removed input should deserialize");
validate_config_lock_replay(&expected, &actual, ConfigLockReplayOptions::default())
.expect("removed compatibility input should not cause lock drift");
}
#[tokio::test]
async fn lock_validation_rejects_codex_version_mismatch_by_default() {
let sc = crate::session::tests::make_session_configuration_for_tests().await;
+10 -50
View File
@@ -15,10 +15,6 @@ use crate::session::session::Session;
use crate::session::session::SessionSettingsUpdate;
use crate::config::Config;
use crate::realtime_context::REALTIME_TURN_TOKEN_BUDGET;
use crate::realtime_context::truncate_realtime_text_to_token_budget;
use crate::realtime_conversation::REALTIME_USER_TEXT_PREFIX;
use crate::realtime_conversation::prefix_realtime_v2_text;
use crate::review_prompts::resolve_review_request;
use crate::session::spawn_review_thread;
use crate::tasks::CompactTask;
@@ -54,9 +50,7 @@ use codex_protocol::request_user_input::RequestUserInputResponse;
use crate::context_manager::is_user_turn_boundary;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::items::UserMessageItem;
use codex_protocol::mcp::RequestId as ProtocolRequestId;
use codex_protocol::user_input::UserInput;
use codex_rmcp_client::ElicitationAction;
use codex_rmcp_client::ElicitationResponse;
use serde_json::Value;
@@ -91,14 +85,7 @@ pub async fn user_input_or_turn(
op: Op,
client_user_message_id: Option<String>,
) {
user_input_or_turn_inner(
sess,
sub_id,
op,
/*mirror_user_text_to_realtime*/ Some(()),
client_user_message_id,
)
.await;
user_input_or_turn_inner(sess, sub_id, op, client_user_message_id).await;
}
pub async fn update_thread_settings(
@@ -196,7 +183,6 @@ pub(super) async fn user_input_or_turn_inner(
sess: &Arc<Session>,
sub_id: String,
op: Op,
mirror_user_text_to_realtime: Option<()>,
client_user_message_id: Option<String>,
) {
let Op::UserInput {
@@ -230,7 +216,7 @@ pub(super) async fn user_input_or_turn_inner(
}
sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref())
.await;
let accepted_items = match sess
match sess
.steer_input(
items.clone(),
additional_context.clone(),
@@ -242,7 +228,6 @@ pub(super) async fn user_input_or_turn_inner(
{
Ok(_) => {
current_context.session_telemetry.user_prompt(&items);
Some(items)
}
Err(SteerInputError::NoActiveTurn(items)) => {
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata {
@@ -256,7 +241,6 @@ pub(super) async fn user_input_or_turn_inner(
Some(sess.mcp_elicitation_reviewer()),
)
.await;
let accepted_items = items.clone();
let additional_context_input = {
let mut state = sess.state.lock().await;
state.additional_context.merge(additional_context)
@@ -278,7 +262,6 @@ pub(super) async fn user_input_or_turn_inner(
crate::tasks::RegularTask::new(),
)
.await;
Some(accepted_items)
}
Err(err) => {
sess.send_event_raw(Event {
@@ -286,33 +269,7 @@ pub(super) async fn user_input_or_turn_inner(
msg: EventMsg::Error(err.to_error_event()),
})
.await;
None
}
};
if let (Some(items), Some(())) = (accepted_items, mirror_user_text_to_realtime) {
self::mirror_user_text_to_realtime(sess, &items).await;
}
}
async fn mirror_user_text_to_realtime(sess: &Arc<Session>, items: &[UserInput]) {
let text = UserMessageItem::new(items).message();
if text.is_empty() {
return;
}
let text = if sess.conversation.is_running_v2().await {
prefix_realtime_v2_text(text, REALTIME_USER_TEXT_PREFIX)
} else {
text
};
let text = truncate_realtime_text_to_token_budget(&text, REALTIME_TURN_TOKEN_BUDGET);
if text.is_empty() {
return;
}
if sess.conversation.running_state().await.is_none() {
return;
}
if let Err(err) = sess.conversation.text_in(text).await {
debug!("failed to mirror user text to realtime conversation: {err}");
}
}
@@ -623,6 +580,9 @@ pub async fn set_thread_memory_mode(sess: &Arc<Session>, sub_id: String, mode: T
}
async fn shutdown_session_runtime(sess: &Arc<Session>) {
if let Some(startup_prewarm) = sess.take_session_startup_prewarm().await {
startup_prewarm.abort().await;
}
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
let _ = sess.conversation.shutdown().await;
sess.services
@@ -632,11 +592,11 @@ async fn shutdown_session_runtime(sess: &Arc<Session>) {
if let Err(err) = sess.services.code_mode_service.shutdown().await {
warn!("failed to shutdown code mode session: {err}");
}
let mcp_shutdown = {
let mut manager = sess.services.mcp_connection_manager.write().await;
manager.begin_shutdown()
};
mcp_shutdown.await;
sess.services
.mcp_connection_manager
.load_full()
.shutdown()
.await;
sess.guardian_review_session.shutdown().await;
}
+17 -46
View File
@@ -91,8 +91,7 @@ impl Session {
if self
.services
.mcp_connection_manager
.read()
.await
.load_full()
.elicitations_auto_deny()
{
return McpServerElicitationOutcome {
@@ -226,16 +225,11 @@ impl Session {
self.services
.mcp_connection_manager
.read()
.await
.load_full()
.resolve_elicitation(server_name, id, response)
.await
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP resource calls are serialized through the session-owned manager guard"
)]
pub async fn list_resources(
&self,
server: &str,
@@ -243,16 +237,11 @@ impl Session {
) -> anyhow::Result<ListResourcesResult> {
self.services
.mcp_connection_manager
.read()
.await
.load_full()
.list_resources(server, params)
.await
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP resource calls are serialized through the session-owned manager guard"
)]
pub async fn list_resource_templates(
&self,
server: &str,
@@ -260,16 +249,11 @@ impl Session {
) -> anyhow::Result<ListResourceTemplatesResult> {
self.services
.mcp_connection_manager
.read()
.await
.load_full()
.list_resource_templates(server, params)
.await
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP resource calls are serialized through the session-owned manager guard"
)]
pub async fn read_resource(
&self,
server: &str,
@@ -277,16 +261,11 @@ impl Session {
) -> anyhow::Result<ReadResourceResult> {
self.services
.mcp_connection_manager
.read()
.await
.load_full()
.read_resource(server, params)
.await
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP tool calls are serialized through the session-owned manager guard"
)]
pub async fn call_tool(
&self,
server: &str,
@@ -296,8 +275,7 @@ impl Session {
) -> anyhow::Result<CallToolResult> {
self.services
.mcp_connection_manager
.read()
.await
.load_full()
.call_tool(server, tool, arguments, meta)
.await
}
@@ -338,18 +316,21 @@ impl Session {
turn_context.cwd.to_path_buf(),
),
};
{
let mcp_startup_cancellation_token = {
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
guard.cancel();
*guard = CancellationToken::new();
}
let (refreshed_manager, cancel_token) = McpConnectionManager::new(
let cancellation_token = CancellationToken::new();
*guard = cancellation_token.clone();
cancellation_token
};
let refreshed_manager = McpConnectionManager::new(
&mcp_servers,
store_mode,
auth_statuses,
&turn_context.approval_policy,
turn_context.sub_id.clone(),
self.get_tx_event(),
mcp_startup_cancellation_token,
turn_context.permission_profile(),
mcp_runtime_context,
config.codex_home.to_path_buf(),
@@ -363,22 +344,12 @@ impl Session {
)
.await;
{
let current_manager = self.services.mcp_connection_manager.read().await;
let current_manager = self.services.mcp_connection_manager.load_full();
refreshed_manager.set_elicitations_auto_deny(current_manager.elicitations_auto_deny());
}
{
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
if guard.is_cancelled() {
cancel_token.cancel();
}
*guard = cancel_token;
}
let mut old_manager = {
let mut manager = self.services.mcp_connection_manager.write().await;
std::mem::replace(&mut *manager, refreshed_manager)
};
old_manager.shutdown().await;
self.services
.mcp_connection_manager
.store(Arc::new(refreshed_manager));
}
pub(crate) async fn refresh_mcp_servers_if_requested(
+129 -23
View File
@@ -1,3 +1,4 @@
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
@@ -31,6 +32,7 @@ use crate::context::PersonalitySpecInstructions;
use crate::default_skill_metadata_budget;
use crate::environment_selection::ResolvedTurnEnvironments;
use crate::exec_policy::ExecPolicyManager;
use crate::image_preparation::prepare_response_items;
use crate::parse_turn_item;
use crate::realtime_conversation::RealtimeConversationManager;
use crate::session_prefix::format_subagent_notification_message;
@@ -63,6 +65,7 @@ use codex_login::CodexAuth;
use codex_login::auth_env_telemetry::collect_auth_env_telemetry;
use codex_login::default_client::originator;
use codex_mcp::McpConnectionManager;
use codex_mcp::McpResourceClient;
use codex_mcp::McpRuntimeContext;
use codex_mcp::codex_apps_tools_cache_key;
use codex_models_manager::manager::RefreshStrategy;
@@ -205,6 +208,7 @@ mod review;
mod rollout_reconstruction;
#[allow(clippy::module_inception)]
pub(crate) mod session;
mod token_budget;
pub(crate) mod turn;
pub(crate) mod turn_context;
use self::config_lock::export_config_lock_if_configured;
@@ -273,10 +277,12 @@ impl SteerInputError {
/// Conceptually this is the same role that `previous_model` used to fill, but
/// it can carry other prior-turn settings that matter when constructing
/// sensible state-change diffs or full-context reinjection, such as model
/// switches or detecting a prior `realtime_active -> false` transition.
/// switches, compaction compatibility, or detecting a prior
/// `realtime_active -> false` transition.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PreviousTurnSettings {
pub(crate) model: String,
pub(crate) comp_hash: Option<String>,
pub(crate) realtime_active: Option<bool>,
}
@@ -326,6 +332,7 @@ use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::LocalImagePreparation;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
@@ -793,7 +800,7 @@ impl Codex {
..Default::default()
})
.await?;
let mcp_connection_manager = self.session.services.mcp_connection_manager.read().await;
let mcp_connection_manager = self.session.services.mcp_connection_manager.load_full();
mcp_connection_manager.set_elicitations_auto_deny(mcp_elicitations_auto_deny);
Ok(())
}
@@ -1132,7 +1139,6 @@ impl Session {
additional_context: Default::default(),
thread_settings: Default::default(),
},
/*mirror_user_text_to_realtime*/ None,
/*client_user_message_id*/ None,
)
.await;
@@ -1300,15 +1306,27 @@ impl Session {
turn_context: &TurnContext,
rollout_items: &[RolloutItem],
) -> Option<PreviousTurnSettings> {
let reconstructed_rollout = self
let rollout_reconstruction::RolloutReconstruction {
mut history,
previous_turn_settings,
reference_context_item,
window_id,
} = self
.reconstruct_history_from_rollout(turn_context, rollout_items)
.await;
let previous_turn_settings = reconstructed_rollout.previous_turn_settings.clone();
self.replace_history(
reconstructed_rollout.history,
reconstructed_rollout.reference_context_item,
)
.await;
if turn_context.features.enabled(Feature::ResizeAllImages) {
// Keep the recorded rollout unchanged. Prepare its reconstructed history before
// installing it, so legacy images are processed once for this resume or fork and
// will be processed again if the rollout is reconstructed in a future session.
// This meets image resizing requirements without modifying persisted rollouts.
prepare_response_items(&mut history);
}
{
let mut state = self.state.lock().await;
state.replace_history(history, reference_context_item);
state.set_auto_compact_window_id(window_id);
state.set_previous_turn_settings(previous_turn_settings.clone());
}
let prefix_tokens = if matches!(
turn_context.config.model_auto_compact_token_limit_scope,
AutoCompactTokenLimitScope::BodyAfterPrefix
@@ -1323,8 +1341,6 @@ impl Session {
self.set_auto_compact_window_estimated_prefill_for_scope(turn_context, prefix_tokens)
.await;
}
self.set_previous_turn_settings(previous_turn_settings.clone())
.await;
previous_turn_settings
}
@@ -1794,9 +1810,7 @@ impl Session {
let Some(text) = realtime_text_for_event(msg) else {
return;
};
if self.conversation.running_state().await.is_none()
|| self.conversation.active_handoff_id().await.is_none()
{
if self.conversation.running_state().await.is_none() {
return;
}
if let Err(err) = self.conversation.handoff_out(text).await {
@@ -2583,11 +2597,43 @@ impl Session {
/// Records conversation items: append to history, persist to rollout, and
/// notify clients observing raw response items.
pub(crate) fn prepare_conversation_items_for_history<'a>(
&self,
turn_context: &TurnContext,
items: &'a [ResponseItem],
) -> Cow<'a, [ResponseItem]> {
if !turn_context.features.enabled(Feature::ResizeAllImages) {
return Cow::Borrowed(items);
}
let mut prepared_items = items.to_vec();
prepare_response_items(&mut prepared_items);
Cow::Owned(prepared_items)
}
pub(crate) fn response_item_from_user_input(
&self,
turn_context: &TurnContext,
input: Vec<UserInput>,
) -> ResponseItem {
let local_image_preparation = if turn_context.features.enabled(Feature::ResizeAllImages) {
LocalImagePreparation::Defer
} else {
LocalImagePreparation::Process
};
ResponseItem::from(ResponseInputItem::from_user_input(
input,
local_image_preparation,
))
}
pub(crate) async fn record_conversation_items(
&self,
turn_context: &TurnContext,
items: &[ResponseItem],
) {
let items = self.prepare_conversation_items_for_history(turn_context, items);
let items = items.as_ref();
{
let mut state = self.state.lock().await;
state.record_items(items.iter(), turn_context.truncation_policy);
@@ -2656,6 +2702,7 @@ impl Session {
.await;
}
#[cfg(test)]
pub(crate) async fn replace_history(
&self,
items: Vec<ResponseItem>,
@@ -2674,7 +2721,6 @@ impl Session {
{
let mut state = self.state.lock().await;
state.replace_history(items, reference_context_item.clone());
state.start_next_auto_compact_window();
}
self.persist_rollout_items(&[RolloutItem::Compacted(compacted_item)])
@@ -2687,7 +2733,6 @@ impl Session {
let mut state = self.state.lock().await;
state.queue_pending_session_start_source(codex_hooks::SessionStartSource::Compact);
}
self.services.model_client.advance_window_generation();
}
async fn persist_rollout_response_items(&self, items: &[ResponseItem]) {
@@ -2749,10 +2794,6 @@ impl Session {
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP app context rendering reads through the session-owned manager guard"
)]
pub(crate) async fn build_initial_context(
&self,
turn_context: &TurnContext,
@@ -2766,6 +2807,7 @@ impl Session {
collaboration_mode,
base_instructions,
session_source,
auto_compact_window_id,
) = {
let state = self.state.lock().await;
(
@@ -2774,6 +2816,7 @@ impl Session {
state.session_configuration.collaboration_mode.clone(),
state.session_configuration.base_instructions.clone(),
state.session_configuration.session_source.clone(),
state.auto_compact_window_id(),
)
};
if let Some(model_switch_message) =
@@ -2845,7 +2888,7 @@ impl Session {
}
}
if turn_context.config.include_apps_instructions && turn_context.apps_enabled() {
let mcp_connection_manager = self.services.mcp_connection_manager.read().await;
let mcp_connection_manager = self.services.mcp_connection_manager.load_full();
let accessible_and_enabled_connectors =
connectors::list_accessible_and_enabled_connectors_from_manager(
&mcp_connection_manager,
@@ -2923,6 +2966,18 @@ impl Session {
.render(),
);
}
// This is full-context metadata. Steady-state context diffs should not re-emit it.
if turn_context.features.enabled(Feature::TokenBudget)
&& let Some(model_context_window) = turn_context.model_context_window()
{
developer_sections.push(
crate::context::TokenBudgetContext::new(
auto_compact_window_id,
model_context_window,
)
.render(),
);
}
if turn_context.config.include_environment_context {
let shell = self.user_shell();
let subagents = self
@@ -2994,6 +3049,56 @@ impl Session {
state.clone_history()
}
pub(crate) async fn current_window_id(&self) -> String {
let state = self.state.lock().await;
let thread_id = self.thread_id;
let window_id = state.auto_compact_window_id();
format!("{thread_id}:{window_id}")
}
pub(crate) async fn advance_auto_compact_window_id(&self) -> u64 {
let mut state = self.state.lock().await;
state.advance_auto_compact_window_id()
}
pub(crate) async fn request_new_context_window(&self) {
let mut state = self.state.lock().await;
state.request_new_context_window();
}
pub(crate) async fn maybe_start_new_context_window(
&self,
turn_context: &TurnContext,
) -> Option<u64> {
let window_id = {
let mut state = self.state.lock().await;
state.start_new_context_window_if_requested()
};
let window_id = window_id?;
let context_items = self.build_initial_context(turn_context).await;
let turn_context_item = turn_context.to_turn_context_item();
let replacement_history = context_items;
{
let mut state = self.state.lock().await;
state.replace_history(replacement_history.clone(), Some(turn_context_item.clone()));
};
self.persist_rollout_items(&[
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(replacement_history),
window_id: Some(window_id),
}),
RolloutItem::TurnContext(turn_context_item),
])
.await;
{
let mut state = self.state.lock().await;
state.queue_pending_session_start_source(codex_hooks::SessionStartSource::Compact);
}
self.recompute_token_usage(turn_context).await;
Some(window_id)
}
pub(crate) async fn reference_context_item(&self) -> Option<TurnContextItem> {
let state = self.state.lock().await;
state.reference_context_item()
@@ -3012,6 +3117,7 @@ impl Session {
/// Mid-turn compaction is the other path that can re-establish that baseline when it
/// reinjects full initial context into replacement history. Other non-regular tasks
/// intentionally do not update the baseline.
#[instrument(level = "trace", skip_all)]
pub(crate) async fn record_context_updates_and_set_reference_context_item(
&self,
turn_context: &TurnContext,
@@ -3201,7 +3307,7 @@ impl Session {
// Persist the user message to history, but emit the turn item from `UserInput` so
// UI-only `text_elements` are preserved. `ResponseItem::Message` does not carry
// those spans, and `record_response_item_and_emit_turn_item` would drop them.
let response_item = ResponseItem::from(ResponseInputItem::from(input.to_vec()));
let response_item = self.response_item_from_user_input(turn_context, input.to_vec());
self.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
.await;
let mut user_message_item = UserMessageItem::new(input);
+1
View File
@@ -114,6 +114,7 @@ pub(super) async fn spawn_review_thread(
config: per_turn_config,
auth_manager: auth_manager_for_context,
model_info: model_info.clone(),
comp_hash: model_info.comp_hash.clone(),
tool_mode,
session_telemetry: session_telemetry_for_context,
provider: provider_for_context,
@@ -8,6 +8,7 @@ pub(super) struct RolloutReconstruction {
pub(super) history: Vec<ResponseItem>,
pub(super) previous_turn_settings: Option<PreviousTurnSettings>,
pub(super) reference_context_item: Option<TurnContextItem>,
pub(super) window_id: u64,
}
#[derive(Debug, Default)]
@@ -33,6 +34,7 @@ struct ActiveReplaySegment<'a> {
previous_turn_settings: Option<PreviousTurnSettings>,
reference_context_item: TurnReferenceContextItem,
base_replacement_history: Option<&'a [ResponseItem]>,
window_id: Option<u64>,
}
fn turn_ids_are_compatible(active_turn_id: Option<&str>, item_turn_id: Option<&str>) -> bool {
@@ -45,6 +47,7 @@ fn finalize_active_segment<'a>(
base_replacement_history: &mut Option<&'a [ResponseItem]>,
previous_turn_settings: &mut Option<PreviousTurnSettings>,
reference_context_item: &mut TurnReferenceContextItem,
window_id: &mut Option<u64>,
pending_rollback_turns: &mut usize,
) {
// Thread rollback drops the newest surviving real user-message boundaries. In replay, that
@@ -65,6 +68,10 @@ fn finalize_active_segment<'a>(
*base_replacement_history = Some(segment_base_replacement_history);
}
if window_id.is_none() {
*window_id = active_segment.window_id;
}
// `previous_turn_settings` come from the newest surviving user turn that established them.
if previous_turn_settings.is_none() && active_segment.counts_as_user_turn {
*previous_turn_settings = active_segment.previous_turn_settings;
@@ -97,6 +104,7 @@ impl Session {
let mut base_replacement_history: Option<&[ResponseItem]> = None;
let mut previous_turn_settings = None;
let mut reference_context_item = TurnReferenceContextItem::NeverSet;
let mut window_id = None;
// Rollback is "drop the newest N user turns". While scanning in reverse, that becomes
// "skip the next N user-turn segments we finalize".
let mut pending_rollback_turns = 0usize;
@@ -112,6 +120,9 @@ impl Session {
RolloutItem::Compacted(compacted) => {
let active_segment =
active_segment.get_or_insert_with(ActiveReplaySegment::default);
if active_segment.window_id.is_none() {
active_segment.window_id = compacted.window_id;
}
// Looking backward, compaction clears any older baseline unless a newer
// `TurnContextItem` in this same segment has already re-established it.
if matches!(
@@ -173,6 +184,7 @@ impl Session {
) {
active_segment.previous_turn_settings = Some(PreviousTurnSettings {
model: ctx.model.clone(),
comp_hash: ctx.comp_hash.clone(),
realtime_active: ctx.realtime_active,
});
if matches!(
@@ -198,6 +210,7 @@ impl Session {
&mut base_replacement_history,
&mut previous_turn_settings,
&mut reference_context_item,
&mut window_id,
&mut pending_rollback_turns,
);
}
@@ -227,10 +240,19 @@ impl Session {
&mut base_replacement_history,
&mut previous_turn_settings,
&mut reference_context_item,
&mut window_id,
&mut pending_rollback_turns,
);
}
let fallback_window_id = u64::try_from(
rollout_items
.iter()
.filter(|item| matches!(item, RolloutItem::Compacted(_)))
.count(),
)
.unwrap_or(u64::MAX);
let mut history = ContextManager::new();
let mut saw_legacy_compaction_without_replacement_history = false;
if let Some(base_replacement_history) = base_replacement_history {
@@ -296,6 +318,7 @@ impl Session {
history: history.raw_items().to_vec(),
previous_turn_settings,
reference_context_item,
window_id: window_id.unwrap_or(fallback_window_id),
}
}
}
@@ -70,6 +70,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
network: None,
file_system_sandbox_policy: None,
model: previous_model.to_string(),
comp_hash: None,
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
@@ -109,6 +110,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
network: None,
file_system_sandbox_policy: None,
model: previous_model.to_string(),
comp_hash: Some("comp-hash-a".to_string()),
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
@@ -166,6 +168,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
session.previous_turn_settings().await,
Some(PreviousTurnSettings {
model: previous_model.to_string(),
comp_hash: Some("comp-hash-a".to_string()),
realtime_active: Some(turn_context.realtime_active),
})
);
@@ -271,6 +274,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com
reconstructed.previous_turn_settings,
Some(PreviousTurnSettings {
model: turn_context.model_info.slug.clone(),
comp_hash: None,
realtime_active: Some(turn_context.realtime_active),
})
);
@@ -364,6 +368,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc
reconstructed.previous_turn_settings,
Some(PreviousTurnSettings {
model: turn_context.model_info.slug.clone(),
comp_hash: None,
realtime_active: Some(turn_context.realtime_active),
})
);
@@ -489,6 +494,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
reconstructed.previous_turn_settings,
Some(PreviousTurnSettings {
model: turn_context.model_info.slug.clone(),
comp_hash: None,
realtime_active: Some(turn_context.realtime_active),
})
);
@@ -589,6 +595,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
reconstructed.previous_turn_settings,
Some(PreviousTurnSettings {
model: turn_context.model_info.slug.clone(),
comp_hash: None,
realtime_active: Some(turn_context.realtime_active),
})
);
@@ -791,6 +798,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
window_id: None,
}),
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 },
@@ -809,6 +817,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
session.previous_turn_settings().await,
Some(PreviousTurnSettings {
model: turn_context.model_info.slug.clone(),
comp_hash: None,
realtime_active: Some(turn_context.realtime_active),
})
);
@@ -846,6 +855,7 @@ async fn record_initial_history_resumed_does_not_seed_reference_context_item_aft
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
window_id: None,
}),
];
@@ -871,6 +881,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_does_
RolloutItem::Compacted(CompactedItem {
message: "legacy summary".to_string(),
replacement_history: None,
window_id: None,
}),
];
@@ -902,6 +913,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear
RolloutItem::Compacted(CompactedItem {
message: "legacy summary".to_string(),
replacement_history: None,
window_id: None,
}),
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
@@ -959,6 +971,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
network: None,
file_system_sandbox_policy: None,
model: previous_model.to_string(),
comp_hash: None,
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
@@ -994,6 +1007,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
window_id: None,
}),
RolloutItem::TurnContext(previous_context_item),
RolloutItem::EventMsg(EventMsg::TurnComplete(
@@ -1019,6 +1033,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
session.previous_turn_settings().await,
Some(PreviousTurnSettings {
model: previous_model.to_string(),
comp_hash: None,
realtime_active: Some(turn_context.realtime_active),
})
);
@@ -1038,6 +1053,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
network: None,
file_system_sandbox_policy: None,
model: previous_model.to_string(),
comp_hash: None,
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
@@ -1067,6 +1083,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
network: None,
file_system_sandbox_policy: None,
model: previous_model.to_string(),
comp_hash: None,
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
@@ -1140,6 +1157,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
window_id: None,
}),
];
@@ -1155,6 +1173,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
session.previous_turn_settings().await,
Some(PreviousTurnSettings {
model: previous_model.to_string(),
comp_hash: None,
realtime_active: Some(turn_context.realtime_active),
})
);
@@ -1186,6 +1205,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
network: None,
file_system_sandbox_policy: None,
model: current_model.to_string(),
comp_hash: None,
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
@@ -1275,6 +1295,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
session.previous_turn_settings().await,
Some(PreviousTurnSettings {
model: current_model.to_string(),
comp_hash: None,
realtime_active: Some(turn_context.realtime_active),
})
);
@@ -1304,6 +1325,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
network: None,
file_system_sandbox_policy: None,
model: previous_model.to_string(),
comp_hash: None,
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
@@ -1369,6 +1391,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
window_id: None,
}),
];
@@ -1384,6 +1407,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
session.previous_turn_settings().await,
Some(PreviousTurnSettings {
model: previous_model.to_string(),
comp_hash: None,
realtime_active: Some(turn_context.realtime_active),
})
);
@@ -1434,6 +1458,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_
session.previous_turn_settings().await,
Some(PreviousTurnSettings {
model: turn_context.model_info.slug.clone(),
comp_hash: None,
realtime_active: Some(turn_context.realtime_active),
})
);
@@ -1463,6 +1488,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
network: None,
file_system_sandbox_policy: None,
model: previous_model.to_string(),
comp_hash: None,
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
@@ -1529,6 +1555,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(Vec::new()),
window_id: None,
}),
// A newer TurnStarted replaces the incomplete compacted turn without a matching
// completion/abort for the old one.
@@ -1555,6 +1582,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
session.previous_turn_settings().await,
Some(PreviousTurnSettings {
model: previous_model.to_string(),
comp_hash: None,
realtime_active: Some(turn_context.realtime_active),
})
);
+24 -74
View File
@@ -469,10 +469,6 @@ impl Session {
#[instrument(name = "session_init", level = "info", skip_all)]
#[allow(clippy::too_many_arguments)]
#[expect(
clippy::await_holding_invalid_type,
reason = "session initialization must serialize access through session-owned manager guards"
)]
pub(crate) async fn new(
mut session_configuration: SessionConfiguration,
config: Arc<Config>,
@@ -519,17 +515,6 @@ impl Session {
}
InitialHistory::Resumed(resumed_history) => resumed_history.conversation_id,
};
let window_generation = match &initial_history {
InitialHistory::Resumed(resumed_history) => u64::try_from(
resumed_history
.history
.iter()
.filter(|item| matches!(item, RolloutItem::Compacted(_)))
.count(),
)
.unwrap_or(u64::MAX),
InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => 0,
};
// Kick off independent async setup tasks in parallel to reduce startup latency.
//
// - initialize thread persistence with new or resumed session info
@@ -961,8 +946,20 @@ impl Session {
.effective_agent_max_threads(MultiAgentVersion::V2)
.unwrap_or(usize::MAX),
);
// Keep one stable manager handle for the session so extension resource clients
// automatically observe the manager installed at startup and on later refreshes.
let mcp_connection_manager = Arc::new(arc_swap::ArcSwap::from_pointee(
McpConnectionManager::new_uninitialized_with_permission_profile(
&config.permissions.approval_policy,
config.permissions.permission_profile(),
config.prefix_mcp_tool_names(),
),
));
let session_extension_data =
codex_extension_api::ExtensionData::new(session_id.to_string());
session_extension_data.insert(McpResourceClient::new(Arc::clone(
&mcp_connection_manager,
)));
let thread_extension_data = codex_extension_api::ExtensionData::new_with_init(
thread_id.to_string(),
thread_extension_init,
@@ -985,13 +982,7 @@ impl Session {
// before any MCP-related events. It is reasonable to consider
// changing this to use Option or OnceCell, though the current
// setup is straightforward enough and performs well.
mcp_connection_manager: Arc::new(RwLock::new(
McpConnectionManager::new_uninitialized_with_permission_profile(
&config.permissions.approval_policy,
config.permissions.permission_profile(),
config.prefix_mcp_tool_names(),
),
)),
mcp_connection_manager,
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
unified_exec_manager: UnifiedExecProcessManager::new(
config.background_terminal_max_timeout,
@@ -1051,9 +1042,6 @@ impl Session {
code_mode_service: crate::tools::code_mode::CodeModeService::new(),
environment_manager,
};
services
.model_client
.set_window_generation(window_generation);
let (out_of_band_elicitation_paused, _out_of_band_elicitation_paused_rx) =
watch::channel(false);
@@ -1116,15 +1104,6 @@ impl Session {
sess.send_event_raw(event).await;
}
let mut required_mcp_servers: Vec<String> = mcp_servers
.iter()
.filter(|(_, server)| server.enabled() && server.required())
.map(|(name, _)| name.clone())
.collect();
required_mcp_servers.sort();
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
@@ -1137,11 +1116,13 @@ impl Session {
} else {
ElicitationCapability::default()
};
{
let mcp_startup_cancellation_token = {
let mut cancel_guard = sess.services.mcp_startup_cancellation_token.lock().await;
cancel_guard.cancel();
*cancel_guard = CancellationToken::new();
}
let cancel_token = CancellationToken::new();
*cancel_guard = cancel_token.clone();
cancel_token
};
let turn_environment = crate::environment_selection::resolve_environment_selections(
sess.services.environment_manager.as_ref(),
session_configuration.environment_selections(),
@@ -1164,13 +1145,14 @@ impl Session {
session_configuration.cwd().to_path_buf(),
),
};
let (mcp_connection_manager, cancel_token) = McpConnectionManager::new(
let mcp_connection_manager = McpConnectionManager::new(
&mcp_servers,
config.mcp_oauth_credentials_store_mode,
auth_statuses.clone(),
auth_statuses,
&session_configuration.approval_policy,
INITIAL_SUBMIT_ID.to_owned(),
tx_event.clone(),
mcp_startup_cancellation_token,
session_configuration.permission_profile(),
mcp_runtime_context,
config.codex_home.to_path_buf(),
@@ -1185,43 +1167,11 @@ impl Session {
.instrument(info_span!(
"session_init.mcp_manager_init",
otel.name = "session_init.mcp_manager_init",
session_init.enabled_mcp_server_count = enabled_mcp_server_count,
session_init.required_mcp_server_count = required_mcp_server_count,
))
.await;
{
let mut manager_guard = sess.services.mcp_connection_manager.write().await;
*manager_guard = mcp_connection_manager;
}
{
let mut cancel_guard = sess.services.mcp_startup_cancellation_token.lock().await;
if cancel_guard.is_cancelled() {
cancel_token.cancel();
}
*cancel_guard = cancel_token;
}
if !required_mcp_servers.is_empty() {
let failures = sess
.services
.mcp_connection_manager
.read()
.await
.required_startup_failures(&required_mcp_servers)
.instrument(info_span!(
"session_init.required_mcp_wait",
otel.name = "session_init.required_mcp_wait",
session_init.required_mcp_server_count = required_mcp_server_count,
))
.await;
if !failures.is_empty() {
let details = failures
.iter()
.map(|failure| format!("{}: {}", failure.server, failure.error))
.collect::<Vec<_>>()
.join("; ");
anyhow::bail!("required MCP servers failed to initialize: {details}");
}
}
sess.services
.install_mcp_connection_manager(mcp_connection_manager)
.await?;
sess.schedule_startup_prewarm(session_configuration.base_instructions.clone())
.await;
let session_start_source = match &initial_history {
+279 -106
View File
@@ -42,7 +42,9 @@ use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ImageDetail;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::SandboxEnforcement;
use codex_protocol::openai_models::ModelServiceTier;
@@ -319,8 +321,7 @@ async fn request_mcp_server_elicitation_auto_accepts_when_auto_deny_is_enabled()
session
.services
.mcp_connection_manager
.read()
.await
.load_full()
.set_elicitations_auto_deny(/*auto_deny*/ true);
let requested_schema: McpElicitationSchema = serde_json::from_value(json!({
@@ -1586,6 +1587,7 @@ async fn reconstruct_history_matches_live_compactions() {
.await;
assert_eq!(expected, reconstructed.history);
assert_eq!(2, reconstructed.window_id);
}
#[tokio::test]
@@ -1613,6 +1615,7 @@ async fn reconstruct_history_uses_replacement_history_verbatim() {
let rollout_items = vec![RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(replacement_history.clone()),
window_id: Some(42),
})];
let reconstructed = session
@@ -1620,6 +1623,7 @@ async fn reconstruct_history_uses_replacement_history_verbatim() {
.await;
assert_eq!(reconstructed.history, replacement_history);
assert_eq!(42, reconstructed.window_id);
}
#[tokio::test]
@@ -1639,6 +1643,114 @@ async fn record_initial_history_reconstructs_resumed_transcript() {
assert_eq!(expected, history.raw_items());
}
#[tokio::test]
async fn resize_all_images_prepares_failures_before_history_insertion() {
let (session, turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx(
CodexAuth::from_api_key("Test API Key"),
Vec::new(),
|config| {
let _ = config.features.enable(Feature::ResizeAllImages);
},
)
.await;
let item = ResponseItem::FunctionCallOutput {
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(vec![
FunctionCallOutputContentItem::InputText {
text: "before".to_string(),
},
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,%%%".to_string(),
detail: Some(ImageDetail::High),
},
FunctionCallOutputContentItem::InputImage {
image_url: "https://example.com/image.png".to_string(),
detail: Some(ImageDetail::High),
},
]),
success: Some(true),
},
};
session
.record_conversation_items(turn_context.as_ref(), std::slice::from_ref(&item))
.await;
assert_eq!(
session.state.lock().await.clone_history().raw_items(),
&[ResponseItem::FunctionCallOutput {
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(vec![
FunctionCallOutputContentItem::InputText {
text: "before".to_string(),
},
FunctionCallOutputContentItem::InputText {
text: "image content omitted because it could not be processed".to_string(),
},
FunctionCallOutputContentItem::InputImage {
image_url: "https://example.com/image.png".to_string(),
detail: Some(ImageDetail::High),
},
]),
success: Some(true),
},
}]
);
}
#[tokio::test]
async fn resize_all_images_prepares_resumed_history_before_installing_it() {
let (session, _turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx(
CodexAuth::from_api_key("Test API Key"),
Vec::new(),
|config| {
let _ = config.features.enable(Feature::ResizeAllImages);
},
)
.await;
let resumed_item = ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![
ContentItem::InputImage {
image_url: "data:image/png;base64,%%%".to_string(),
detail: Some(ImageDetail::High),
},
ContentItem::InputText {
text: "keep me".to_string(),
},
],
phase: None,
};
session
.record_initial_history(InitialHistory::Resumed(ResumedHistory {
conversation_id: ThreadId::default(),
history: vec![RolloutItem::ResponseItem(resumed_item)],
rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")),
}))
.await;
assert_eq!(
session.state.lock().await.clone_history().raw_items(),
&[ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![
ContentItem::InputText {
text: "image content omitted because it could not be processed".to_string(),
},
ContentItem::InputText {
text: "keep me".to_string(),
},
],
phase: None,
}]
);
}
#[test]
fn resolve_multi_agent_version_handles_unset_and_legacy_history() {
let thread_id = ThreadId::default();
@@ -1922,26 +2034,27 @@ async fn record_token_usage_info_notifies_extension_contributors() {
records: Arc<std::sync::Mutex<Vec<RecordedTokenUsage>>>,
}
#[async_trait::async_trait]
impl codex_extension_api::TokenUsageContributor for TokenUsageRecorder {
async fn on_token_usage(
&self,
session_store: &codex_extension_api::ExtensionData,
thread_store: &codex_extension_api::ExtensionData,
turn_store: &codex_extension_api::ExtensionData,
token_usage: &TokenUsageInfo,
) {
self.records
.lock()
.expect("token usage records lock")
.push(RecordedTokenUsage {
session_level_id: session_store.level_id().to_string(),
thread_level_id: thread_store.level_id().to_string(),
turn_level_id: turn_store.level_id().to_string(),
token_usage: token_usage.clone(),
saw_session_store: session_store.get::<SessionTokenUsageMarker>().is_some(),
saw_thread_store: thread_store.get::<ThreadTokenUsageMarker>().is_some(),
});
fn on_token_usage<'a>(
&'a self,
session_store: &'a codex_extension_api::ExtensionData,
thread_store: &'a codex_extension_api::ExtensionData,
turn_store: &'a codex_extension_api::ExtensionData,
token_usage: &'a TokenUsageInfo,
) -> codex_extension_api::ExtensionFuture<'a, ()> {
Box::pin(async move {
self.records
.lock()
.expect("token usage records lock")
.push(RecordedTokenUsage {
session_level_id: session_store.level_id().to_string(),
thread_level_id: thread_store.level_id().to_string(),
turn_level_id: turn_store.level_id().to_string(),
token_usage: token_usage.clone(),
saw_session_store: session_store.get::<SessionTokenUsageMarker>().is_some(),
saw_thread_store: thread_store.get::<ThreadTokenUsageMarker>().is_some(),
});
})
}
}
@@ -2040,25 +2153,32 @@ async fn turn_start_lifecycle_exposes_turn_metadata_and_token_baseline() {
records: Arc<std::sync::Mutex<Vec<RecordedTurnStart>>>,
}
#[async_trait::async_trait]
impl codex_extension_api::TurnLifecycleContributor for TurnStartRecorder {
async fn on_turn_start(&self, input: codex_extension_api::TurnStartInput<'_>) {
self.records
.lock()
.expect("turn start records lock")
.push(RecordedTurnStart {
session_level_id: input.session_store.level_id().to_string(),
thread_level_id: input.thread_store.level_id().to_string(),
turn_level_id: input.turn_store.level_id().to_string(),
turn_id: input.turn_id.to_string(),
collaboration_mode: input.collaboration_mode.clone(),
token_usage_at_turn_start: input.token_usage_at_turn_start.clone(),
saw_session_store: input
.session_store
.get::<SessionTurnStartMarker>()
.is_some(),
saw_thread_store: input.thread_store.get::<ThreadTurnStartMarker>().is_some(),
});
fn on_turn_start<'a>(
&'a self,
input: codex_extension_api::TurnStartInput<'a>,
) -> codex_extension_api::ExtensionFuture<'a, ()> {
Box::pin(async move {
self.records
.lock()
.expect("turn start records lock")
.push(RecordedTurnStart {
session_level_id: input.session_store.level_id().to_string(),
thread_level_id: input.thread_store.level_id().to_string(),
turn_level_id: input.turn_store.level_id().to_string(),
turn_id: input.turn_id.to_string(),
collaboration_mode: input.collaboration_mode.clone(),
token_usage_at_turn_start: input.token_usage_at_turn_start.clone(),
saw_session_store: input
.session_store
.get::<SessionTurnStartMarker>()
.is_some(),
saw_thread_store: input
.thread_store
.get::<ThreadTurnStartMarker>()
.is_some(),
});
})
}
}
@@ -2138,24 +2258,31 @@ async fn turn_error_lifecycle_exposes_error_and_stores() {
records: Arc<std::sync::Mutex<Vec<RecordedTurnError>>>,
}
#[async_trait::async_trait]
impl codex_extension_api::TurnLifecycleContributor for TurnErrorRecorder {
async fn on_turn_error(&self, input: codex_extension_api::TurnErrorInput<'_>) {
self.records
.lock()
.expect("turn error records lock")
.push(RecordedTurnError {
session_level_id: input.session_store.level_id().to_string(),
thread_level_id: input.thread_store.level_id().to_string(),
turn_level_id: input.turn_store.level_id().to_string(),
turn_id: input.turn_id.to_string(),
error: input.error,
saw_session_store: input
.session_store
.get::<SessionTurnErrorMarker>()
.is_some(),
saw_thread_store: input.thread_store.get::<ThreadTurnErrorMarker>().is_some(),
});
fn on_turn_error<'a>(
&'a self,
input: codex_extension_api::TurnErrorInput<'a>,
) -> codex_extension_api::ExtensionFuture<'a, ()> {
Box::pin(async move {
self.records
.lock()
.expect("turn error records lock")
.push(RecordedTurnError {
session_level_id: input.session_store.level_id().to_string(),
thread_level_id: input.thread_store.level_id().to_string(),
turn_level_id: input.turn_store.level_id().to_string(),
turn_id: input.turn_id.to_string(),
error: input.error,
saw_session_store: input
.session_store
.get::<SessionTurnErrorMarker>()
.is_some(),
saw_thread_store: input
.thread_store
.get::<ThreadTurnErrorMarker>()
.is_some(),
});
})
}
}
@@ -2542,6 +2669,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
network: None,
file_system_sandbox_policy: None,
model: previous_model.to_string(),
comp_hash: None,
personality: turn_context.personality,
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
multi_agent_version: None,
@@ -2594,6 +2722,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
session.previous_turn_settings().await,
Some(PreviousTurnSettings {
model: previous_model.to_string(),
comp_hash: None,
realtime_active: Some(turn_context.realtime_active),
})
);
@@ -2636,6 +2765,7 @@ async fn thread_rollback_drops_last_turn_from_history() {
sess.persist_rollout_items(&rollout_items).await;
sess.set_previous_turn_settings(Some(PreviousTurnSettings {
model: "stale-model".to_string(),
comp_hash: None,
realtime_active: Some(tc.realtime_active),
}))
.await;
@@ -2818,6 +2948,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context
.await;
sess.set_previous_turn_settings(Some(PreviousTurnSettings {
model: "stale-model".to_string(),
comp_hash: None,
realtime_active: None,
}))
.await;
@@ -2834,6 +2965,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context
sess.previous_turn_settings().await,
Some(PreviousTurnSettings {
model: tc.model_info.slug.clone(),
comp_hash: None,
realtime_active: Some(tc.realtime_active),
})
);
@@ -2905,6 +3037,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
RolloutItem::Compacted(CompactedItem {
message: "summary after compaction".to_string(),
replacement_history: Some(compacted_history.clone()),
window_id: Some(7),
}),
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: compact_turn_id,
@@ -2933,6 +3066,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
RolloutItem::TurnContext(TurnContextItem {
turn_id: Some(rolled_back_turn_id.clone()),
model: "rolled-back-model".to_string(),
comp_hash: None,
..first_context_item.clone()
}),
RolloutItem::ResponseItem(user_message("turn 2 user")),
@@ -2951,6 +3085,10 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
Some(first_context_item),
)
.await;
{
let mut state = sess.state.lock().await;
state.set_auto_compact_window_id(/*window_id*/ 99);
}
handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await;
let rollback_event = wait_for_thread_rolled_back(&rx).await;
@@ -2958,6 +3096,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
assert_eq!(sess.clone_history().await.raw_items(), compacted_history);
assert!(sess.reference_context_item().await.is_none());
assert!(sess.current_window_id().await.ends_with(":7"));
}
#[tokio::test]
@@ -4801,7 +4940,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
);
let services = SessionServices {
mcp_connection_manager: Arc::new(RwLock::new(
mcp_connection_manager: Arc::new(arc_swap::ArcSwap::from_pointee(
McpConnectionManager::new_uninitialized_with_permission_profile(
&config.permissions.approval_policy,
config.permissions.permission_profile(),
@@ -6411,16 +6550,20 @@ async fn submission_loop_channel_close_emits_thread_stop_lifecycle() {
expected_thread_id: ThreadId,
}
#[async_trait::async_trait]
impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for ThreadStopRecorder {
async fn on_thread_stop(&self, input: codex_extension_api::ThreadStopInput<'_>) {
assert_eq!(
self.expected_thread_id.to_string(),
input.thread_store.level_id()
);
assert!(input.session_store.get::<SessionStopMarker>().is_some());
assert!(input.thread_store.get::<ThreadStopMarker>().is_some());
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
fn on_thread_stop<'a>(
&'a self,
input: codex_extension_api::ThreadStopInput<'a>,
) -> codex_extension_api::ExtensionFuture<'a, ()> {
Box::pin(async move {
assert_eq!(
self.expected_thread_id.to_string(),
input.thread_store.level_id()
);
assert!(input.session_store.get::<SessionStopMarker>().is_some());
assert!(input.thread_store.get::<ThreadStopMarker>().is_some());
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
})
}
}
@@ -6457,33 +6600,41 @@ async fn submission_loop_channel_close_aborts_active_turn_before_thread_stop_lif
expected_turn_id: String,
}
#[async_trait::async_trait]
impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for LifecycleRecorder {
async fn on_thread_stop(&self, input: codex_extension_api::ThreadStopInput<'_>) {
assert_eq!(
self.expected_thread_id.to_string(),
input.thread_store.level_id()
);
self.calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push("thread_stop");
fn on_thread_stop<'a>(
&'a self,
input: codex_extension_api::ThreadStopInput<'a>,
) -> codex_extension_api::ExtensionFuture<'a, ()> {
Box::pin(async move {
assert_eq!(
self.expected_thread_id.to_string(),
input.thread_store.level_id()
);
self.calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push("thread_stop");
})
}
}
#[async_trait::async_trait]
impl codex_extension_api::TurnLifecycleContributor for LifecycleRecorder {
async fn on_turn_abort(&self, input: codex_extension_api::TurnAbortInput<'_>) {
assert_eq!(
self.expected_thread_id.to_string(),
input.thread_store.level_id()
);
assert_eq!(self.expected_turn_id, input.turn_store.level_id());
assert_eq!(TurnAbortReason::Interrupted, input.reason);
self.calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push("turn_abort");
fn on_turn_abort<'a>(
&'a self,
input: codex_extension_api::TurnAbortInput<'a>,
) -> codex_extension_api::ExtensionFuture<'a, ()> {
Box::pin(async move {
assert_eq!(
self.expected_thread_id.to_string(),
input.thread_store.level_id()
);
assert_eq!(self.expected_turn_id, input.turn_store.level_id());
assert_eq!(TurnAbortReason::Interrupted, input.reason);
self.calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push("turn_abort");
})
}
}
@@ -6867,7 +7018,7 @@ where
);
let services = SessionServices {
mcp_connection_manager: Arc::new(RwLock::new(
mcp_connection_manager: Arc::new(arc_swap::ArcSwap::from_pointee(
McpConnectionManager::new_uninitialized_with_permission_profile(
&config.permissions.approval_policy,
config.permissions.permission_profile(),
@@ -7312,6 +7463,7 @@ async fn build_settings_update_items_uses_previous_turn_settings_for_realtime_en
previous_context_item.realtime_active = None;
let previous_turn_settings = PreviousTurnSettings {
model: previous_context.model_info.slug.clone(),
comp_hash: None,
realtime_active: Some(true),
};
let mut current_context = previous_context
@@ -7905,6 +8057,7 @@ async fn build_initial_context_uses_previous_turn_settings_for_realtime_end() {
let (session, turn_context) = make_session_and_context().await;
let previous_turn_settings = PreviousTurnSettings {
model: turn_context.model_info.slug.clone(),
comp_hash: None,
realtime_active: Some(true),
};
@@ -7927,6 +8080,7 @@ async fn build_initial_context_restates_realtime_start_when_reference_context_is
turn_context.realtime_active = true;
let previous_turn_settings = PreviousTurnSettings {
model: turn_context.model_info.slug.clone(),
comp_hash: None,
realtime_active: Some(true),
};
@@ -7960,6 +8114,18 @@ fn file_system_policy_with_unreadable_glob(turn_context: &TurnContext) -> FileSy
policy
}
#[tokio::test]
async fn turn_context_item_uses_turn_context_comp_hash_snapshot() {
let (_session, mut turn_context) = make_session_and_context().await;
turn_context.comp_hash = Some("turn-context-hash".to_string());
turn_context.model_info.comp_hash = Some("model-info-hash".to_string());
assert_eq!(
turn_context.to_turn_context_item().comp_hash.as_deref(),
Some("turn-context-hash")
);
}
#[tokio::test]
async fn turn_context_item_omits_legacy_equivalent_file_system_sandbox_policy() {
let (_session, turn_context) = make_session_and_context().await;
@@ -8151,6 +8317,7 @@ async fn build_initial_context_prepends_model_switch_message() {
let (session, turn_context) = make_session_and_context().await;
let previous_turn_settings = PreviousTurnSettings {
model: "previous-regular-model".to_string(),
comp_hash: None,
realtime_active: None,
};
@@ -8203,6 +8370,7 @@ async fn record_context_updates_and_set_reference_context_item_persists_full_rei
session
.set_previous_turn_settings(Some(PreviousTurnSettings {
model: previous_context.model_info.slug.clone(),
comp_hash: None,
realtime_active: Some(previous_context.realtime_active),
}))
.await;
@@ -8698,15 +8866,19 @@ async fn task_finish_emits_thread_idle_lifecycle_after_active_turn_clears() {
expected_thread_id: ThreadId,
}
#[async_trait::async_trait]
impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for ThreadIdleRecorder {
async fn on_thread_idle(&self, input: codex_extension_api::ThreadIdleInput<'_>) {
assert_eq!(
self.expected_thread_id.to_string(),
input.thread_store.level_id()
);
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.idle_tx.send(()).await.expect("idle receiver open");
fn on_thread_idle<'a>(
&'a self,
input: codex_extension_api::ThreadIdleInput<'a>,
) -> codex_extension_api::ExtensionFuture<'a, ()> {
Box::pin(async move {
assert_eq!(
self.expected_thread_id.to_string(),
input.thread_store.level_id()
);
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.idle_tx.send(()).await.expect("idle receiver open");
})
}
}
@@ -8740,10 +8912,14 @@ async fn thread_idle_lifecycle_waits_for_trigger_turn_mailbox_work() {
calls: Arc<std::sync::atomic::AtomicUsize>,
}
#[async_trait::async_trait]
impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for ThreadIdleRecorder {
async fn on_thread_idle(&self, _input: codex_extension_api::ThreadIdleInput<'_>) {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
fn on_thread_idle<'a>(
&'a self,
_input: codex_extension_api::ThreadIdleInput<'a>,
) -> codex_extension_api::ExtensionFuture<'a, ()> {
Box::pin(async move {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
})
}
}
@@ -9405,18 +9581,13 @@ async fn abort_review_task_emits_exited_then_aborted_and_records_history() {
}
#[tokio::test]
#[expect(
clippy::await_holding_invalid_type,
reason = "test builds a router from session-owned MCP manager state"
)]
async fn fatal_tool_error_stops_turn_and_reports_error() {
let (session, turn_context, _rx) = make_session_and_context_with_rx().await;
let tools = {
session
.services
.mcp_connection_manager
.read()
.await
.load_full()
.list_all_tools()
.await
};
@@ -9552,6 +9723,7 @@ async fn sample_rollout(
rollout_items.push(RolloutItem::Compacted(CompactedItem {
message: summary1.to_string(),
replacement_history: None,
window_id: None,
}));
let user2 = ResponseItem::Message {
@@ -9592,6 +9764,7 @@ async fn sample_rollout(
rollout_items.push(RolloutItem::Compacted(CompactedItem {
message: summary2.to_string(),
replacement_history: None,
window_id: None,
}));
let user3 = ResponseItem::Message {
+44
View File
@@ -0,0 +1,44 @@
use super::session::Session;
use super::turn_context::TurnContext;
use crate::context::ContextualUserFragment;
use codex_features::Feature;
const TOKEN_BUDGET_USAGE_THRESHOLDS: [i64; 3] = [25, 50, 75];
pub(super) async fn maybe_record_token_budget_remaining_context(
sess: &Session,
turn_context: &TurnContext,
tokens_before_sampling: i64,
tokens_after_sampling: i64,
) {
if !turn_context.features.enabled(Feature::TokenBudget) {
return;
}
let Some(model_context_window) = turn_context.model_context_window() else {
return;
};
if model_context_window <= 0 || tokens_after_sampling <= tokens_before_sampling {
return;
}
let tokens_before_sampling = tokens_before_sampling.max(0);
let tokens_after_sampling = tokens_after_sampling.max(0);
let crossed_threshold = TOKEN_BUDGET_USAGE_THRESHOLDS.iter().any(|threshold| {
tokens_before_sampling.saturating_mul(100) < model_context_window.saturating_mul(*threshold)
&& tokens_after_sampling.saturating_mul(100)
>= model_context_window.saturating_mul(*threshold)
});
if !crossed_threshold {
return;
}
let tokens_left = model_context_window
.saturating_sub(tokens_after_sampling)
.max(0);
let response_item = ContextualUserFragment::into(
crate::context::TokenBudgetRemainingContext::new(tokens_left),
);
sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
.await;
}
+110 -59
View File
@@ -172,6 +172,7 @@ pub(crate) async fn run_turn(
.await;
sess.set_previous_turn_settings(Some(PreviousTurnSettings {
model: turn_context.model_info.slug.clone(),
comp_hash: turn_context.comp_hash.clone(),
realtime_active: Some(turn_context.realtime_active),
}))
.await;
@@ -186,10 +187,9 @@ pub(crate) async fn run_turn(
let mut stop_hook_active = false;
// Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains
// many turns, from the perspective of the user, it is a single turn.
let display_roots = turn_diff_display_roots(turn_context.as_ref()).await;
let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(
TurnDiffTracker::with_environment_display_roots(
turn_diff_display_roots(turn_context.as_ref()).await,
),
TurnDiffTracker::with_environment_display_roots(display_roots),
));
// `ModelClientSession` is turn-scoped and caches WebSocket + sticky routing state, so we reuse
@@ -214,22 +214,26 @@ pub(crate) async fn run_turn(
}
// Construct the input that we will send to the model.
let sampling_request_input: Vec<ResponseItem> = {
let sampling_request_input: Vec<ResponseItem> = async {
sess.clone_history()
.await
.for_prompt(&turn_context.model_info.input_modalities)
};
}
.instrument(trace_span!("run_turn.prepare_sampling_request_input"))
.await;
let window_id = sess.services.model_client.current_window_id();
let window_id = sess.current_window_id().await;
let turn_metadata_header = turn_context
.turn_metadata_state
.current_header_value_for_model_request(&window_id);
let tokens_before_sampling = sess.get_total_token_usage().await;
match run_sampling_request(
Arc::clone(&sess),
Arc::clone(&turn_context),
Arc::clone(&turn_extension_data),
Arc::clone(&turn_diff_tracker),
&mut client_session,
&window_id,
turn_metadata_header.as_deref(),
sampling_request_input.clone(),
cancellation_token.child_token(),
@@ -242,15 +246,20 @@ pub(crate) async fn run_turn(
last_agent_message: sampling_request_last_agent_message,
} = sampling_request_output;
can_drain_pending_input = true;
let has_pending_input = sess.input_queue.has_pending_input(&sess.active_turn).await;
let (has_pending_input, token_status, estimated_token_count) = async {
let has_pending_input =
sess.input_queue.has_pending_input(&sess.active_turn).await;
let token_status =
auto_compact_token_status(sess.as_ref(), turn_context.as_ref()).await;
let estimated_token_count =
sess.get_estimated_token_count(turn_context.as_ref()).await;
(has_pending_input, token_status, estimated_token_count)
}
.instrument(trace_span!("run_turn.collect_post_sampling_state"))
.await;
let needs_follow_up = model_needs_follow_up || has_pending_input;
let token_status =
auto_compact_token_status(sess.as_ref(), turn_context.as_ref()).await;
let token_limit_reached = token_status.token_limit_reached;
let estimated_token_count =
sess.get_estimated_token_count(turn_context.as_ref()).await;
trace!(
turn_id = %turn_context.sub_id,
total_usage_tokens = token_status.active_context_tokens,
@@ -258,7 +267,6 @@ pub(crate) async fn run_turn(
estimated_token_count = ?estimated_token_count,
auto_compact_scope_limit = token_status.auto_compact_scope_limit,
auto_compact_limit_scope = ?turn_context.config.model_auto_compact_token_limit_scope,
auto_compact_window_ordinal = ?token_status.auto_compact_window_ordinal,
auto_compact_window_prefill_tokens = ?token_status.auto_compact_window_prefill_tokens,
full_context_window_limit = ?token_status.full_context_window_limit,
full_context_window_limit_reached = token_status.full_context_window_limit_reached,
@@ -269,6 +277,24 @@ pub(crate) async fn run_turn(
"post sampling token usage"
);
let tokens_after_sampling = token_status.active_context_tokens;
super::token_budget::maybe_record_token_budget_remaining_context(
sess.as_ref(),
turn_context.as_ref(),
tokens_before_sampling,
tokens_after_sampling,
)
.await;
let started_new_context_window = sess
.maybe_start_new_context_window(turn_context.as_ref())
.await
.is_some();
if started_new_context_window && needs_follow_up {
can_drain_pending_input = !model_needs_follow_up;
continue;
}
// as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop.
if token_limit_reached && needs_follow_up {
if let Err(err) = run_auto_compact(
@@ -381,6 +407,7 @@ pub(crate) async fn run_turn(
last_agent_message
}
#[instrument(level = "trace", skip_all)]
async fn turn_diff_display_roots(turn_context: &TurnContext) -> Vec<(String, PathBuf)> {
let mut display_roots = Vec::new();
for turn_environment in &turn_context.environments.turn_environments {
@@ -396,6 +423,7 @@ async fn turn_diff_display_roots(turn_context: &TurnContext) -> Vec<(String, Pat
display_roots
}
#[instrument(level = "trace", skip_all)]
async fn run_hooks_and_record_inputs(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
@@ -424,10 +452,7 @@ async fn run_hooks_and_record_inputs(
blocked_input && !accepted_user_input
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP tool listing borrows the read guard across cancellation-aware await"
)]
#[instrument(level = "trace", skip_all)]
async fn build_skills_and_plugins(
sess: &Arc<Session>,
turn_context: &TurnContext,
@@ -464,8 +489,7 @@ async fn build_skills_and_plugins(
match sess
.services
.mcp_connection_manager
.read()
.await
.load_full()
.list_all_tools()
.or_cancel(cancellation_token)
.await
@@ -698,7 +722,6 @@ struct AutoCompactTokenStatus {
auto_compact_scope_tokens: i64,
auto_compact_scope_limit: i64,
full_context_window_limit: Option<i64>,
auto_compact_window_ordinal: Option<u64>,
auto_compact_window_prefill_tokens: Option<i64>,
full_context_window_limit_reached: bool,
token_limit_reached: bool,
@@ -709,7 +732,6 @@ async fn auto_compact_token_status(
turn_context: &TurnContext,
) -> AutoCompactTokenStatus {
let active_context_tokens = sess.get_total_token_usage().await;
let mut auto_compact_window_ordinal = None;
let mut auto_compact_window_prefill_tokens = None;
let (auto_compact_scope_tokens, auto_compact_scope_limit, full_context_window_limit) =
match turn_context.config.model_auto_compact_token_limit_scope {
@@ -723,7 +745,6 @@ async fn auto_compact_token_status(
),
AutoCompactTokenLimitScope::BodyAfterPrefix => {
let window = sess.auto_compact_window_snapshot().await;
auto_compact_window_ordinal = Some(window.ordinal);
auto_compact_window_prefill_tokens = window.prefill_input_tokens;
let baseline = window.prefill_input_tokens.unwrap_or(active_context_tokens);
(
@@ -749,13 +770,13 @@ async fn auto_compact_token_status(
auto_compact_scope_tokens,
auto_compact_scope_limit,
full_context_window_limit,
auto_compact_window_ordinal,
auto_compact_window_prefill_tokens,
full_context_window_limit_reached,
token_limit_reached,
}
}
#[instrument(level = "trace", skip_all)]
async fn run_pre_sampling_compact(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
@@ -778,8 +799,16 @@ async fn run_pre_sampling_compact(
Ok(())
}
/// Runs pre-sampling compaction against the previous model when switching to a smaller
/// context-window model.
/// Returns true only when both turns declare compaction compatibility hashes and they differ.
/// A missing hash does not provide enough information to trigger compaction.
fn comp_hash_changed(previous: Option<&str>, current: Option<&str>) -> bool {
previous
.zip(current)
.is_some_and(|(previous, current)| previous != current)
}
/// Runs pre-sampling compaction against the previous model when its compaction compatibility
/// hash changed or when switching to a smaller context-window model.
///
/// Returns `Err(_)` only when compaction was attempted and failed.
async fn maybe_run_previous_model_inline_compact(
@@ -790,12 +819,29 @@ async fn maybe_run_previous_model_inline_compact(
let Some(previous_turn_settings) = sess.previous_turn_settings().await else {
return Ok(());
};
let should_compact_for_comp_hash_change = comp_hash_changed(
previous_turn_settings.comp_hash.as_deref(),
turn_context.comp_hash.as_deref(),
);
let previous_model_turn_context = Arc::new(
turn_context
.with_model(previous_turn_settings.model, &sess.services.models_manager)
.await,
);
if should_compact_for_comp_hash_change {
run_auto_compact(
sess,
&previous_model_turn_context,
client_session,
InitialContextInjection::DoNotInject,
CompactionReason::CompHashChanged,
CompactionPhase::PreTurn,
)
.await?;
return Ok(());
}
let Some(old_context_window) = previous_model_turn_context.model_context_window() else {
return Ok(());
};
@@ -834,6 +880,11 @@ async fn maybe_run_previous_model_inline_compact(
Ok(())
}
#[instrument(
level = "trace",
skip_all,
fields(reason = ?reason, phase = ?phase)
)]
async fn run_auto_compact(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
@@ -942,6 +993,7 @@ pub(super) fn collect_explicit_app_ids_from_skill_items(
connector_ids
}
#[instrument(level = "trace", skip_all)]
pub(crate) fn build_prompt(
input: Vec<ResponseItem>,
router: &ToolRouter,
@@ -977,6 +1029,7 @@ async fn run_sampling_request(
turn_store: Arc<codex_extension_api::ExtensionData>,
turn_diff_tracker: SharedTurnDiffTracker,
client_session: &mut ModelClientSession,
window_id: &str,
turn_metadata_header: Option<&str>,
input: Vec<ResponseItem>,
cancellation_token: CancellationToken,
@@ -1020,6 +1073,7 @@ async fn run_sampling_request(
Arc::clone(&turn_context),
Arc::clone(&turn_store),
client_session,
window_id,
turn_metadata_header,
Arc::clone(&turn_diff_tracker),
&prompt,
@@ -1062,10 +1116,6 @@ async fn run_sampling_request(
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "tool router construction reads through the session-owned manager guard"
)]
#[instrument(level = "trace",
skip_all,
fields(
@@ -1079,22 +1129,17 @@ pub(crate) async fn built_tools(
turn_context: &TurnContext,
cancellation_token: &CancellationToken,
) -> CodexResult<Arc<ToolRouter>> {
let mcp_connection_manager = sess
.services
.mcp_connection_manager
.read()
.instrument(trace_span!("read_mcp_connection_manager"))
.await;
let mcp_connection_manager = sess.services.mcp_connection_manager.load_full();
let has_mcp_servers = mcp_connection_manager.has_servers();
let all_mcp_tools = mcp_connection_manager
.list_all_tools()
.or_cancel(cancellation_token)
.await?;
drop(mcp_connection_manager);
let loaded_plugins = sess
.services
.plugins_manager
.plugins_for_config(&turn_context.config.plugins_config_input())
.instrument(trace_span!("built_tools.load_plugins"))
.await;
let apps_enabled = turn_context.apps_enabled();
@@ -1125,35 +1170,39 @@ pub(crate) async fn built_tools(
.into_iter()
.map(|connector_id| connector_id.0)
.collect::<Vec<_>>();
let discoverable_tools = if apps_enabled && tool_suggest_enabled(turn_context) {
if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() {
match connectors::list_tool_suggest_discoverable_tools_with_auth(
&turn_context.config,
sess.services.plugins_manager.as_ref(),
auth.as_ref(),
accessible_connectors.as_slice(),
&loaded_plugin_app_connector_ids,
)
.await
.map(|discoverable_tools| {
filter_request_plugin_install_discoverable_tools_for_client(
discoverable_tools,
turn_context.app_server_client_name.as_deref(),
let discoverable_tools = async {
if apps_enabled && tool_suggest_enabled(turn_context) {
if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() {
match connectors::list_tool_suggest_discoverable_tools_with_auth(
&turn_context.config,
sess.services.plugins_manager.as_ref(),
auth.as_ref(),
accessible_connectors.as_slice(),
&loaded_plugin_app_connector_ids,
)
}) {
Ok(discoverable_tools) if discoverable_tools.is_empty() => None,
Ok(discoverable_tools) => Some(discoverable_tools),
Err(err) => {
warn!("failed to load discoverable tool suggestions: {err:#}");
None
.await
.map(|discoverable_tools| {
filter_request_plugin_install_discoverable_tools_for_client(
discoverable_tools,
turn_context.app_server_client_name.as_deref(),
)
}) {
Ok(discoverable_tools) if discoverable_tools.is_empty() => None,
Ok(discoverable_tools) => Some(discoverable_tools),
Err(err) => {
warn!("failed to load discoverable tool suggestions: {err:#}");
None
}
}
} else {
None
}
} else {
None
}
} else {
None
};
}
.instrument(trace_span!("built_tools.load_discoverable_tools"))
.await;
let mcp_tool_exposure = build_mcp_tool_exposure(
&all_mcp_tools,
@@ -1754,6 +1803,7 @@ async fn try_run_sampling_request(
turn_context: Arc<TurnContext>,
turn_store: Arc<codex_extension_api::ExtensionData>,
client_session: &mut ModelClientSession,
window_id: &str,
turn_metadata_header: Option<&str>,
turn_diff_tracker: SharedTurnDiffTracker,
prompt: &Prompt,
@@ -1775,6 +1825,7 @@ async fn try_run_sampling_request(
let sampling_timing_guard = turn_context.turn_timing_state.begin_sampling();
let mut stream = client_session
.stream(
window_id,
prompt,
&turn_context.model_info,
&turn_context.session_telemetry,
+5 -1
View File
@@ -61,6 +61,7 @@ pub struct TurnContext {
pub config: Arc<Config>,
pub(crate) auth_manager: Option<Arc<AuthManager>>,
pub(crate) model_info: ModelInfo,
pub(crate) comp_hash: Option<String>,
pub(crate) tool_mode: ToolMode,
pub(crate) session_telemetry: SessionTelemetry,
pub(crate) provider: SharedModelProvider,
@@ -229,6 +230,7 @@ impl TurnContext {
config: Arc::new(config),
auth_manager: self.auth_manager.clone(),
model_info: model_info.clone(),
comp_hash: model_info.comp_hash.clone(),
tool_mode,
session_telemetry: self
.session_telemetry
@@ -355,6 +357,7 @@ impl TurnContext {
network: self.turn_context_network_item(),
file_system_sandbox_policy: self.non_legacy_file_system_sandbox_policy(),
model: self.model_info.slug.clone(),
comp_hash: self.comp_hash.clone(),
personality: self.personality,
collaboration_mode: Some(self.collaboration_mode.clone()),
multi_agent_version: Some(self.multi_agent_version),
@@ -531,6 +534,7 @@ impl Session {
config: per_turn_config.clone(),
auth_manager: auth_manager_for_context,
model_info: model_info.clone(),
comp_hash: model_info.comp_hash.clone(),
tool_mode,
session_telemetry: session_telemetry_for_context,
provider: provider_for_context,
@@ -724,7 +728,7 @@ impl Session {
.unwrap_or_else(|| session_configuration.cwd().clone());
let per_turn_config = Self::build_per_turn_config(&session_configuration, cwd.clone());
{
let mcp_connection_manager = self.services.mcp_connection_manager.read().await;
let mcp_connection_manager = self.services.mcp_connection_manager.load_full();
mcp_connection_manager.set_approval_policy(&session_configuration.approval_policy);
mcp_connection_manager
.set_permission_profile(session_configuration.permission_profile());
+14 -13
View File
@@ -7,20 +7,21 @@ use std::sync::Arc;
struct RewriteAgentMessageContributor;
#[async_trait::async_trait]
impl TurnItemContributor for RewriteAgentMessageContributor {
async fn contribute(
&self,
_thread_store: &ExtensionData,
_turn_store: &ExtensionData,
item: &mut TurnItem,
) -> Result<(), String> {
if let TurnItem::AgentMessage(agent_message) = item {
agent_message.content = vec![AgentMessageContent::Text {
text: "plan contributed assistant text".to_string(),
}];
}
Ok(())
fn contribute<'a>(
&'a self,
_thread_store: &'a ExtensionData,
_turn_store: &'a ExtensionData,
item: &'a mut TurnItem,
) -> codex_extension_api::ExtensionFuture<'a, Result<(), String>> {
Box::pin(async move {
if let TurnItem::AgentMessage(agent_message) = item {
agent_message.content = vec![AgentMessageContent::Text {
text: "plan contributed assistant text".to_string(),
}];
}
Ok(())
})
}
}
+10 -3
View File
@@ -4,6 +4,7 @@ use std::time::Instant;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tokio_util::task::AbortOnDropHandle;
use tracing::info;
use tracing::warn;
@@ -19,7 +20,7 @@ use codex_protocol::error::Result as CodexResult;
use codex_protocol::models::BaseInstructions;
pub(crate) struct SessionStartupPrewarmHandle {
task: JoinHandle<CodexResult<ModelClientSession>>,
task: AbortOnDropHandle<CodexResult<ModelClientSession>>,
started_at: Instant,
timeout: Duration,
}
@@ -40,12 +41,17 @@ impl SessionStartupPrewarmHandle {
timeout: Duration,
) -> Self {
Self {
task,
task: AbortOnDropHandle::new(task),
started_at,
timeout,
}
}
pub(crate) async fn abort(self) {
self.task.abort();
let _ = self.task.await;
}
async fn resolve(
self,
session_telemetry: &SessionTelemetry,
@@ -260,7 +266,7 @@ async fn schedule_startup_prewarm_inner(
build_prompt_started_at.elapsed(),
/*status*/ None,
);
let window_id = session.services.model_client.current_window_id();
let window_id = session.current_window_id().await;
let startup_turn_metadata_header = startup_turn_context
.turn_metadata_state
.current_header_value_for_prewarm(&window_id);
@@ -268,6 +274,7 @@ async fn schedule_startup_prewarm_inner(
let websocket_warmup_started_at = Instant::now();
client_session
.prewarm_websocket(
&window_id,
&startup_prompt,
&startup_turn_context.model_info,
&startup_turn_context.session_telemetry,
+37 -20
View File
@@ -2,7 +2,6 @@ use codex_protocol::protocol::TokenUsage;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AutoCompactWindowSnapshot {
pub(crate) ordinal: u64,
pub(crate) prefill_input_tokens: Option<i64>,
}
@@ -14,7 +13,8 @@ enum AutoCompactWindowPrefill {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct AutoCompactWindow {
ordinal: u64,
window_id: u64,
new_context_window_requested: bool,
/// Absolute input-token baseline for the current compaction window.
///
/// `body_after_prefix` subtracts this from later active-context usage. It is
@@ -26,7 +26,8 @@ pub(super) struct AutoCompactWindow {
impl AutoCompactWindow {
pub(super) fn new() -> Self {
Self {
ordinal: 1,
window_id: 0,
new_context_window_requested: false,
prefill_input_tokens: None,
}
}
@@ -35,9 +36,28 @@ impl AutoCompactWindow {
self.prefill_input_tokens = None;
}
pub(super) fn start_next(&mut self) {
self.ordinal = self.ordinal.saturating_add(1);
self.clear_prefill();
pub(super) fn window_id(&self) -> u64 {
self.window_id
}
pub(super) fn set_window_id(&mut self, window_id: u64) {
self.window_id = window_id;
}
pub(super) fn advance_window_id(&mut self) -> u64 {
self.window_id = self.window_id.saturating_add(1);
self.new_context_window_requested = false;
self.window_id
}
pub(super) fn request_new_context_window(&mut self) {
self.new_context_window_requested = true;
}
pub(super) fn take_new_context_window_request(&mut self) -> bool {
let requested = self.new_context_window_requested;
self.new_context_window_requested = false;
requested
}
/// Records the request-input side of the first server usage sample. The
@@ -74,7 +94,6 @@ impl AutoCompactWindow {
None => None,
};
AutoCompactWindowSnapshot {
ordinal: self.ordinal,
prefill_input_tokens,
}
}
@@ -89,10 +108,20 @@ mod tests {
fn tracks_prefill_and_window_boundaries() {
let mut window = AutoCompactWindow::new();
assert_eq!(window.window_id(), 0);
window.set_window_id(/*window_id*/ 3);
assert_eq!(window.window_id(), 3);
window.request_new_context_window();
assert!(window.take_new_context_window_request());
assert!(!window.take_new_context_window_request());
window.request_new_context_window();
assert_eq!(window.advance_window_id(), 4);
assert_eq!(window.window_id(), 4);
assert!(!window.take_new_context_window_request());
assert_eq!(
window.snapshot(),
AutoCompactWindowSnapshot {
ordinal: 1,
prefill_input_tokens: None,
}
);
@@ -101,7 +130,6 @@ mod tests {
assert_eq!(
window.snapshot(),
AutoCompactWindowSnapshot {
ordinal: 1,
prefill_input_tokens: Some(150),
}
);
@@ -114,7 +142,6 @@ mod tests {
assert_eq!(
window.snapshot(),
AutoCompactWindowSnapshot {
ordinal: 1,
prefill_input_tokens: Some(120),
}
);
@@ -128,18 +155,8 @@ mod tests {
assert_eq!(
window.snapshot(),
AutoCompactWindowSnapshot {
ordinal: 1,
prefill_input_tokens: Some(120),
}
);
window.start_next();
assert_eq!(
window.snapshot(),
AutoCompactWindowSnapshot {
ordinal: 2,
prefill_input_tokens: None,
}
);
}
}
+18 -2
View File
@@ -15,6 +15,7 @@ use crate::tools::code_mode::CodeModeService;
use crate::tools::network_approval::NetworkApprovalService;
use crate::tools::sandboxing::ApprovalStore;
use crate::unified_exec::UnifiedExecProcessManager;
use anyhow::Result;
use arc_swap::ArcSwap;
use arc_swap::ArcSwapOption;
use codex_analytics::AnalyticsEventsClient;
@@ -34,12 +35,12 @@ use codex_thread_store::ThreadStore;
use std::path::PathBuf;
use tokio::runtime::Handle;
use tokio::sync::Mutex;
use tokio::sync::RwLock;
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
pub(crate) struct SessionServices {
pub(crate) mcp_connection_manager: Arc<RwLock<McpConnectionManager>>,
/// The latest manager; callers retain an owned handle while performing MCP I/O.
pub(crate) mcp_connection_manager: Arc<ArcSwap<McpConnectionManager>>,
pub(crate) mcp_startup_cancellation_token: Mutex<CancellationToken>,
pub(crate) unified_exec_manager: UnifiedExecProcessManager,
#[cfg_attr(not(unix), allow(dead_code))]
@@ -82,3 +83,18 @@ pub(crate) struct SessionServices {
/// the same manager through child-thread spawn paths without reconstructing it.
pub(crate) environment_manager: Arc<EnvironmentManager>,
}
impl SessionServices {
/// Installs the manager before validating required servers so startup-time elicitation can
/// resolve through the session's manager while validation waits.
pub(crate) async fn install_mcp_connection_manager(
&self,
manager: McpConnectionManager,
) -> Result<()> {
self.mcp_connection_manager.store(Arc::new(manager));
self.mcp_connection_manager
.load_full()
.validate_required_servers()
.await
}
}
+26 -4
View File
@@ -140,14 +140,36 @@ impl SessionState {
self.auto_compact_window.set_estimated_prefill(tokens);
}
pub(crate) fn start_next_auto_compact_window(&mut self) {
self.auto_compact_window.start_next();
}
pub(crate) fn auto_compact_window_snapshot(&self) -> AutoCompactWindowSnapshot {
self.auto_compact_window.snapshot()
}
pub(crate) fn auto_compact_window_id(&self) -> u64 {
self.auto_compact_window.window_id()
}
pub(crate) fn set_auto_compact_window_id(&mut self, window_id: u64) {
self.auto_compact_window.set_window_id(window_id);
}
pub(crate) fn advance_auto_compact_window_id(&mut self) -> u64 {
self.auto_compact_window.advance_window_id()
}
pub(crate) fn request_new_context_window(&mut self) {
self.auto_compact_window.request_new_context_window();
}
pub(crate) fn start_new_context_window_if_requested(&mut self) -> Option<u64> {
if !self.auto_compact_window.take_new_context_window_request() {
return None;
}
let window_id = self.auto_compact_window.advance_window_id();
self.auto_compact_window.clear_prefill();
Some(window_id)
}
pub(crate) fn token_info(&self) -> Option<TokenUsageInfo> {
self.history.token_info()
}
+1 -3
View File
@@ -65,18 +65,16 @@ async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() {
}
#[tokio::test]
async fn replace_history_clears_auto_compact_window_prefill_without_advancing() {
async fn replace_history_clears_auto_compact_window_prefill() {
let session_configuration = make_session_configuration_for_tests().await;
let mut state = SessionState::new(session_configuration);
state.start_next_auto_compact_window();
state.set_auto_compact_window_estimated_prefill(/*tokens*/ 100);
state.replace_history(Vec::new(), /*reference_context_item*/ None);
assert_eq!(
state.auto_compact_window_snapshot(),
AutoCompactWindowSnapshot {
ordinal: 2,
prefill_input_tokens: None,
}
);
+30 -28
View File
@@ -167,41 +167,43 @@ struct TestTurnItemContributor;
#[derive(Debug)]
struct TurnItemContributorRan;
#[async_trait::async_trait]
impl TurnItemContributor for TestTurnItemContributor {
async fn contribute(
&self,
_thread_store: &ExtensionData,
turn_store: &ExtensionData,
item: &mut TurnItem,
) -> Result<(), String> {
turn_store.insert(TurnItemContributorRan);
if let TurnItem::AgentMessage(agent_message) = item {
agent_message.memory_citation = Some(MemoryCitation {
entries: Vec::new(),
rollout_ids: Vec::new(),
});
}
Ok(())
fn contribute<'a>(
&'a self,
_thread_store: &'a ExtensionData,
turn_store: &'a ExtensionData,
item: &'a mut TurnItem,
) -> codex_extension_api::ExtensionFuture<'a, Result<(), String>> {
Box::pin(async move {
turn_store.insert(TurnItemContributorRan);
if let TurnItem::AgentMessage(agent_message) = item {
agent_message.memory_citation = Some(MemoryCitation {
entries: Vec::new(),
rollout_ids: Vec::new(),
});
}
Ok(())
})
}
}
struct RewriteAgentMessageContributor;
#[async_trait::async_trait]
impl TurnItemContributor for RewriteAgentMessageContributor {
async fn contribute(
&self,
_thread_store: &ExtensionData,
_turn_store: &ExtensionData,
item: &mut TurnItem,
) -> Result<(), String> {
if let TurnItem::AgentMessage(agent_message) = item {
agent_message.content = vec![AgentMessageContent::Text {
text: "contributed assistant text".to_string(),
}];
}
Ok(())
fn contribute<'a>(
&'a self,
_thread_store: &'a ExtensionData,
_turn_store: &'a ExtensionData,
item: &'a mut TurnItem,
) -> codex_extension_api::ExtensionFuture<'a, Result<(), String>> {
Box::pin(async move {
if let TurnItem::AgentMessage(agent_message) = item {
agent_message.content = vec![AgentMessageContent::Text {
text: "contributed assistant text".to_string(),
}];
}
Ok(())
})
}
}
+12
View File
@@ -21,6 +21,7 @@ use tracing::info_span;
use tracing::trace;
use tracing::warn;
use crate::codex_thread::BackgroundTerminalInfo;
use crate::config::Config;
use crate::context::ContextualUserFragment;
use crate::hook_runtime::inspect_pending_input;
@@ -782,6 +783,17 @@ impl Session {
.await;
}
pub(crate) async fn list_background_terminals(&self) -> Vec<BackgroundTerminalInfo> {
self.services.unified_exec_manager.list_processes().await
}
pub(crate) async fn terminate_background_terminal(&self, process_id: i32) -> bool {
self.services
.unified_exec_manager
.terminate_process(process_id)
.await
}
async fn handle_task_abort(self: &Arc<Self>, task: RunningTask, reason: TurnAbortReason) {
let sub_id = task.turn_context.sub_id.clone();
if task.cancellation_token.is_cancelled() {
+5 -8
View File
@@ -282,6 +282,7 @@ impl ThreadManager {
codex_home.to_path_buf(),
restriction_product,
));
plugins_manager.set_auth_mode(auth_manager.get_api_auth_mode());
let mcp_manager = Arc::new(McpManager::new_with_extensions(
Arc::clone(&plugins_manager),
Arc::clone(&extensions),
@@ -375,6 +376,7 @@ impl ThreadManager {
codex_home.clone(),
restriction_product,
));
plugins_manager.set_auth_mode(auth_manager.get_api_auth_mode());
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
let skills_manager = Arc::new(SkillsManager::new_with_restriction_product(
skills_codex_home,
@@ -525,14 +527,12 @@ impl ThreadManager {
&self,
thread_id: ThreadId,
) -> CodexResult<Vec<ThreadId>> {
let thread = self.state.get_thread(thread_id).await?;
let mut subtree_thread_ids = Vec::new();
let mut seen_thread_ids = HashSet::new();
subtree_thread_ids.push(thread_id);
seen_thread_ids.insert(thread_id);
if let Some(state_db_ctx) = thread.state_db() {
if let Some(state_db_ctx) = self.state.state_db() {
for status in [
DirectionalThreadSpawnEdgeStatus::Open,
DirectionalThreadSpawnEdgeStatus::Closed,
@@ -551,11 +551,8 @@ impl ThreadManager {
}
}
for descendant_id in thread
.codex
.session
.services
.agent_control
for descendant_id in self
.agent_control()
.list_live_agent_subtree_thread_ids(thread_id)
.await?
{
+17 -13
View File
@@ -394,20 +394,24 @@ async fn start_thread_seeds_extension_data_before_lifecycle_contributors_run() {
observed: Arc<std::sync::Mutex<Option<(String, String)>>>,
}
#[async_trait::async_trait]
impl codex_extension_api::ThreadLifecycleContributor<Config> for InitialDataRecorder {
async fn on_thread_start(&self, input: codex_extension_api::ThreadStartInput<'_, Config>) {
let marker = input
.thread_store
.get::<InitialMarker>()
.expect("initial extension data should be available");
*self
.observed
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some((
input.thread_store.level_id().to_string(),
marker.0.to_string(),
));
fn on_thread_start<'a>(
&'a self,
input: codex_extension_api::ThreadStartInput<'a, Config>,
) -> codex_extension_api::ExtensionFuture<'a, ()> {
Box::pin(async move {
let marker = input
.thread_store
.get::<InitialMarker>()
.expect("initial extension data should be available");
*self
.observed
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some((
input.thread_store.level_id().to_string(),
marker.0.to_string(),
));
})
}
}
@@ -91,7 +91,6 @@ impl CodeModeExecuteHandler {
}
}
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for CodeModeExecuteHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain(PUBLIC_TOOL_NAME)
@@ -101,7 +100,13 @@ impl ToolExecutor<ToolInvocation> for CodeModeExecuteHandler {
self.spec.clone()
}
async fn handle(
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl CodeModeExecuteHandler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
@@ -44,7 +44,6 @@ where
})
}
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for CodeModeWaitHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain(WAIT_TOOL_NAME)
@@ -54,7 +53,13 @@ impl ToolExecutor<ToolInvocation> for CodeModeWaitHandler {
create_wait_tool()
}
async fn handle(
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl CodeModeWaitHandler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
+96 -2
View File
@@ -588,7 +588,7 @@ async fn emit_patch_end(
if let Some(tracker) = ctx.turn_diff_tracker {
let (should_emit_turn_diff, unified_diff) = {
let mut guard = tracker.lock().await;
let previous_diff = guard.get_unified_diff();
let had_unified_diff = guard.has_unified_diff();
let tracker_changed = match tracker_update {
TurnDiffTrackerUpdate::Track {
environment_id,
@@ -605,7 +605,7 @@ async fn emit_patch_end(
};
let unified_diff = guard.get_unified_diff();
(
tracker_changed && (previous_diff.is_some() || unified_diff.is_some()),
tracker_changed && (had_unified_diff || unified_diff.is_some()),
unified_diff.unwrap_or_default(),
)
};
@@ -718,4 +718,98 @@ mod tests {
)
.await;
}
#[tokio::test]
async fn net_zero_patch_emits_empty_turn_diff() {
let (session, turn, rx_event) =
make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await;
let tracker = Arc::new(Mutex::new(TurnDiffTracker::new()));
let dir = tempdir().expect("tempdir");
let cwd = AbsolutePathBuf::from_absolute_path(dir.path()).expect("absolute cwd");
for patch in [
"*** Begin Patch\n*** Add File: a.txt\n+one\n*** End Patch",
"*** Begin Patch\n*** Delete File: a.txt\n*** End Patch",
] {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let delta = codex_apply_patch::apply_patch(
patch,
&cwd,
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.expect("apply patch");
emit_patch_end(
ToolEventCtx::new(session.as_ref(), turn.as_ref(), "call-id", Some(&tracker)),
HashMap::new(),
String::new(),
String::new(),
PatchApplyStatus::Completed,
TurnDiffTrackerUpdate::Track {
environment_id: None,
delta: &delta,
},
)
.await;
rx_event.recv().await.expect("item completed event");
let unified_diff = loop {
let event = rx_event.recv().await.expect("turn diff event");
if let EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) = event.msg {
break unified_diff;
}
};
if patch.contains("Delete File") {
assert_eq!(unified_diff, "");
} else {
assert!(unified_diff.contains("+one"));
}
}
}
#[tokio::test]
async fn invalidation_emits_empty_turn_diff() {
let (session, turn, rx_event) =
make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await;
let tracker = Arc::new(Mutex::new(TurnDiffTracker::new()));
let dir = tempdir().expect("tempdir");
let cwd = AbsolutePathBuf::from_absolute_path(dir.path()).expect("absolute cwd");
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let delta = codex_apply_patch::apply_patch(
"*** Begin Patch\n*** Add File: a.txt\n+one\n*** End Patch",
&cwd,
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.expect("apply patch");
tracker.lock().await.track_delta("", &delta);
emit_patch_end(
ToolEventCtx::new(session.as_ref(), turn.as_ref(), "call-id", Some(&tracker)),
HashMap::new(),
String::new(),
String::new(),
PatchApplyStatus::Completed,
TurnDiffTrackerUpdate::Invalidate,
)
.await;
rx_event.recv().await.expect("item completed event");
loop {
let event = rx_event.recv().await.expect("turn diff event");
if let EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) = event.msg {
assert_eq!(unified_diff, "");
break;
}
}
}
}
@@ -13,7 +13,6 @@ use super::*;
pub struct ReportAgentJobResultHandler;
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for ReportAgentJobResultHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain("report_agent_job_result")
@@ -23,7 +22,13 @@ impl ToolExecutor<ToolInvocation> for ReportAgentJobResultHandler {
create_report_agent_job_result_tool()
}
async fn handle(
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl ReportAgentJobResultHandler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
@@ -14,7 +14,6 @@ use super::*;
pub struct SpawnAgentsOnCsvHandler;
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for SpawnAgentsOnCsvHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain("spawn_agents_on_csv")
@@ -24,7 +23,13 @@ impl ToolExecutor<ToolInvocation> for SpawnAgentsOnCsvHandler {
create_spawn_agents_on_csv_tool()
}
async fn handle(
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl SpawnAgentsOnCsvHandler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
@@ -305,7 +305,6 @@ async fn effective_patch_permissions(
)
}
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for ApplyPatchHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain("apply_patch")
@@ -315,7 +314,13 @@ impl ToolExecutor<ToolInvocation> for ApplyPatchHandler {
create_apply_patch_freeform_tool(self.multi_environment)
}
async fn handle(
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl ApplyPatchHandler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
+8 -30
View File
@@ -33,7 +33,6 @@ pub struct DynamicToolHandler {
tool_name: ToolName,
spec: ToolSpec,
exposure: ToolExposure,
search_text: String,
}
impl DynamicToolHandler {
@@ -56,12 +55,10 @@ impl DynamicToolHandler {
} else {
ToolExposure::Direct
},
search_text: build_dynamic_search_text(tool),
})
}
}
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for DynamicToolHandler {
fn tool_name(&self) -> ToolName {
self.tool_name.clone()
@@ -76,8 +73,7 @@ impl ToolExecutor<ToolInvocation> for DynamicToolHandler {
}
fn search_info(&self) -> Option<ToolSearchInfo> {
ToolSearchInfo::from_spec(
self.search_text.clone(),
ToolSearchInfo::from_tool_spec(
self.spec(),
Some(ToolSearchSourceInfo {
name: "Dynamic tools".to_string(),
@@ -86,7 +82,13 @@ impl ToolExecutor<ToolInvocation> for DynamicToolHandler {
)
}
async fn handle(
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl DynamicToolHandler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
@@ -212,27 +214,3 @@ async fn request_dynamic_tool(
response
}
fn build_dynamic_search_text(tool: &DynamicToolSpec) -> String {
let mut schema_properties = tool
.input_schema
.get("properties")
.and_then(serde_json::Value::as_object)
.map(|map| map.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
schema_properties.sort();
let mut parts = vec![
tool.name.clone(),
tool.name.replace('_', " "),
tool.description.clone(),
];
if let Some(namespace) = &tool.namespace {
parts.push(namespace.clone());
}
parts.extend(schema_properties);
parts.join(" ")
}
#[cfg(test)]
#[path = "dynamic_tests.rs"]
mod tests;
@@ -1,36 +0,0 @@
use super::*;
use codex_tools::ToolSearchSourceInfo;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn search_info_uses_dynamic_tool_metadata_and_parameter_names() {
let handler = DynamicToolHandler::new(&DynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: "automation_update".to_string(),
description: "Create or update automations.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"timezone": { "type": "string" },
"mode": { "type": "string" }
}
}),
defer_loading: true,
})
.expect("dynamic handler should be created");
let search_info = handler.search_info().expect("dynamic search info");
assert_eq!(
search_info.entry.search_text,
"automation_update automation update Create or update automations. codex_app mode timezone"
);
assert_eq!(
search_info.source_info,
Some(ToolSearchSourceInfo {
name: "Dynamic tools".to_string(),
description: Some("Tools provided by the current Codex thread.".to_string()),
})
);
}
@@ -11,13 +11,11 @@ use codex_tools::ToolSpec;
use codex_tools::TurnItemEmissionFuture;
use codex_tools::TurnItemEmitter;
use crate::function_tool::FunctionCallError;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::stream_events_utils::TurnItemContributorPolicy;
use crate::stream_events_utils::finalize_turn_item;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
@@ -30,7 +28,6 @@ impl ExtensionToolAdapter {
}
}
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for ExtensionToolAdapter {
fn tool_name(&self) -> ToolName {
self.0.tool_name()
@@ -52,11 +49,8 @@ impl ToolExecutor<ToolInvocation> for ExtensionToolAdapter {
self.0.search_info()
}
async fn handle(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn ToolOutput>, FunctionCallError> {
self.0.handle(to_extension_call(&invocation).await).await
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(async move { self.0.handle(to_extension_call(&invocation).await).await })
}
}
@@ -162,7 +156,6 @@ mod tests {
struct StubExtensionExecutor;
#[async_trait::async_trait]
impl codex_extension_api::ToolExecutor<codex_tools::ToolCall> for StubExtensionExecutor {
fn tool_name(&self) -> codex_tools::ToolName {
codex_tools::ToolName::plain("extension_echo")
@@ -187,13 +180,13 @@ mod tests {
})
}
async fn handle(
&self,
_call: codex_tools::ToolCall,
) -> Result<Box<dyn codex_tools::ToolOutput>, codex_tools::FunctionCallError> {
Ok(Box::new(codex_tools::JsonToolOutput::new(
json!({ "ok": true }),
)))
fn handle(&self, _call: codex_tools::ToolCall) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(async {
Ok(
Box::new(codex_tools::JsonToolOutput::new(json!({ "ok": true })))
as Box<dyn codex_tools::ToolOutput>,
)
})
}
}
@@ -201,7 +194,6 @@ mod tests {
captured_call: Arc<Mutex<Option<codex_tools::ToolCall>>>,
}
#[async_trait::async_trait]
impl codex_extension_api::ToolExecutor<codex_tools::ToolCall> for CapturingExtensionExecutor {
fn tool_name(&self) -> codex_tools::ToolName {
codex_tools::ToolName::plain("extension_echo")
@@ -218,7 +210,13 @@ mod tests {
})
}
async fn handle(
fn handle(&self, call: codex_tools::ToolCall) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(call))
}
}
impl CapturingExtensionExecutor {
async fn handle_call(
&self,
call: codex_tools::ToolCall,
) -> Result<Box<dyn codex_tools::ToolOutput>, codex_tools::FunctionCallError> {
@@ -233,9 +231,10 @@ mod tests {
call.turn_item_emitter.emit_started(item.clone()).await;
call.turn_item_emitter.emit_completed(item).await;
*self.captured_call.lock().await = Some(call);
Ok(Box::new(codex_tools::JsonToolOutput::new(
json!({ "ok": true }),
)))
Ok(
Box::new(codex_tools::JsonToolOutput::new(json!({ "ok": true })))
as Box<dyn codex_tools::ToolOutput>,
)
}
}
@@ -388,16 +387,17 @@ mod tests {
struct RecordExtensionTurnItemContributor;
#[async_trait::async_trait]
impl TurnItemContributor for RecordExtensionTurnItemContributor {
async fn contribute(
&self,
_thread_store: &ExtensionData,
turn_store: &ExtensionData,
_item: &mut TurnItem,
) -> Result<(), String> {
turn_store.insert(ExtensionTurnItemContributorRan);
Ok(())
fn contribute<'a>(
&'a self,
_thread_store: &'a ExtensionData,
turn_store: &'a ExtensionData,
_item: &'a mut TurnItem,
) -> codex_extension_api::ExtensionFuture<'a, Result<(), String>> {
Box::pin(async move {
turn_store.insert(ExtensionTurnItemContributorRan);
Ok(())
})
}
}
@@ -431,7 +431,6 @@ mod tests {
);
}
#[async_trait::async_trait]
impl codex_extension_api::ToolExecutor<codex_tools::ToolCall> for ImageGenerationExtensionExecutor {
fn tool_name(&self) -> codex_tools::ToolName {
codex_tools::ToolName::namespaced("image_gen", "imagegen")
@@ -448,7 +447,13 @@ mod tests {
})
}
async fn handle(
fn handle(&self, call: codex_tools::ToolCall) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(call))
}
}
impl ImageGenerationExtensionExecutor {
async fn handle_call(
&self,
call: codex_tools::ToolCall,
) -> Result<Box<dyn codex_tools::ToolOutput>, codex_tools::FunctionCallError> {
@@ -474,9 +479,10 @@ mod tests {
},
))
.await;
Ok(Box::new(codex_tools::JsonToolOutput::new(
json!({ "ok": true }),
)))
Ok(
Box::new(codex_tools::JsonToolOutput::new(json!({ "ok": true })))
as Box<dyn codex_tools::ToolOutput>,
)
}
}
@@ -0,0 +1,54 @@
use crate::context::ContextualUserFragment;
use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::get_context_remaining_spec::GET_CONTEXT_REMAINING_TOOL_NAME;
use crate::tools::handlers::get_context_remaining_spec::create_get_context_remaining_tool;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
pub struct GetContextRemainingHandler;
impl ToolExecutor<ToolInvocation> for GetContextRemainingHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain(GET_CONTEXT_REMAINING_TOOL_NAME)
}
fn spec(&self) -> ToolSpec {
create_get_context_remaining_tool()
}
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(async move {
if !matches!(invocation.payload, ToolPayload::Function { .. }) {
return Err(FunctionCallError::RespondToModel(
"get_context_remaining handler received unsupported payload".to_string(),
));
}
let Some(model_context_window) = invocation.turn.model_context_window() else {
let fragment = crate::context::TokenBudgetRemainingContext::unknown().render();
return Ok(boxed_tool_output(FunctionToolOutput::from_text(
fragment,
Some(true),
)));
};
let active_context_tokens = invocation.session.get_total_token_usage().await.max(0);
let tokens_left = model_context_window
.saturating_sub(active_context_tokens)
.max(0);
let fragment = crate::context::TokenBudgetRemainingContext::new(tokens_left).render();
Ok(boxed_tool_output(FunctionToolOutput::from_text(
fragment,
Some(true),
)))
})
}
}
impl CoreToolRuntime for GetContextRemainingHandler {}
@@ -0,0 +1,17 @@
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use std::collections::BTreeMap;
pub(crate) const GET_CONTEXT_REMAINING_TOOL_NAME: &str = "get_context_remaining";
pub fn create_get_context_remaining_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: GET_CONTEXT_REMAINING_TOOL_NAME.to_string(),
description: "Get the remaining tokens in the current context window.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(BTreeMap::new(), /*required*/ None, Some(false.into())),
output_schema: None,
})
}
@@ -54,7 +54,6 @@ impl ListAvailablePluginsToInstallHandler {
}
}
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for ListAvailablePluginsToInstallHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain(LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME)
@@ -68,7 +67,13 @@ impl ToolExecutor<ToolInvocation> for ListAvailablePluginsToInstallHandler {
false
}
async fn handle(
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl ListAvailablePluginsToInstallHandler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
+7 -2
View File
@@ -64,7 +64,6 @@ fn ensure_mcp_prefix(name: &str) -> String {
}
}
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for McpHandler {
fn tool_name(&self) -> ToolName {
self.tool_info.canonical_tool_name()
@@ -113,7 +112,13 @@ impl ToolExecutor<ToolInvocation> for McpHandler {
)
}
async fn handle(
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl McpHandler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
@@ -7,6 +7,8 @@ use codex_protocol::items::McpToolCallItem;
use codex_protocol::items::McpToolCallStatus;
use codex_protocol::items::TurnItem;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::protocol::TruncationPolicy;
use codex_utils_output_truncation::truncate_text;
use rmcp::model::ListResourceTemplatesResult;
use rmcp::model::ListResourcesResult;
use rmcp::model::ReadResourceResult;
@@ -270,7 +272,10 @@ fn normalize_required_string(field: &str, value: String) -> Result<String, Funct
}
}
fn serialize_function_output<T>(payload: T) -> Result<FunctionToolOutput, FunctionCallError>
fn serialize_function_output<T>(
payload: T,
truncation_policy: TruncationPolicy,
) -> Result<FunctionToolOutput, FunctionCallError>
where
T: Serialize,
{
@@ -279,6 +284,9 @@ where
"failed to serialize MCP resource response: {err}"
))
})?;
// Match regular MCP tool outputs by bounding the copy persisted to the
// rollout and injected into model context.
let content = truncate_text(&content, truncation_policy * 1.2);
Ok(FunctionToolOutput::from_text(content, Some(true)))
}
@@ -26,7 +26,6 @@ use super::serialize_function_output;
pub struct ListMcpResourceTemplatesHandler;
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for ListMcpResourceTemplatesHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain("list_mcp_resource_templates")
@@ -40,11 +39,13 @@ impl ToolExecutor<ToolInvocation> for ListMcpResourceTemplatesHandler {
true
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP resource template listing reads through the session-owned manager guard"
)]
async fn handle(
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl ListMcpResourceTemplatesHandler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
@@ -107,8 +108,7 @@ impl ToolExecutor<ToolInvocation> for ListMcpResourceTemplatesHandler {
let templates = session
.services
.mcp_connection_manager
.read()
.await
.load_full()
.list_all_resource_templates()
.await;
Ok(ListResourceTemplatesPayload::from_all_servers(templates))
@@ -117,7 +117,7 @@ impl ToolExecutor<ToolInvocation> for ListMcpResourceTemplatesHandler {
.await;
match payload_result {
Ok(payload) => match serialize_function_output(payload) {
Ok(payload) => match serialize_function_output(payload, turn.truncation_policy) {
Ok(output) => {
let content = function_call_output_content_items_to_text(&output.body)
.unwrap_or_default();
@@ -26,7 +26,6 @@ use super::serialize_function_output;
pub struct ListMcpResourcesHandler;
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for ListMcpResourcesHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain("list_mcp_resources")
@@ -40,11 +39,13 @@ impl ToolExecutor<ToolInvocation> for ListMcpResourcesHandler {
true
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP resource listing reads through the session-owned manager guard"
)]
async fn handle(
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl ListMcpResourcesHandler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
@@ -105,8 +106,7 @@ impl ToolExecutor<ToolInvocation> for ListMcpResourcesHandler {
let resources = session
.services
.mcp_connection_manager
.read()
.await
.load_full()
.list_all_resources()
.await;
Ok(ListResourcesPayload::from_all_servers(resources))
@@ -115,7 +115,7 @@ impl ToolExecutor<ToolInvocation> for ListMcpResourcesHandler {
.await;
match payload_result {
Ok(payload) => match serialize_function_output(payload) {
Ok(payload) => match serialize_function_output(payload, turn.truncation_policy) {
Ok(output) => {
let content = function_call_output_content_items_to_text(&output.body)
.unwrap_or_default();
@@ -26,7 +26,6 @@ use super::serialize_function_output;
pub struct ReadMcpResourceHandler;
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for ReadMcpResourceHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain("read_mcp_resource")
@@ -40,7 +39,13 @@ impl ToolExecutor<ToolInvocation> for ReadMcpResourceHandler {
true
}
async fn handle(
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl ReadMcpResourceHandler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
@@ -93,7 +98,7 @@ impl ToolExecutor<ToolInvocation> for ReadMcpResourceHandler {
.await;
match payload_result {
Ok(payload) => match serialize_function_output(payload) {
Ok(payload) => match serialize_function_output(payload, turn.truncation_policy) {
Ok(output) => {
let content = function_call_output_content_items_to_text(&output.body)
.unwrap_or_default();
@@ -1,6 +1,7 @@
use super::*;
use pretty_assertions::assert_eq;
use rmcp::model::AnnotateAble;
use rmcp::model::ResourceContents;
use serde_json::json;
fn resource(uri: &str, name: &str) -> Resource {
@@ -123,3 +124,39 @@ fn template_with_server_serializes_server_field() {
})
);
}
#[test]
fn serialize_function_output_preserves_small_payload() {
let payload = json!({"server": "hosted", "resources": []});
let expected = serde_json::to_string(&payload).expect("serialize payload");
let output = serialize_function_output(payload, TruncationPolicy::Bytes(1_024))
.expect("serialize function output")
.into_text();
assert_eq!(output, expected);
}
#[test]
fn serialize_function_output_caps_read_resource_payload() {
let truncation_policy = TruncationPolicy::Bytes(8_000);
let payload = ReadResourcePayload {
server: "hosted".to_string(),
uri: "skill://large/SKILL.md".to_string(),
result: ReadResourceResult::new(vec![ResourceContents::TextResourceContents {
uri: "skill://large/SKILL.md".to_string(),
mime_type: Some("text/markdown".to_string()),
text: "x".repeat(16_000),
meta: None,
}]),
};
let serialized = serde_json::to_string(&payload).expect("serialize payload");
let expected = truncate_text(&serialized, truncation_policy * 1.2);
let output = serialize_function_output(payload, truncation_policy)
.expect("serialize bounded function output")
.into_text();
assert_ne!(output, serialized);
assert_eq!(output, expected);
}
+6
View File
@@ -4,6 +4,8 @@ pub(crate) mod apply_patch;
pub(crate) mod apply_patch_spec;
mod dynamic;
pub(crate) mod extension_tools;
mod get_context_remaining;
pub(crate) mod get_context_remaining_spec;
mod list_available_plugins_to_install;
pub(crate) mod list_available_plugins_to_install_spec;
mod mcp;
@@ -13,6 +15,8 @@ pub(crate) mod multi_agents;
pub(crate) mod multi_agents_common;
pub(crate) mod multi_agents_spec;
pub(crate) mod multi_agents_v2;
mod new_context_window;
pub(crate) mod new_context_window_spec;
mod plan;
pub(crate) mod plan_spec;
mod request_permissions;
@@ -51,11 +55,13 @@ pub use apply_patch::ApplyPatchHandler;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::protocol::AskForApproval;
pub use dynamic::DynamicToolHandler;
pub use get_context_remaining::GetContextRemainingHandler;
pub use list_available_plugins_to_install::ListAvailablePluginsToInstallHandler;
pub use mcp::McpHandler;
pub use mcp_resource::ListMcpResourceTemplatesHandler;
pub use mcp_resource::ListMcpResourcesHandler;
pub use mcp_resource::ReadMcpResourceHandler;
pub use new_context_window::NewContextWindowHandler;
pub use plan::PlanHandler;
pub use request_permissions::RequestPermissionsHandler;
pub use request_plugin_install::RequestPluginInstallHandler;
@@ -6,7 +6,6 @@ use codex_tools::ToolSpec;
pub(crate) struct Handler;
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for Handler {
fn tool_name(&self) -> ToolName {
ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "close_agent")
@@ -23,11 +22,8 @@ impl ToolExecutor<ToolInvocation> for Handler {
)
}
async fn handle(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
handle_close_agent(invocation).await.map(boxed_tool_output)
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(async move { handle_close_agent(invocation).await.map(boxed_tool_output) })
}
}
@@ -7,7 +7,6 @@ use std::sync::Arc;
pub(crate) struct Handler;
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for Handler {
fn tool_name(&self) -> ToolName {
ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "resume_agent")
@@ -24,11 +23,8 @@ impl ToolExecutor<ToolInvocation> for Handler {
)
}
async fn handle(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
handle_resume_agent(invocation).await.map(boxed_tool_output)
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(async move { handle_resume_agent(invocation).await.map(boxed_tool_output) })
}
}
@@ -6,7 +6,6 @@ use codex_tools::ToolSpec;
pub(crate) struct Handler;
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for Handler {
fn tool_name(&self) -> ToolName {
ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "send_input")
@@ -23,7 +22,13 @@ impl ToolExecutor<ToolInvocation> for Handler {
)
}
async fn handle(
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl Handler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
@@ -22,7 +22,6 @@ impl Handler {
}
}
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for Handler {
fn tool_name(&self) -> ToolName {
ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "spawn_agent")
@@ -39,11 +38,8 @@ impl ToolExecutor<ToolInvocation> for Handler {
)
}
async fn handle(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
handle_spawn_agent(invocation).await.map(boxed_tool_output)
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(async move { handle_spawn_agent(invocation).await.map(boxed_tool_output) })
}
}
@@ -204,7 +200,7 @@ async fn handle_spawn_agent(
turn.session_telemetry.counter(
"codex.multi_agent.spawn",
/*inc*/ 1,
&[("role", role_tag)],
&[("role", role_tag), ("version", "v1")],
);
Ok(SpawnAgentResult {
@@ -27,7 +27,6 @@ impl Handler {
}
}
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for Handler {
fn tool_name(&self) -> ToolName {
ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "wait_agent")
@@ -44,7 +43,13 @@ impl ToolExecutor<ToolInvocation> for Handler {
)
}
async fn handle(
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl Handler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
@@ -26,7 +26,6 @@ pub struct SpawnAgentToolOptions {
pub hide_agent_type_model_reasoning: bool,
pub include_usage_hint: bool,
pub usage_hint_text: Option<String>,
pub max_concurrent_threads_per_session: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -102,7 +101,6 @@ pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions) -> ToolSpec {
inherited_model_guidance,
options.include_usage_hint,
options.usage_hint_text,
options.max_concurrent_threads_per_session,
),
strict: false,
defer_loading: None,
@@ -722,17 +720,9 @@ fn spawn_agent_tool_description_v2(
inherited_model_guidance: Option<&str>,
include_usage_hint: bool,
usage_hint_text: Option<String>,
max_concurrent_threads_per_session: Option<usize>,
) -> String {
let agent_role_guidance = available_models_description.unwrap_or_default();
let inherited_model_guidance = inherited_model_guidance.unwrap_or_default();
let concurrency_guidance = max_concurrent_threads_per_session
.map(|limit| {
format!(
"This session is configured with `max_concurrent_threads_per_session = {limit}` for concurrently open agent threads."
)
})
.unwrap_or_default();
let tool_description = format!(
r#"
@@ -743,8 +733,7 @@ The spawned agent will have the same tools as you and the ability to spawn its o
{inherited_model_guidance}
Only call this tool for a concrete, bounded subtask that can run independently alongside useful local work; otherwise continue locally.
It will be able to send you and other running agents messages, and its final answer will be provided to you when it finishes.
The new agent's canonical task name will be provided to it along with the message.
{concurrency_guidance}"#
The new agent's canonical task name will be provided to it along with the message."#
);
if !include_usage_hint {
@@ -47,7 +47,6 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
hide_agent_type_model_reasoning: false,
include_usage_hint: true,
usage_hint_text: None,
max_concurrent_threads_per_session: Some(4),
});
let ToolSpec::Function(ResponsesApiTool {
@@ -69,7 +68,7 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
.expect("spawn_agent should use object params");
assert!(description.contains("Spawns an agent to work on the specified task."));
assert!(description.contains("The spawned agent will have the same tools as you"));
assert!(description.contains("`max_concurrent_threads_per_session = 4`"));
assert!(!description.contains("max_concurrent_threads_per_session"));
assert!(description.contains(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE));
assert!(
description
@@ -124,7 +123,6 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
hide_agent_type_model_reasoning: false,
include_usage_hint: true,
usage_hint_text: None,
max_concurrent_threads_per_session: None,
});
let ToolSpec::Namespace(namespace) = tool else {
@@ -182,7 +180,6 @@ fn spawn_agent_tool_caps_visible_model_summaries() {
hide_agent_type_model_reasoning: false,
include_usage_hint: true,
usage_hint_text: None,
max_concurrent_threads_per_session: Some(4),
});
let ToolSpec::Function(ResponsesApiTool { description, .. }) = tool else {
@@ -227,7 +224,6 @@ fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() {
hide_agent_type_model_reasoning: true,
include_usage_hint: true,
usage_hint_text: None,
max_concurrent_threads_per_session: Some(4),
});
let ToolSpec::Function(ResponsesApiTool {
@@ -7,7 +7,6 @@ use codex_tools::ToolSpec;
pub(crate) struct Handler;
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for Handler {
fn tool_name(&self) -> ToolName {
ToolName::plain("followup_task")
@@ -17,7 +16,13 @@ impl ToolExecutor<ToolInvocation> for Handler {
create_followup_task_tool()
}
async fn handle(
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl Handler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
@@ -6,7 +6,6 @@ use codex_tools::ToolSpec;
pub(crate) struct Handler;
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for Handler {
fn tool_name(&self) -> ToolName {
ToolName::plain("interrupt_agent")
@@ -16,13 +15,12 @@ impl ToolExecutor<ToolInvocation> for Handler {
create_interrupt_agent_tool_v2()
}
async fn handle(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
handle_interrupt_agent(invocation)
.await
.map(boxed_tool_output)
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(async move {
handle_interrupt_agent(invocation)
.await
.map(boxed_tool_output)
})
}
}
@@ -5,7 +5,6 @@ use codex_tools::ToolSpec;
pub(crate) struct Handler;
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for Handler {
fn tool_name(&self) -> ToolName {
ToolName::plain("list_agents")
@@ -15,7 +14,13 @@ impl ToolExecutor<ToolInvocation> for Handler {
create_list_agents_tool()
}
async fn handle(
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl Handler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
@@ -7,7 +7,6 @@ use codex_tools::ToolSpec;
pub(crate) struct Handler;
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for Handler {
fn tool_name(&self) -> ToolName {
ToolName::plain("send_message")
@@ -17,7 +16,13 @@ impl ToolExecutor<ToolInvocation> for Handler {
create_send_message_tool()
}
async fn handle(
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl Handler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
@@ -22,7 +22,6 @@ impl Handler {
}
}
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for Handler {
fn tool_name(&self) -> ToolName {
ToolName::plain("spawn_agent")
@@ -32,11 +31,8 @@ impl ToolExecutor<ToolInvocation> for Handler {
create_spawn_agent_tool_v2(self.options.clone())
}
async fn handle(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
handle_spawn_agent(invocation).await.map(boxed_tool_output)
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(async move { handle_spawn_agent(invocation).await.map(boxed_tool_output) })
}
}
@@ -165,7 +161,7 @@ async fn handle_spawn_agent(
turn.session_telemetry.counter(
"codex.multi_agent.spawn",
/*inc*/ 1,
&[("role", role_tag)],
&[("role", role_tag), ("version", "v2")],
);
let task_name = String::from(new_agent_path);
@@ -19,7 +19,6 @@ impl Handler {
}
}
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for Handler {
fn tool_name(&self) -> ToolName {
ToolName::plain("wait_agent")
@@ -29,7 +28,13 @@ impl ToolExecutor<ToolInvocation> for Handler {
create_wait_agent_tool_v2(self.options)
}
async fn handle(
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl Handler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
@@ -0,0 +1,45 @@
use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::new_context_window_spec::NEW_CONTEXT_WINDOW_TOOL_NAME;
use crate::tools::handlers::new_context_window_spec::create_new_context_window_tool;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
pub(crate) const NEW_CONTEXT_WINDOW_MESSAGE: &str =
"A new context window will start without summarizing conversation history.";
pub struct NewContextWindowHandler;
impl ToolExecutor<ToolInvocation> for NewContextWindowHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain(NEW_CONTEXT_WINDOW_TOOL_NAME)
}
fn spec(&self) -> ToolSpec {
create_new_context_window_tool()
}
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(async move {
if !matches!(invocation.payload, ToolPayload::Function { .. }) {
return Err(FunctionCallError::RespondToModel(
"new_context handler received unsupported payload".to_string(),
));
}
invocation.session.request_new_context_window().await;
Ok(boxed_tool_output(FunctionToolOutput::from_text(
NEW_CONTEXT_WINDOW_MESSAGE.to_string(),
Some(true),
)))
})
}
}
impl CoreToolRuntime for NewContextWindowHandler {}
@@ -0,0 +1,17 @@
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use std::collections::BTreeMap;
pub(crate) const NEW_CONTEXT_WINDOW_TOOL_NAME: &str = "new_context";
pub fn create_new_context_window_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: NEW_CONTEXT_WINDOW_TOOL_NAME.to_string(),
description: "Start a new context window.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(BTreeMap::new(), /*required*/ None, Some(false.into())),
output_schema: None,
})
}
+7 -2
View File
@@ -45,7 +45,6 @@ impl ToolOutput for PlanToolOutput {
}
}
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for PlanHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain("update_plan")
@@ -55,7 +54,13 @@ impl ToolExecutor<ToolInvocation> for PlanHandler {
create_update_plan_tool()
}
async fn handle(
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl PlanHandler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
@@ -25,7 +25,6 @@ struct RequestPermissionsEnvironmentArgs {
environment_id: Option<String>,
}
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for RequestPermissionsHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain("request_permissions")
@@ -35,7 +34,13 @@ impl ToolExecutor<ToolInvocation> for RequestPermissionsHandler {
create_request_permissions_tool(request_permissions_tool_description())
}
async fn handle(
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl RequestPermissionsHandler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
@@ -48,7 +48,6 @@ impl RequestPluginInstallHandler {
}
}
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain(REQUEST_PLUGIN_INSTALL_TOOL_NAME)
@@ -62,7 +61,13 @@ impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
true
}
async fn handle(
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl RequestPluginInstallHandler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
@@ -304,10 +309,6 @@ fn is_remote_plugin_install_suggestion(plugin_id: &str) -> bool {
.is_some_and(|(_, marketplace_name)| marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME)
}
#[expect(
clippy::await_holding_invalid_type,
reason = "connector cache refresh reads through the session-owned manager guard"
)]
async fn refresh_missing_requested_connectors(
session: &crate::session::session::Session,
turn: &crate::session::turn_context::TurnContext,
@@ -319,7 +320,7 @@ async fn refresh_missing_requested_connectors(
return Some(Vec::new());
}
let manager = session.services.mcp_connection_manager.read().await;
let manager = session.services.mcp_connection_manager.load_full();
let mcp_tools = manager.list_all_tools().await;
let accessible_connectors = connectors::with_app_enabled_state(
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
@@ -358,7 +359,7 @@ fn verified_plugin_install_completed(
) -> bool {
let plugins_input = config.plugins_config_input();
plugins_manager
.list_marketplaces_for_config(&plugins_input, &[])
.list_marketplaces_for_config(&plugins_input, &[], /*include_openai_curated*/ true)
.ok()
.into_iter()
.flat_map(|outcome| outcome.marketplaces)
@@ -130,6 +130,7 @@ async fn persist_disabled_install_request_writes_plugin_config() {
let codex_home = tempdir().expect("tempdir should succeed");
let tool = DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo {
id: "slack@openai-curated".to_string(),
remote_plugin_id: None,
name: "Slack".to_string(),
description: None,
has_skills: true,
@@ -20,7 +20,6 @@ pub struct RequestUserInputHandler {
pub available_modes: Vec<ModeKind>,
}
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for RequestUserInputHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain(REQUEST_USER_INPUT_TOOL_NAME)
@@ -30,7 +29,13 @@ impl ToolExecutor<ToolInvocation> for RequestUserInputHandler {
create_request_user_input_tool(request_user_input_tool_description(&self.available_modes))
}
async fn handle(
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl RequestUserInputHandler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {

Some files were not shown because too many files have changed in this diff Show More