Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 2 additions & 9 deletions codex-rs/core/src/mcp_tool_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,12 +192,6 @@ pub(crate) async fn handle_mcp_tool_call(
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())),
};
}
let request_meta = build_mcp_tool_call_request_meta(
turn_context.as_ref(),
&server,
&call_id,
metadata.as_ref(),
);
let connector_id = metadata
.as_ref()
.and_then(|metadata| metadata.connector_id.clone());
Expand Down Expand Up @@ -235,7 +229,6 @@ pub(crate) async fn handle_mcp_tool_call(
&call_id,
invocation,
metadata.as_ref(),
request_meta,
mcp_app_resource_uri,
)
.await;
Expand Down Expand Up @@ -303,7 +296,6 @@ pub(crate) async fn handle_mcp_tool_call(
&call_id,
invocation,
metadata.as_ref(),
request_meta,
mcp_app_resource_uri,
)
.await
Expand All @@ -320,7 +312,6 @@ async fn handle_approved_mcp_tool_call(
call_id: &str,
invocation: McpInvocation,
metadata: Option<&McpToolApprovalMetadata>,
request_meta: Option<JsonValue>,
mcp_app_resource_uri: Option<String>,
) -> HandledMcpToolCall {
let server = invocation.server.clone();
Expand Down Expand Up @@ -353,6 +344,8 @@ async fn handle_approved_mcp_tool_call(
};
let result = async {
let rewritten_arguments = rewrite?;
let request_meta =
build_mcp_tool_call_request_meta(turn_context, &server, call_id, metadata);
let result = execute_mcp_tool_call(
sess,
turn_context,
Expand Down
3 changes: 3 additions & 0 deletions codex-rs/core/src/session/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ impl Session {
id,
request,
});
turn_context
.turn_metadata_state
.mark_user_input_requested_during_turn();
self.send_event(turn_context, event).await;
rx_response.await.ok()
}
Expand Down
3 changes: 3 additions & 0 deletions codex-rs/core/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2275,6 +2275,9 @@ impl Session {
turn_id: turn_context.sub_id.clone(),
questions: args.questions,
});
turn_context
.turn_metadata_state
.mark_user_input_requested_during_turn();
self.send_event(turn_context, event).await;
rx_response.await.ok()
}
Expand Down
21 changes: 21 additions & 0 deletions codex-rs/core/src/turn_metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::RwLock;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;

use codex_utils_string::to_ascii_json_string;
use serde::Serialize;
Expand All @@ -23,6 +25,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
const MODEL_KEY: &str = "model";
const REASONING_EFFORT_KEY: &str = "reasoning_effort";
const TURN_STARTED_AT_UNIX_MS_KEY: &str = "turn_started_at_unix_ms";
const USER_INPUT_REQUESTED_DURING_TURN_KEY: &str = "user_input_requested_during_turn";

pub(crate) struct McpTurnMetadataContext<'a> {
pub(crate) model: &'a str,
Expand Down Expand Up @@ -186,6 +189,7 @@ pub(crate) struct TurnMetadataState {
enriched_header: Arc<RwLock<Option<String>>>,
turn_started_at_unix_ms: Arc<RwLock<Option<i64>>>,
responsesapi_client_metadata: Arc<RwLock<Option<HashMap<String, String>>>>,
user_input_requested_during_turn: Arc<AtomicBool>,
enrichment_task: Arc<Mutex<Option<JoinHandle<()>>>>,
}

Expand Down Expand Up @@ -231,6 +235,7 @@ impl TurnMetadataState {
enriched_header: Arc::new(RwLock::new(None)),
turn_started_at_unix_ms: Arc::new(RwLock::new(None)),
responsesapi_client_metadata: Arc::new(RwLock::new(None)),
user_input_requested_during_turn: Arc::new(AtomicBool::new(false)),
enrichment_task: Arc::new(Mutex::new(None)),
}
}
Expand Down Expand Up @@ -285,9 +290,25 @@ impl TurnMetadataState {
metadata.remove(REASONING_EFFORT_KEY);
}
}
if self
.user_input_requested_during_turn
.load(Ordering::Relaxed)
{
metadata.insert(
USER_INPUT_REQUESTED_DURING_TURN_KEY.to_string(),
Value::Bool(true),
);
} else {
metadata.remove(USER_INPUT_REQUESTED_DURING_TURN_KEY);
}
Some(Value::Object(metadata))
}

pub(crate) fn mark_user_input_requested_during_turn(&self) {
self.user_input_requested_during_turn
.store(true, Ordering::Relaxed);
}

pub(crate) fn set_responsesapi_client_metadata(
&self,
responsesapi_client_metadata: HashMap<String, String>,
Expand Down
50 changes: 50 additions & 0 deletions codex-rs/core/src/turn_metadata_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,56 @@ fn turn_metadata_state_includes_model_and_reasoning_effort_only_in_request_meta(
);
}

#[test]
fn turn_metadata_state_marks_user_input_requested_during_turn_only_for_mcp_request_meta() {
let temp_dir = TempDir::new().expect("temp dir");
let cwd = temp_dir.path().abs();
let permission_profile = PermissionProfile::read_only();

let state = TurnMetadataState::new(
"session-a".to_string(),
"thread-a".to_string(),
/*thread_source*/ None,
"turn-a".to_string(),
cwd,
&permission_profile,
WindowsSandboxLevel::Disabled,
/*enforce_managed_network*/ false,
);

let header = state.current_header_value().expect("header");
let header_json: Value = serde_json::from_str(&header).expect("json");
assert!(
header_json
.get(USER_INPUT_REQUESTED_DURING_TURN_KEY)
.is_none()
);

let meta = state
.current_meta_value_for_mcp_request(test_mcp_turn_metadata_context())
.expect("turn metadata should be present");
assert!(meta.get(USER_INPUT_REQUESTED_DURING_TURN_KEY).is_none());

state.mark_user_input_requested_during_turn();

let header = state.current_header_value().expect("header");
let header_json: Value = serde_json::from_str(&header).expect("json");
assert!(
header_json
.get(USER_INPUT_REQUESTED_DURING_TURN_KEY)
.is_none()
);

let meta = state
.current_meta_value_for_mcp_request(test_mcp_turn_metadata_context())
.expect("turn metadata should be present");
assert_eq!(
meta.get(USER_INPUT_REQUESTED_DURING_TURN_KEY)
.and_then(Value::as_bool),
Some(true)
);
}

#[test]
fn turn_metadata_state_ignores_client_turn_started_at_unix_ms_before_start() {
let temp_dir = TempDir::new().expect("temp dir");
Expand Down
120 changes: 117 additions & 3 deletions codex-rs/core/tests/common/apps_test_server.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
use crate::test_codex::TestCodexBuilder;
use crate::test_codex::test_codex;
use anyhow::Result;
use codex_core::config::Config;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_models_manager::bundled_models_response;
use serde_json::Value;
use serde_json::json;
use wiremock::Mock;
Expand All @@ -15,10 +21,21 @@ const CONNECTOR_NAME: &str = "Calendar";
const DISCOVERABLE_CALENDAR_ID: &str = "connector_2128aebfecb84f64a069897515042a44";
const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2";
const CONNECTOR_DESCRIPTION: &str = "Plan events and manage your calendar.";
const CODEX_APPS_META_KEY: &str = "_codex_apps";
const PROTOCOL_VERSION: &str = "2025-11-25";
const SERVER_NAME: &str = "codex-apps-test";
const SERVER_VERSION: &str = "1.0.0";
const SEARCHABLE_TOOL_COUNT: usize = 100;
const CALENDAR_CREATE_EVENT_TOOL_NAME: &str = "calendar_create_event";
pub const CALENDAR_EXTRACT_TEXT_TOOL_NAME: &str = "calendar_extract_text";
const CALENDAR_LIST_EVENTS_TOOL_NAME: &str = "calendar_list_events";
pub const DIRECT_CALENDAR_CREATE_EVENT_TOOL: &str = "mcp__codex_apps__calendar_create_event";
pub const DIRECT_CALENDAR_LIST_EVENTS_TOOL: &str = "mcp__codex_apps__calendar_list_events";
pub const DIRECT_CALENDAR_EXTRACT_TEXT_TOOL: &str = "mcp__codex_apps__calendar_extract_text";
pub const SEARCH_CALENDAR_NAMESPACE: &str = "mcp__codex_apps__calendar";
pub const SEARCH_CALENDAR_CREATE_TOOL: &str = "_create_event";
pub const SEARCH_CALENDAR_EXTRACT_TEXT_TOOL: &str = "_extract_text";
pub const SEARCH_CALENDAR_LIST_TOOL: &str = "_list_events";
pub const CALENDAR_CREATE_EVENT_RESOURCE_URI: &str =
"connector://calendar/tools/calendar_create_event";
pub const CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI: &str =
Expand Down Expand Up @@ -71,6 +88,103 @@ impl AppsTestServer {
}
}

pub fn configure_search_capable_model(config: &mut Config) {
let mut model_catalog = bundled_models_response()
.unwrap_or_else(|err| panic!("bundled models.json should parse: {err}"));
let model = model_catalog
.models
.iter_mut()
.find(|model| model.slug == "gpt-5.4")
.expect("gpt-5.4 exists in bundled models.json");
config.model = Some("gpt-5.4".to_string());
model.supports_search_tool = true;
config.model_catalog = Some(model_catalog);
}

fn configure_apps(config: &mut Config, apps_base_url: &str) {
config
.features
.enable(Feature::Apps)
.expect("test config should allow feature update");
config.chatgpt_base_url = apps_base_url.to_string();
}

pub fn configure_search_capable_apps(config: &mut Config, apps_base_url: &str) {
configure_apps(config, apps_base_url);
configure_search_capable_model(config);
}

pub fn apps_enabled_builder(apps_base_url: impl Into<String>) -> TestCodexBuilder {
let apps_base_url = apps_base_url.into();
test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| configure_apps(config, apps_base_url.as_str()))
}

pub fn search_capable_apps_builder(apps_base_url: impl Into<String>) -> TestCodexBuilder {
let apps_base_url = apps_base_url.into();
test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| configure_search_capable_apps(config, apps_base_url.as_str()))
}

fn apps_tool_call_id(body: &Value) -> Option<&str> {
body.get("params")?
.get("_meta")?
.get(CODEX_APPS_META_KEY)?
.get("call_id")?
.as_str()
}

async fn recorded_apps_tool_calls(server: &MockServer) -> Vec<Value> {
server
.received_requests()
.await
.expect("mock server should capture requests")
.into_iter()
.filter_map(|request| {
let body: Value = serde_json::from_slice(&request.body).ok()?;
(request.url.path() == "/api/codex/apps"
&& body.get("method").and_then(Value::as_str) == Some("tools/call"))
.then_some(body)
})
.collect()
}

pub async fn recorded_apps_tool_call_by_call_id(server: &MockServer, call_id: &str) -> Value {
let matches = recorded_apps_tool_calls(server)
.await
.into_iter()
.filter(|body| apps_tool_call_id(body) == Some(call_id))
.collect::<Vec<_>>();
assert_eq!(
matches.len(),
1,
"expected exactly one apps tools/call request for call_id {call_id}"
);
matches
.into_iter()
.next()
.expect("matching apps tools/call request should be recorded")
}

pub async fn recorded_apps_tool_call_by_name(server: &MockServer, tool_name: &str) -> Value {
let matches = recorded_apps_tool_calls(server)
.await
.into_iter()
.filter(|body| body.pointer("/params/name").and_then(Value::as_str) == Some(tool_name))
.collect::<Vec<_>>();
assert_eq!(
matches.len(),
1,
"expected exactly one apps tools/call request for tool {tool_name}"
);
matches
.into_iter()
.next()
.expect("matching apps tools/call request should be recorded")
}

async fn mount_oauth_metadata(server: &MockServer) {
Mock::given(method("GET"))
.and(path("/.well-known/oauth-authorization-server/mcp"))
Expand Down Expand Up @@ -187,7 +301,7 @@ impl Respond for CodexAppsJsonRpcResponder {
"result": {
"tools": [
{
"name": "calendar_create_event",
"name": CALENDAR_CREATE_EVENT_TOOL_NAME,
"description": "Create a calendar event.",
"annotations": {
"readOnlyHint": false,
Expand Down Expand Up @@ -217,7 +331,7 @@ impl Respond for CodexAppsJsonRpcResponder {
}
},
{
"name": "calendar_list_events",
"name": CALENDAR_LIST_EVENTS_TOOL_NAME,
"description": "List calendar events.",
"annotations": {
"readOnlyHint": true
Expand All @@ -242,7 +356,7 @@ impl Respond for CodexAppsJsonRpcResponder {
}
},
{
"name": "calendar_extract_text",
"name": CALENDAR_EXTRACT_TEXT_TOOL_NAME,
"description": "Extract text from an uploaded document.",
"annotations": {
"readOnlyHint": false
Expand Down
Loading
Loading