mirror of
https://github.com/openai/codex.git
synced 2026-04-28 18:32:04 +03:00
1726 lines
59 KiB
Markdown
1726 lines
59 KiB
Markdown
# PR #1642: Add an elicitation for approve patch and refactor tool calls
|
||
|
||
- URL: https://github.com/openai/codex/pull/1642
|
||
- Author: gpeal
|
||
- Created: 2025-07-21 21:20:33 UTC
|
||
- Updated: 2025-07-22 06:58:52 UTC
|
||
- Changes: +584/-197, Files changed: 8, Commits: 22
|
||
|
||
## Description
|
||
|
||
1. Added an elicitation for `approve-patch` which is very similar to `approve-exec`.
|
||
2. Extracted both elicitations to their own files to prevent `codex_tool_runner` from blowing up in size.
|
||
|
||
## Full Diff
|
||
|
||
```diff
|
||
diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs
|
||
index 3893a48595..df2154dd1f 100644
|
||
--- a/codex-rs/mcp-server/src/codex_tool_runner.rs
|
||
+++ b/codex-rs/mcp-server/src/codex_tool_runner.rs
|
||
@@ -3,38 +3,31 @@
|
||
//! and to make future feature-growth easier to manage.
|
||
|
||
use std::collections::HashMap;
|
||
-use std::path::PathBuf;
|
||
use std::sync::Arc;
|
||
|
||
use codex_core::Codex;
|
||
use codex_core::codex_wrapper::init_codex;
|
||
use codex_core::config::Config as CodexConfig;
|
||
use codex_core::protocol::AgentMessageEvent;
|
||
+use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||
use codex_core::protocol::EventMsg;
|
||
use codex_core::protocol::ExecApprovalRequestEvent;
|
||
use codex_core::protocol::InputItem;
|
||
use codex_core::protocol::Op;
|
||
-use codex_core::protocol::ReviewDecision;
|
||
use codex_core::protocol::Submission;
|
||
use codex_core::protocol::TaskCompleteEvent;
|
||
use mcp_types::CallToolResult;
|
||
use mcp_types::ContentBlock;
|
||
-use mcp_types::ElicitRequest;
|
||
-use mcp_types::ElicitRequestParamsRequestedSchema;
|
||
-use mcp_types::JSONRPCErrorError;
|
||
-use mcp_types::ModelContextProtocolRequest;
|
||
use mcp_types::RequestId;
|
||
use mcp_types::TextContent;
|
||
-use serde::Deserialize;
|
||
-use serde::Serialize;
|
||
-use serde_json::json;
|
||
use tokio::sync::Mutex;
|
||
-use tracing::error;
|
||
use uuid::Uuid;
|
||
|
||
+use crate::exec_approval::handle_exec_approval_request;
|
||
use crate::outgoing_message::OutgoingMessageSender;
|
||
+use crate::patch_approval::handle_patch_approval_request;
|
||
|
||
-const INVALID_PARAMS_ERROR_CODE: i64 = -32602;
|
||
+pub(crate) const INVALID_PARAMS_ERROR_CODE: i64 = -32602;
|
||
|
||
/// Run a complete Codex session and stream events back to the client.
|
||
///
|
||
@@ -120,7 +113,7 @@ async fn run_codex_tool_session_inner(
|
||
outgoing: Arc<OutgoingMessageSender>,
|
||
request_id: RequestId,
|
||
) {
|
||
- let sub_id = match &request_id {
|
||
+ let request_id_str = match &request_id {
|
||
RequestId::String(s) => s.clone(),
|
||
RequestId::Integer(n) => n.to_string(),
|
||
};
|
||
@@ -138,80 +131,34 @@ async fn run_codex_tool_session_inner(
|
||
cwd,
|
||
reason: _,
|
||
}) => {
|
||
- let escaped_command = shlex::try_join(command.iter().map(|s| s.as_str()))
|
||
- .unwrap_or_else(|_| command.join(" "));
|
||
- let message = format!(
|
||
- "Allow Codex to run `{escaped_command}` in `{cwd}`?",
|
||
- cwd = cwd.to_string_lossy()
|
||
- );
|
||
-
|
||
- let params = ExecApprovalElicitRequestParams {
|
||
- message,
|
||
- requested_schema: ElicitRequestParamsRequestedSchema {
|
||
- r#type: "object".to_string(),
|
||
- properties: json!({}),
|
||
- required: None,
|
||
- },
|
||
- codex_elicitation: "exec-approval".to_string(),
|
||
- codex_mcp_tool_call_id: sub_id.clone(),
|
||
- codex_event_id: event.id.clone(),
|
||
- codex_command: command,
|
||
- codex_cwd: cwd,
|
||
- };
|
||
- let params_json = match serde_json::to_value(¶ms) {
|
||
- Ok(value) => value,
|
||
- Err(err) => {
|
||
- let message = format!(
|
||
- "Failed to serialize ExecApprovalElicitRequestParams: {err}"
|
||
- );
|
||
- tracing::error!("{message}");
|
||
-
|
||
- outgoing
|
||
- .send_error(
|
||
- request_id.clone(),
|
||
- JSONRPCErrorError {
|
||
- code: INVALID_PARAMS_ERROR_CODE,
|
||
- message,
|
||
- data: None,
|
||
- },
|
||
- )
|
||
- .await;
|
||
-
|
||
- continue;
|
||
- }
|
||
- };
|
||
-
|
||
- let on_response = outgoing
|
||
- .send_request(ElicitRequest::METHOD, Some(params_json))
|
||
- .await;
|
||
-
|
||
- // Listen for the response on a separate task so we do
|
||
- // not block the main loop of this function.
|
||
- {
|
||
- let codex = codex.clone();
|
||
- let event_id = event.id.clone();
|
||
- tokio::spawn(async move {
|
||
- on_exec_approval_response(event_id, on_response, codex).await;
|
||
- });
|
||
- }
|
||
-
|
||
- // Continue, don't break so the session continues.
|
||
+ handle_exec_approval_request(
|
||
+ command,
|
||
+ cwd,
|
||
+ outgoing.clone(),
|
||
+ codex.clone(),
|
||
+ request_id.clone(),
|
||
+ request_id_str.clone(),
|
||
+ event.id.clone(),
|
||
+ )
|
||
+ .await;
|
||
continue;
|
||
}
|
||
- EventMsg::ApplyPatchApprovalRequest(_) => {
|
||
- let result = CallToolResult {
|
||
- content: vec![ContentBlock::TextContent(TextContent {
|
||
- r#type: "text".to_string(),
|
||
- text: "PATCH_APPROVAL_REQUIRED".to_string(),
|
||
- annotations: None,
|
||
- })],
|
||
- is_error: None,
|
||
- structured_content: None,
|
||
- };
|
||
- outgoing
|
||
- .send_response(request_id.clone(), result.into())
|
||
- .await;
|
||
- // Continue, don't break so the session continues.
|
||
+ EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
|
||
+ reason,
|
||
+ grant_root,
|
||
+ changes,
|
||
+ }) => {
|
||
+ handle_patch_approval_request(
|
||
+ reason,
|
||
+ grant_root,
|
||
+ changes,
|
||
+ outgoing.clone(),
|
||
+ codex.clone(),
|
||
+ request_id.clone(),
|
||
+ request_id_str.clone(),
|
||
+ event.id.clone(),
|
||
+ )
|
||
+ .await;
|
||
continue;
|
||
}
|
||
EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => {
|
||
@@ -286,71 +233,3 @@ async fn run_codex_tool_session_inner(
|
||
}
|
||
}
|
||
}
|
||
-
|
||
-async fn on_exec_approval_response(
|
||
- event_id: String,
|
||
- receiver: tokio::sync::oneshot::Receiver<mcp_types::Result>,
|
||
- codex: Arc<Codex>,
|
||
-) {
|
||
- let response = receiver.await;
|
||
- let value = match response {
|
||
- Ok(value) => value,
|
||
- Err(err) => {
|
||
- error!("request failed: {err:?}");
|
||
- return;
|
||
- }
|
||
- };
|
||
-
|
||
- // Try to deserialize `value` and then make the appropriate call to `codex`.
|
||
- let response = match serde_json::from_value::<ExecApprovalResponse>(value) {
|
||
- Ok(response) => response,
|
||
- Err(err) => {
|
||
- error!("failed to deserialize ExecApprovalResponse: {err}");
|
||
- // If we cannot deserialize the response, we deny the request to be
|
||
- // conservative.
|
||
- ExecApprovalResponse {
|
||
- decision: ReviewDecision::Denied,
|
||
- }
|
||
- }
|
||
- };
|
||
-
|
||
- if let Err(err) = codex
|
||
- .submit(Op::ExecApproval {
|
||
- id: event_id,
|
||
- decision: response.decision,
|
||
- })
|
||
- .await
|
||
- {
|
||
- error!("failed to submit ExecApproval: {err}");
|
||
- }
|
||
-}
|
||
-
|
||
-// TODO(mbolin): ExecApprovalResponse does not conform to ElicitResult. See:
|
||
-// - https://github.com/modelcontextprotocol/modelcontextprotocol/blob/f962dc1780fa5eed7fb7c8a0232f1fc83ef220cd/schema/2025-06-18/schema.json#L617-L636
|
||
-// - https://modelcontextprotocol.io/specification/draft/client/elicitation#protocol-messages
|
||
-// It should have "action" and "content" fields.
|
||
-
|
||
-#[derive(Debug, Serialize, Deserialize)]
|
||
-pub struct ExecApprovalResponse {
|
||
- pub decision: ReviewDecision,
|
||
-}
|
||
-
|
||
-/// Conforms to [`mcp_types::ElicitRequestParams`] so that it can be used as the
|
||
-/// `params` field of an [`mcp_types::ElicitRequest`].
|
||
-#[derive(Debug, Serialize)]
|
||
-pub struct ExecApprovalElicitRequestParams {
|
||
- // These fields are required so that `params`
|
||
- // conforms to ElicitRequestParams.
|
||
- pub message: String,
|
||
-
|
||
- #[serde(rename = "requestedSchema")]
|
||
- pub requested_schema: ElicitRequestParamsRequestedSchema,
|
||
-
|
||
- // These are additional fields the client can use to
|
||
- // correlate the request with the codex tool call.
|
||
- pub codex_elicitation: String,
|
||
- pub codex_mcp_tool_call_id: String,
|
||
- pub codex_event_id: String,
|
||
- pub codex_command: Vec<String>,
|
||
- pub codex_cwd: PathBuf,
|
||
-}
|
||
diff --git a/codex-rs/mcp-server/src/exec_approval.rs b/codex-rs/mcp-server/src/exec_approval.rs
|
||
new file mode 100644
|
||
index 0000000000..fc0c41d0d1
|
||
--- /dev/null
|
||
+++ b/codex-rs/mcp-server/src/exec_approval.rs
|
||
@@ -0,0 +1,145 @@
|
||
+use std::path::PathBuf;
|
||
+use std::sync::Arc;
|
||
+
|
||
+use codex_core::Codex;
|
||
+use codex_core::protocol::Op;
|
||
+use codex_core::protocol::ReviewDecision;
|
||
+use mcp_types::ElicitRequest;
|
||
+use mcp_types::ElicitRequestParamsRequestedSchema;
|
||
+use mcp_types::JSONRPCErrorError;
|
||
+use mcp_types::ModelContextProtocolRequest;
|
||
+use mcp_types::RequestId;
|
||
+use serde::Deserialize;
|
||
+use serde::Serialize;
|
||
+use serde_json::json;
|
||
+use tracing::error;
|
||
+
|
||
+use crate::codex_tool_runner::INVALID_PARAMS_ERROR_CODE;
|
||
+
|
||
+/// Conforms to [`mcp_types::ElicitRequestParams`] so that it can be used as the
|
||
+/// `params` field of an [`ElicitRequest`].
|
||
+#[derive(Debug, Serialize)]
|
||
+pub struct ExecApprovalElicitRequestParams {
|
||
+ // These fields are required so that `params`
|
||
+ // conforms to ElicitRequestParams.
|
||
+ pub message: String,
|
||
+
|
||
+ #[serde(rename = "requestedSchema")]
|
||
+ pub requested_schema: ElicitRequestParamsRequestedSchema,
|
||
+
|
||
+ // These are additional fields the client can use to
|
||
+ // correlate the request with the codex tool call.
|
||
+ pub codex_elicitation: String,
|
||
+ pub codex_mcp_tool_call_id: String,
|
||
+ pub codex_event_id: String,
|
||
+ pub codex_command: Vec<String>,
|
||
+ pub codex_cwd: PathBuf,
|
||
+}
|
||
+
|
||
+// TODO(mbolin): ExecApprovalResponse does not conform to ElicitResult. See:
|
||
+// - https://github.com/modelcontextprotocol/modelcontextprotocol/blob/f962dc1780fa5eed7fb7c8a0232f1fc83ef220cd/schema/2025-06-18/schema.json#L617-L636
|
||
+// - https://modelcontextprotocol.io/specification/draft/client/elicitation#protocol-messages
|
||
+// It should have "action" and "content" fields.
|
||
+#[derive(Debug, Serialize, Deserialize)]
|
||
+pub struct ExecApprovalResponse {
|
||
+ pub decision: ReviewDecision,
|
||
+}
|
||
+
|
||
+pub(crate) async fn handle_exec_approval_request(
|
||
+ command: Vec<String>,
|
||
+ cwd: PathBuf,
|
||
+ outgoing: Arc<crate::outgoing_message::OutgoingMessageSender>,
|
||
+ codex: Arc<Codex>,
|
||
+ request_id: RequestId,
|
||
+ tool_call_id: String,
|
||
+ event_id: String,
|
||
+) {
|
||
+ let escaped_command =
|
||
+ shlex::try_join(command.iter().map(|s| s.as_str())).unwrap_or_else(|_| command.join(" "));
|
||
+ let message = format!(
|
||
+ "Allow Codex to run `{escaped_command}` in `{cwd}`?",
|
||
+ cwd = cwd.to_string_lossy()
|
||
+ );
|
||
+
|
||
+ let params = ExecApprovalElicitRequestParams {
|
||
+ message,
|
||
+ requested_schema: ElicitRequestParamsRequestedSchema {
|
||
+ r#type: "object".to_string(),
|
||
+ properties: json!({}),
|
||
+ required: None,
|
||
+ },
|
||
+ codex_elicitation: "exec-approval".to_string(),
|
||
+ codex_mcp_tool_call_id: tool_call_id.clone(),
|
||
+ codex_event_id: event_id.clone(),
|
||
+ codex_command: command,
|
||
+ codex_cwd: cwd,
|
||
+ };
|
||
+ let params_json = match serde_json::to_value(¶ms) {
|
||
+ Ok(value) => value,
|
||
+ Err(err) => {
|
||
+ let message = format!("Failed to serialize ExecApprovalElicitRequestParams: {err}");
|
||
+ error!("{message}");
|
||
+
|
||
+ outgoing
|
||
+ .send_error(
|
||
+ request_id.clone(),
|
||
+ JSONRPCErrorError {
|
||
+ code: INVALID_PARAMS_ERROR_CODE,
|
||
+ message,
|
||
+ data: None,
|
||
+ },
|
||
+ )
|
||
+ .await;
|
||
+
|
||
+ return;
|
||
+ }
|
||
+ };
|
||
+
|
||
+ let on_response = outgoing
|
||
+ .send_request(ElicitRequest::METHOD, Some(params_json))
|
||
+ .await;
|
||
+
|
||
+ // Listen for the response on a separate task so we don't block the main agent loop.
|
||
+ {
|
||
+ let codex = codex.clone();
|
||
+ let event_id = event_id.clone();
|
||
+ tokio::spawn(async move {
|
||
+ on_exec_approval_response(event_id, on_response, codex).await;
|
||
+ });
|
||
+ }
|
||
+}
|
||
+
|
||
+async fn on_exec_approval_response(
|
||
+ event_id: String,
|
||
+ receiver: tokio::sync::oneshot::Receiver<mcp_types::Result>,
|
||
+ codex: Arc<Codex>,
|
||
+) {
|
||
+ let response = receiver.await;
|
||
+ let value = match response {
|
||
+ Ok(value) => value,
|
||
+ Err(err) => {
|
||
+ error!("request failed: {err:?}");
|
||
+ return;
|
||
+ }
|
||
+ };
|
||
+
|
||
+ // Try to deserialize `value` and then make the appropriate call to `codex`.
|
||
+ let response = serde_json::from_value::<ExecApprovalResponse>(value).unwrap_or_else(|err| {
|
||
+ error!("failed to deserialize ExecApprovalResponse: {err}");
|
||
+ // If we cannot deserialize the response, we deny the request to be
|
||
+ // conservative.
|
||
+ ExecApprovalResponse {
|
||
+ decision: ReviewDecision::Denied,
|
||
+ }
|
||
+ });
|
||
+
|
||
+ if let Err(err) = codex
|
||
+ .submit(Op::ExecApproval {
|
||
+ id: event_id,
|
||
+ decision: response.decision,
|
||
+ })
|
||
+ .await
|
||
+ {
|
||
+ error!("failed to submit ExecApproval: {err}");
|
||
+ }
|
||
+}
|
||
diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs
|
||
index 1f1ecc3f2a..300d1b5fe5 100644
|
||
--- a/codex-rs/mcp-server/src/lib.rs
|
||
+++ b/codex-rs/mcp-server/src/lib.rs
|
||
@@ -16,17 +16,21 @@ use tracing::info;
|
||
|
||
mod codex_tool_config;
|
||
mod codex_tool_runner;
|
||
+mod exec_approval;
|
||
mod json_to_toml;
|
||
mod message_processor;
|
||
mod outgoing_message;
|
||
+mod patch_approval;
|
||
|
||
use crate::message_processor::MessageProcessor;
|
||
use crate::outgoing_message::OutgoingMessage;
|
||
use crate::outgoing_message::OutgoingMessageSender;
|
||
|
||
pub use crate::codex_tool_config::CodexToolCallParam;
|
||
-pub use crate::codex_tool_runner::ExecApprovalElicitRequestParams;
|
||
-pub use crate::codex_tool_runner::ExecApprovalResponse;
|
||
+pub use crate::exec_approval::ExecApprovalElicitRequestParams;
|
||
+pub use crate::exec_approval::ExecApprovalResponse;
|
||
+pub use crate::patch_approval::PatchApprovalElicitRequestParams;
|
||
+pub use crate::patch_approval::PatchApprovalResponse;
|
||
|
||
/// Size of the bounded channels used to communicate between tasks. The value
|
||
/// is a balance between throughput and memory usage – 128 messages should be
|
||
diff --git a/codex-rs/mcp-server/src/patch_approval.rs b/codex-rs/mcp-server/src/patch_approval.rs
|
||
new file mode 100644
|
||
index 0000000000..bfccfa50ee
|
||
--- /dev/null
|
||
+++ b/codex-rs/mcp-server/src/patch_approval.rs
|
||
@@ -0,0 +1,147 @@
|
||
+use std::collections::HashMap;
|
||
+use std::path::PathBuf;
|
||
+use std::sync::Arc;
|
||
+
|
||
+use codex_core::Codex;
|
||
+use codex_core::protocol::FileChange;
|
||
+use codex_core::protocol::Op;
|
||
+use codex_core::protocol::ReviewDecision;
|
||
+use mcp_types::ElicitRequest;
|
||
+use mcp_types::ElicitRequestParamsRequestedSchema;
|
||
+use mcp_types::JSONRPCErrorError;
|
||
+use mcp_types::ModelContextProtocolRequest;
|
||
+use mcp_types::RequestId;
|
||
+use serde::Deserialize;
|
||
+use serde::Serialize;
|
||
+use serde_json::json;
|
||
+use tracing::error;
|
||
+
|
||
+use crate::codex_tool_runner::INVALID_PARAMS_ERROR_CODE;
|
||
+use crate::outgoing_message::OutgoingMessageSender;
|
||
+
|
||
+#[derive(Debug, Serialize)]
|
||
+pub struct PatchApprovalElicitRequestParams {
|
||
+ pub message: String,
|
||
+ #[serde(rename = "requestedSchema")]
|
||
+ pub requested_schema: ElicitRequestParamsRequestedSchema,
|
||
+ pub codex_elicitation: String,
|
||
+ pub codex_mcp_tool_call_id: String,
|
||
+ pub codex_event_id: String,
|
||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||
+ pub codex_reason: Option<String>,
|
||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||
+ pub codex_grant_root: Option<PathBuf>,
|
||
+ pub codex_changes: HashMap<PathBuf, FileChange>,
|
||
+}
|
||
+
|
||
+#[derive(Debug, Deserialize, Serialize)]
|
||
+pub struct PatchApprovalResponse {
|
||
+ pub decision: ReviewDecision,
|
||
+}
|
||
+
|
||
+#[allow(clippy::too_many_arguments)]
|
||
+pub(crate) async fn handle_patch_approval_request(
|
||
+ reason: Option<String>,
|
||
+ grant_root: Option<PathBuf>,
|
||
+ changes: HashMap<PathBuf, FileChange>,
|
||
+ outgoing: Arc<OutgoingMessageSender>,
|
||
+ codex: Arc<Codex>,
|
||
+ request_id: RequestId,
|
||
+ tool_call_id: String,
|
||
+ event_id: String,
|
||
+) {
|
||
+ let mut message_lines = Vec::new();
|
||
+ if let Some(r) = &reason {
|
||
+ message_lines.push(r.clone());
|
||
+ }
|
||
+ message_lines.push("Allow Codex to apply proposed code changes?".to_string());
|
||
+
|
||
+ let params = PatchApprovalElicitRequestParams {
|
||
+ message: message_lines.join("\n"),
|
||
+ requested_schema: ElicitRequestParamsRequestedSchema {
|
||
+ r#type: "object".to_string(),
|
||
+ properties: json!({}),
|
||
+ required: None,
|
||
+ },
|
||
+ codex_elicitation: "patch-approval".to_string(),
|
||
+ codex_mcp_tool_call_id: tool_call_id.clone(),
|
||
+ codex_event_id: event_id.clone(),
|
||
+ codex_reason: reason,
|
||
+ codex_grant_root: grant_root,
|
||
+ codex_changes: changes,
|
||
+ };
|
||
+ let params_json = match serde_json::to_value(¶ms) {
|
||
+ Ok(value) => value,
|
||
+ Err(err) => {
|
||
+ let message = format!("Failed to serialize PatchApprovalElicitRequestParams: {err}");
|
||
+ error!("{message}");
|
||
+
|
||
+ outgoing
|
||
+ .send_error(
|
||
+ request_id.clone(),
|
||
+ JSONRPCErrorError {
|
||
+ code: INVALID_PARAMS_ERROR_CODE,
|
||
+ message,
|
||
+ data: None,
|
||
+ },
|
||
+ )
|
||
+ .await;
|
||
+
|
||
+ return;
|
||
+ }
|
||
+ };
|
||
+
|
||
+ let on_response = outgoing
|
||
+ .send_request(ElicitRequest::METHOD, Some(params_json))
|
||
+ .await;
|
||
+
|
||
+ // Listen for the response on a separate task so we don't block the main agent loop.
|
||
+ {
|
||
+ let codex = codex.clone();
|
||
+ let event_id = event_id.clone();
|
||
+ tokio::spawn(async move {
|
||
+ on_patch_approval_response(event_id, on_response, codex).await;
|
||
+ });
|
||
+ }
|
||
+}
|
||
+
|
||
+pub(crate) async fn on_patch_approval_response(
|
||
+ event_id: String,
|
||
+ receiver: tokio::sync::oneshot::Receiver<mcp_types::Result>,
|
||
+ codex: Arc<Codex>,
|
||
+) {
|
||
+ let response = receiver.await;
|
||
+ let value = match response {
|
||
+ Ok(value) => value,
|
||
+ Err(err) => {
|
||
+ error!("request failed: {err:?}");
|
||
+ if let Err(submit_err) = codex
|
||
+ .submit(Op::PatchApproval {
|
||
+ id: event_id.clone(),
|
||
+ decision: ReviewDecision::Denied,
|
||
+ })
|
||
+ .await
|
||
+ {
|
||
+ error!("failed to submit denied PatchApproval after request failure: {submit_err}");
|
||
+ }
|
||
+ return;
|
||
+ }
|
||
+ };
|
||
+
|
||
+ let response = serde_json::from_value::<PatchApprovalResponse>(value).unwrap_or_else(|err| {
|
||
+ error!("failed to deserialize PatchApprovalResponse: {err}");
|
||
+ PatchApprovalResponse {
|
||
+ decision: ReviewDecision::Denied,
|
||
+ }
|
||
+ });
|
||
+
|
||
+ if let Err(err) = codex
|
||
+ .submit(Op::PatchApproval {
|
||
+ id: event_id,
|
||
+ decision: response.decision,
|
||
+ })
|
||
+ .await
|
||
+ {
|
||
+ error!("failed to submit PatchApproval: {err}");
|
||
+ }
|
||
+}
|
||
diff --git a/codex-rs/mcp-server/tests/common/mcp_process.rs b/codex-rs/mcp-server/tests/common/mcp_process.rs
|
||
index 42d15f7877..df9cc98acf 100644
|
||
--- a/codex-rs/mcp-server/tests/common/mcp_process.rs
|
||
+++ b/codex-rs/mcp-server/tests/common/mcp_process.rs
|
||
@@ -139,14 +139,18 @@ impl McpProcess {
|
||
|
||
/// Returns the id used to make the request so it can be used when
|
||
/// correlating notifications.
|
||
- pub async fn send_codex_tool_call(&mut self, prompt: &str) -> anyhow::Result<i64> {
|
||
+ pub async fn send_codex_tool_call(
|
||
+ &mut self,
|
||
+ cwd: Option<String>,
|
||
+ prompt: &str,
|
||
+ ) -> anyhow::Result<i64> {
|
||
let codex_tool_call_params = CallToolRequestParams {
|
||
name: "codex".to_string(),
|
||
arguments: Some(serde_json::to_value(CodexToolCallParam {
|
||
+ cwd,
|
||
prompt: prompt.to_string(),
|
||
model: None,
|
||
profile: None,
|
||
- cwd: None,
|
||
approval_policy: None,
|
||
sandbox: None,
|
||
config: None,
|
||
diff --git a/codex-rs/mcp-server/tests/common/mod.rs b/codex-rs/mcp-server/tests/common/mod.rs
|
||
index 61a5774bc4..b338e2e8ce 100644
|
||
--- a/codex-rs/mcp-server/tests/common/mod.rs
|
||
+++ b/codex-rs/mcp-server/tests/common/mod.rs
|
||
@@ -4,5 +4,6 @@ mod responses;
|
||
|
||
pub use mcp_process::McpProcess;
|
||
pub use mock_model_server::create_mock_chat_completions_server;
|
||
+pub use responses::create_apply_patch_sse_response;
|
||
pub use responses::create_final_assistant_message_sse_response;
|
||
pub use responses::create_shell_sse_response;
|
||
diff --git a/codex-rs/mcp-server/tests/common/responses.rs b/codex-rs/mcp-server/tests/common/responses.rs
|
||
index a11c72d0f4..9a827fb986 100644
|
||
--- a/codex-rs/mcp-server/tests/common/responses.rs
|
||
+++ b/codex-rs/mcp-server/tests/common/responses.rs
|
||
@@ -57,3 +57,39 @@ pub fn create_final_assistant_message_sse_response(message: &str) -> anyhow::Res
|
||
);
|
||
Ok(sse)
|
||
}
|
||
+
|
||
+pub fn create_apply_patch_sse_response(
|
||
+ patch_content: &str,
|
||
+ call_id: &str,
|
||
+) -> anyhow::Result<String> {
|
||
+ // Use shell command to call apply_patch with heredoc format
|
||
+ let shell_command = format!("apply_patch <<'EOF'\n{patch_content}\nEOF");
|
||
+ let tool_call_arguments = serde_json::to_string(&json!({
|
||
+ "command": ["bash", "-lc", shell_command]
|
||
+ }))?;
|
||
+
|
||
+ let tool_call = json!({
|
||
+ "choices": [
|
||
+ {
|
||
+ "delta": {
|
||
+ "tool_calls": [
|
||
+ {
|
||
+ "id": call_id,
|
||
+ "function": {
|
||
+ "name": "shell",
|
||
+ "arguments": tool_call_arguments
|
||
+ }
|
||
+ }
|
||
+ ]
|
||
+ },
|
||
+ "finish_reason": "tool_calls"
|
||
+ }
|
||
+ ]
|
||
+ });
|
||
+
|
||
+ let sse = format!(
|
||
+ "data: {}\n\ndata: DONE\n\n",
|
||
+ serde_json::to_string(&tool_call)?
|
||
+ );
|
||
+ Ok(sse)
|
||
+}
|
||
diff --git a/codex-rs/mcp-server/tests/elicitation.rs b/codex-rs/mcp-server/tests/elicitation.rs
|
||
index 7fd68d6775..ac9435e874 100644
|
||
--- a/codex-rs/mcp-server/tests/elicitation.rs
|
||
+++ b/codex-rs/mcp-server/tests/elicitation.rs
|
||
@@ -1,11 +1,17 @@
|
||
mod common;
|
||
|
||
+use std::collections::HashMap;
|
||
+use std::env;
|
||
use std::path::Path;
|
||
+use std::path::PathBuf;
|
||
|
||
use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||
+use codex_core::protocol::FileChange;
|
||
use codex_core::protocol::ReviewDecision;
|
||
use codex_mcp_server::ExecApprovalElicitRequestParams;
|
||
use codex_mcp_server::ExecApprovalResponse;
|
||
+use codex_mcp_server::PatchApprovalElicitRequestParams;
|
||
+use codex_mcp_server::PatchApprovalResponse;
|
||
use mcp_types::ElicitRequest;
|
||
use mcp_types::ElicitRequestParamsRequestedSchema;
|
||
use mcp_types::JSONRPC_VERSION;
|
||
@@ -17,8 +23,10 @@ use pretty_assertions::assert_eq;
|
||
use serde_json::json;
|
||
use tempfile::TempDir;
|
||
use tokio::time::timeout;
|
||
+use wiremock::MockServer;
|
||
|
||
use crate::common::McpProcess;
|
||
+use crate::common::create_apply_patch_sse_response;
|
||
use crate::common::create_final_assistant_message_sse_response;
|
||
use crate::common::create_mock_chat_completions_server;
|
||
use crate::common::create_shell_sse_response;
|
||
@@ -30,7 +38,7 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs
|
||
/// command, as expected.
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn test_shell_command_approval_triggers_elicitation() {
|
||
- if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||
+ if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||
println!(
|
||
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||
);
|
||
@@ -49,12 +57,11 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||
let shell_command = vec!["git".to_string(), "init".to_string()];
|
||
let workdir_for_shell_function_call = TempDir::new()?;
|
||
|
||
- // Configure the mock server so it makes two responses:
|
||
- // 1. The first response is a shell function call that will trigger an
|
||
- // elicitation request.
|
||
- // 2. The second response is the final assistant message that should be
|
||
- // returned after the elicitation is approved and the command is run.
|
||
- let server = create_mock_chat_completions_server(vec![
|
||
+ let McpHandle {
|
||
+ process: mut mcp_process,
|
||
+ server: _server,
|
||
+ dir: _dir,
|
||
+ } = create_mcp_process(vec![
|
||
create_shell_sse_response(
|
||
shell_command.clone(),
|
||
Some(workdir_for_shell_function_call.path()),
|
||
@@ -63,18 +70,14 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||
)?,
|
||
create_final_assistant_message_sse_response("Enjoy your new git repo!")?,
|
||
])
|
||
- .await;
|
||
-
|
||
- // Run `codex mcp` with a specific config.toml.
|
||
- let codex_home = TempDir::new()?;
|
||
- create_config_toml(codex_home.path(), server.uri())?;
|
||
- let mut mcp_process = McpProcess::new(codex_home.path()).await?;
|
||
- timeout(DEFAULT_READ_TIMEOUT, mcp_process.initialize()).await??;
|
||
+ .await?;
|
||
|
||
// Send a "codex" tool request, which should hit the completions endpoint.
|
||
// In turn, it should reply with a tool call, which the MCP should forward
|
||
// as an elicitation.
|
||
- let codex_request_id = mcp_process.send_codex_tool_call("run `git init`").await?;
|
||
+ let codex_request_id = mcp_process
|
||
+ .send_codex_tool_call(None, "run `git init`")
|
||
+ .await?;
|
||
let elicitation_request = timeout(
|
||
DEFAULT_READ_TIMEOUT,
|
||
mcp_process.read_stream_until_request_message(),
|
||
@@ -136,32 +139,6 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||
Ok(())
|
||
}
|
||
|
||
-/// Create a Codex config that uses the mock server as the model provider.
|
||
-/// It also uses `approval_policy = "untrusted"` so that we exercise the
|
||
-/// elicitation code path for shell commands.
|
||
-fn create_config_toml(codex_home: &Path, server_uri: String) -> std::io::Result<()> {
|
||
- let config_toml = codex_home.join("config.toml");
|
||
- std::fs::write(
|
||
- config_toml,
|
||
- format!(
|
||
- r#"
|
||
-model = "mock-model"
|
||
-approval_policy = "untrusted"
|
||
-sandbox_policy = "read-only"
|
||
-
|
||
-model_provider = "mock_provider"
|
||
-
|
||
-[model_providers.mock_provider]
|
||
-name = "Mock provider for test"
|
||
-base_url = "{server_uri}/v1"
|
||
-wire_api = "chat"
|
||
-request_max_retries = 0
|
||
-stream_max_retries = 0
|
||
-"#
|
||
- ),
|
||
- )
|
||
-}
|
||
-
|
||
fn create_expected_elicitation_request(
|
||
elicitation_request_id: RequestId,
|
||
command: Vec<String>,
|
||
@@ -193,3 +170,197 @@ fn create_expected_elicitation_request(
|
||
})?),
|
||
})
|
||
}
|
||
+
|
||
+/// Test that patch approval triggers an elicitation request to the MCP and that
|
||
+/// sending the approval applies the patch, as expected.
|
||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
+async fn test_patch_approval_triggers_elicitation() {
|
||
+ if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||
+ println!(
|
||
+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||
+ );
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ if let Err(err) = patch_approval_triggers_elicitation().await {
|
||
+ panic!("failure: {err}");
|
||
+ }
|
||
+}
|
||
+
|
||
+async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||
+ let cwd = TempDir::new()?;
|
||
+ let test_file = cwd.path().join("destination_file.txt");
|
||
+ std::fs::write(&test_file, "original content\n")?;
|
||
+
|
||
+ let patch_content = format!(
|
||
+ "*** Begin Patch\n*** Update File: {}\n-original content\n+modified content\n*** End Patch",
|
||
+ test_file.as_path().to_string_lossy()
|
||
+ );
|
||
+
|
||
+ let McpHandle {
|
||
+ process: mut mcp_process,
|
||
+ server: _server,
|
||
+ dir: _dir,
|
||
+ } = create_mcp_process(vec![
|
||
+ create_apply_patch_sse_response(&patch_content, "call1234")?,
|
||
+ create_final_assistant_message_sse_response("Patch has been applied successfully!")?,
|
||
+ ])
|
||
+ .await?;
|
||
+
|
||
+ // Send a "codex" tool request that will trigger the apply_patch command
|
||
+ let codex_request_id = mcp_process
|
||
+ .send_codex_tool_call(
|
||
+ Some(cwd.path().to_string_lossy().to_string()),
|
||
+ "please modify the test file",
|
||
+ )
|
||
+ .await?;
|
||
+ let elicitation_request = timeout(
|
||
+ DEFAULT_READ_TIMEOUT,
|
||
+ mcp_process.read_stream_until_request_message(),
|
||
+ )
|
||
+ .await??;
|
||
+
|
||
+ let elicitation_request_id = RequestId::Integer(0);
|
||
+
|
||
+ let mut expected_changes = HashMap::new();
|
||
+ expected_changes.insert(
|
||
+ test_file.as_path().to_path_buf(),
|
||
+ FileChange::Update {
|
||
+ unified_diff: "@@ -1 +1 @@\n-original content\n+modified content\n".to_string(),
|
||
+ move_path: None,
|
||
+ },
|
||
+ );
|
||
+
|
||
+ let expected_elicitation_request = create_expected_patch_approval_elicitation_request(
|
||
+ elicitation_request_id.clone(),
|
||
+ expected_changes,
|
||
+ None, // No grant_root expected
|
||
+ None, // No reason expected
|
||
+ codex_request_id.to_string(),
|
||
+ "1".to_string(),
|
||
+ )?;
|
||
+ assert_eq!(expected_elicitation_request, elicitation_request);
|
||
+
|
||
+ // Accept the patch approval request by responding to the elicitation
|
||
+ mcp_process
|
||
+ .send_response(
|
||
+ elicitation_request_id,
|
||
+ serde_json::to_value(PatchApprovalResponse {
|
||
+ decision: ReviewDecision::Approved,
|
||
+ })?,
|
||
+ )
|
||
+ .await?;
|
||
+
|
||
+ // Verify the original `codex` tool call completes
|
||
+ let codex_response = timeout(
|
||
+ DEFAULT_READ_TIMEOUT,
|
||
+ mcp_process.read_stream_until_response_message(RequestId::Integer(codex_request_id)),
|
||
+ )
|
||
+ .await??;
|
||
+ assert_eq!(
|
||
+ JSONRPCResponse {
|
||
+ jsonrpc: JSONRPC_VERSION.into(),
|
||
+ id: RequestId::Integer(codex_request_id),
|
||
+ result: json!({
|
||
+ "content": [
|
||
+ {
|
||
+ "text": "Patch has been applied successfully!",
|
||
+ "type": "text"
|
||
+ }
|
||
+ ]
|
||
+ }),
|
||
+ },
|
||
+ codex_response
|
||
+ );
|
||
+
|
||
+ let file_contents = std::fs::read_to_string(test_file.as_path())?;
|
||
+ assert_eq!(file_contents, "modified content\n");
|
||
+
|
||
+ Ok(())
|
||
+}
|
||
+
|
||
+fn create_expected_patch_approval_elicitation_request(
|
||
+ elicitation_request_id: RequestId,
|
||
+ changes: HashMap<PathBuf, FileChange>,
|
||
+ grant_root: Option<PathBuf>,
|
||
+ reason: Option<String>,
|
||
+ codex_mcp_tool_call_id: String,
|
||
+ codex_event_id: String,
|
||
+) -> anyhow::Result<JSONRPCRequest> {
|
||
+ let mut message_lines = Vec::new();
|
||
+ if let Some(r) = &reason {
|
||
+ message_lines.push(r.clone());
|
||
+ }
|
||
+ message_lines.push("Allow Codex to apply proposed code changes?".to_string());
|
||
+
|
||
+ Ok(JSONRPCRequest {
|
||
+ jsonrpc: JSONRPC_VERSION.into(),
|
||
+ id: elicitation_request_id,
|
||
+ method: ElicitRequest::METHOD.to_string(),
|
||
+ params: Some(serde_json::to_value(&PatchApprovalElicitRequestParams {
|
||
+ message: message_lines.join("\n"),
|
||
+ requested_schema: ElicitRequestParamsRequestedSchema {
|
||
+ r#type: "object".to_string(),
|
||
+ properties: json!({}),
|
||
+ required: None,
|
||
+ },
|
||
+ codex_elicitation: "patch-approval".to_string(),
|
||
+ codex_mcp_tool_call_id,
|
||
+ codex_event_id,
|
||
+ codex_reason: reason,
|
||
+ codex_grant_root: grant_root,
|
||
+ codex_changes: changes,
|
||
+ })?),
|
||
+ })
|
||
+}
|
||
+
|
||
+/// This handle is used to ensure that the MockServer and TempDir are not dropped while
|
||
+/// the McpProcess is still running.
|
||
+pub struct McpHandle {
|
||
+ pub process: McpProcess,
|
||
+ /// Retain the server for the lifetime of the McpProcess.
|
||
+ #[allow(dead_code)]
|
||
+ server: MockServer,
|
||
+ /// Retain the temporary directory for the lifetime of the McpProcess.
|
||
+ #[allow(dead_code)]
|
||
+ dir: TempDir,
|
||
+}
|
||
+
|
||
+async fn create_mcp_process(responses: Vec<String>) -> anyhow::Result<McpHandle> {
|
||
+ let server = create_mock_chat_completions_server(responses).await;
|
||
+ let codex_home = TempDir::new()?;
|
||
+ create_config_toml(codex_home.path(), &server.uri())?;
|
||
+ let mut mcp_process = McpProcess::new(codex_home.path()).await?;
|
||
+ timeout(DEFAULT_READ_TIMEOUT, mcp_process.initialize()).await??;
|
||
+ Ok(McpHandle {
|
||
+ process: mcp_process,
|
||
+ server,
|
||
+ dir: codex_home,
|
||
+ })
|
||
+}
|
||
+
|
||
+/// Create a Codex config that uses the mock server as the model provider.
|
||
+/// It also uses `approval_policy = "untrusted"` so that we exercise the
|
||
+/// elicitation code path for shell commands.
|
||
+fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
|
||
+ let config_toml = codex_home.join("config.toml");
|
||
+ std::fs::write(
|
||
+ config_toml,
|
||
+ format!(
|
||
+ r#"
|
||
+model = "mock-model"
|
||
+approval_policy = "untrusted"
|
||
+sandbox_policy = "read-only"
|
||
+
|
||
+model_provider = "mock_provider"
|
||
+
|
||
+[model_providers.mock_provider]
|
||
+name = "Mock provider for test"
|
||
+base_url = "{server_uri}/v1"
|
||
+wire_api = "chat"
|
||
+request_max_retries = 0
|
||
+stream_max_retries = 0
|
||
+"#
|
||
+ ),
|
||
+ )
|
||
+}
|
||
```
|
||
|
||
## Review Comments
|
||
|
||
### codex-rs/mcp-server/src/exec_approval.rs
|
||
|
||
- Created: 2025-07-22 05:53:19 UTC | Link: https://github.com/openai/codex/pull/1642#discussion_r2221300200
|
||
|
||
```diff
|
||
@@ -0,0 +1,147 @@
|
||
+use std::path::PathBuf;
|
||
+use std::sync::Arc;
|
||
+
|
||
+use codex_core::Codex;
|
||
+use codex_core::protocol::Op;
|
||
+use codex_core::protocol::ReviewDecision;
|
||
+use mcp_types::ElicitRequest;
|
||
+use mcp_types::ElicitRequestParamsRequestedSchema;
|
||
+use mcp_types::JSONRPCErrorError;
|
||
+use mcp_types::ModelContextProtocolRequest;
|
||
+use mcp_types::RequestId;
|
||
+use serde::Deserialize;
|
||
+use serde::Serialize;
|
||
+use serde_json::json;
|
||
+use tracing::error;
|
||
+
|
||
+use crate::codex_tool_runner::INVALID_PARAMS_ERROR_CODE;
|
||
+
|
||
+/// Conforms to [`mcp_types::ElicitRequestParams`] so that it can be used as the
|
||
+/// `params` field of an [`mcp_types::ElicitRequest`].
|
||
+#[derive(Debug, Serialize)]
|
||
+pub struct ExecApprovalElicitRequestParams {
|
||
+ // These fields are required so that `params`
|
||
+ // conforms to ElicitRequestParams.
|
||
+ pub message: String,
|
||
+
|
||
+ #[serde(rename = "requestedSchema")]
|
||
+ pub requested_schema: ElicitRequestParamsRequestedSchema,
|
||
+
|
||
+ // These are additional fields the client can use to
|
||
+ // correlate the request with the codex tool call.
|
||
+ pub codex_elicitation: String,
|
||
+ pub codex_mcp_tool_call_id: String,
|
||
+ pub codex_event_id: String,
|
||
+ pub codex_command: Vec<String>,
|
||
+ pub codex_cwd: PathBuf,
|
||
+}
|
||
+
|
||
+// TODO(mbolin): ExecApprovalResponse does not conform to ElicitResult. See:
|
||
+// - https://github.com/modelcontextprotocol/modelcontextprotocol/blob/f962dc1780fa5eed7fb7c8a0232f1fc83ef220cd/schema/2025-06-18/schema.json#L617-L636
|
||
+// - https://modelcontextprotocol.io/specification/draft/client/elicitation#protocol-messages
|
||
+// It should have "action" and "content" fields.
|
||
+#[derive(Debug, Serialize, Deserialize)]
|
||
+pub struct ExecApprovalResponse {
|
||
+ pub decision: ReviewDecision,
|
||
+}
|
||
+pub(crate) async fn handle_exec_approval_request(
|
||
```
|
||
|
||
> I don't know why `rustfmt` doesn't put a blank line here...
|
||
|
||
### codex-rs/mcp-server/src/patch_approval.rs
|
||
|
||
- Created: 2025-07-22 05:54:56 UTC | Link: https://github.com/openai/codex/pull/1642#discussion_r2221305230
|
||
|
||
```diff
|
||
@@ -0,0 +1,136 @@
|
||
+use std::path::PathBuf;
|
||
+use std::sync::Arc;
|
||
+
|
||
+use codex_core::Codex;
|
||
+use codex_core::protocol::FileChange;
|
||
+use codex_core::protocol::Op;
|
||
+use codex_core::protocol::ReviewDecision;
|
||
+use mcp_types::ElicitRequest;
|
||
+use mcp_types::ElicitRequestParamsRequestedSchema;
|
||
+use mcp_types::JSONRPCErrorError;
|
||
+use mcp_types::ModelContextProtocolRequest;
|
||
+use mcp_types::RequestId;
|
||
+use serde::Deserialize;
|
||
+use serde::Serialize;
|
||
+use serde_json::json;
|
||
+use tracing::error;
|
||
+
|
||
+use crate::codex_tool_runner::INVALID_PARAMS_ERROR_CODE;
|
||
+
|
||
+#[derive(Debug, Serialize)]
|
||
+pub struct PatchApprovalElicitRequestParams {
|
||
+ pub message: String,
|
||
+ #[serde(rename = "requestedSchema")]
|
||
+ pub requested_schema: ElicitRequestParamsRequestedSchema,
|
||
+ pub codex_elicitation: String,
|
||
+ pub codex_mcp_tool_call_id: String,
|
||
+ pub codex_event_id: String,
|
||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||
+ pub codex_reason: Option<String>,
|
||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||
+ pub codex_grant_root: Option<PathBuf>,
|
||
+ pub codex_changes: std::collections::HashMap<PathBuf, FileChange>,
|
||
```
|
||
|
||
> AI seems to like inline decls for common things like `HashMap` instead of adding a `use` statement...
|
||
|
||
- Created: 2025-07-22 05:56:42 UTC | Link: https://github.com/openai/codex/pull/1642#discussion_r2221310453
|
||
|
||
```diff
|
||
@@ -0,0 +1,136 @@
|
||
+use std::path::PathBuf;
|
||
+use std::sync::Arc;
|
||
+
|
||
+use codex_core::Codex;
|
||
+use codex_core::protocol::FileChange;
|
||
+use codex_core::protocol::Op;
|
||
+use codex_core::protocol::ReviewDecision;
|
||
+use mcp_types::ElicitRequest;
|
||
+use mcp_types::ElicitRequestParamsRequestedSchema;
|
||
+use mcp_types::JSONRPCErrorError;
|
||
+use mcp_types::ModelContextProtocolRequest;
|
||
+use mcp_types::RequestId;
|
||
+use serde::Deserialize;
|
||
+use serde::Serialize;
|
||
+use serde_json::json;
|
||
+use tracing::error;
|
||
+
|
||
+use crate::codex_tool_runner::INVALID_PARAMS_ERROR_CODE;
|
||
+
|
||
+#[derive(Debug, Serialize)]
|
||
+pub struct PatchApprovalElicitRequestParams {
|
||
+ pub message: String,
|
||
+ #[serde(rename = "requestedSchema")]
|
||
+ pub requested_schema: ElicitRequestParamsRequestedSchema,
|
||
+ pub codex_elicitation: String,
|
||
+ pub codex_mcp_tool_call_id: String,
|
||
+ pub codex_event_id: String,
|
||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||
+ pub codex_reason: Option<String>,
|
||
+ #[serde(skip_serializing_if = "Option::is_none")]
|
||
+ pub codex_grant_root: Option<PathBuf>,
|
||
+ pub codex_changes: std::collections::HashMap<PathBuf, FileChange>,
|
||
+}
|
||
+
|
||
+#[derive(Debug, Deserialize, Serialize)]
|
||
+pub struct PatchApprovalResponse {
|
||
+ pub decision: ReviewDecision,
|
||
+}
|
||
+
|
||
+#[allow(clippy::too_many_arguments)]
|
||
+pub(crate) async fn handle_patch_approval_request(
|
||
+ reason: Option<String>,
|
||
+ grant_root: Option<PathBuf>,
|
||
+ changes: std::collections::HashMap<PathBuf, FileChange>,
|
||
+ outgoing: Arc<crate::outgoing_message::OutgoingMessageSender>,
|
||
+ codex: Arc<Codex>,
|
||
+ request_id: RequestId,
|
||
+ tool_call_id: String,
|
||
+ event_id: String,
|
||
+) {
|
||
+ let mut message_lines = Vec::new();
|
||
+ if let Some(r) = &reason {
|
||
+ message_lines.push(r.clone());
|
||
+ }
|
||
+ message_lines.push("Allow Codex to apply proposed code changes?".to_string());
|
||
+
|
||
+ let params = PatchApprovalElicitRequestParams {
|
||
+ message: message_lines.join("\n"),
|
||
+ requested_schema: ElicitRequestParamsRequestedSchema {
|
||
+ r#type: "object".to_string(),
|
||
+ properties: json!({}),
|
||
+ required: None,
|
||
+ },
|
||
+ codex_elicitation: "patch-approval".to_string(),
|
||
+ codex_mcp_tool_call_id: tool_call_id.clone(),
|
||
+ codex_event_id: event_id.clone(),
|
||
+ codex_reason: reason,
|
||
+ codex_grant_root: grant_root,
|
||
+ codex_changes: changes,
|
||
+ };
|
||
+ let params_json = match serde_json::to_value(¶ms) {
|
||
+ Ok(value) => value,
|
||
+ Err(err) => {
|
||
+ let message = format!("Failed to serialize PatchApprovalElicitRequestParams: {err}");
|
||
+ error!("{message}");
|
||
+
|
||
+ outgoing
|
||
+ .send_error(
|
||
+ request_id.clone(),
|
||
+ JSONRPCErrorError {
|
||
+ code: INVALID_PARAMS_ERROR_CODE,
|
||
+ message,
|
||
+ data: None,
|
||
+ },
|
||
+ )
|
||
+ .await;
|
||
+
|
||
+ return;
|
||
+ }
|
||
+ };
|
||
+
|
||
+ let on_response = outgoing
|
||
+ .send_request(ElicitRequest::METHOD, Some(params_json))
|
||
+ .await;
|
||
+
|
||
+ // Listen for the response on a separate task so we don't block the main agent loop.
|
||
+ {
|
||
+ let codex = codex.clone();
|
||
+ let event_id = event_id.clone();
|
||
+ tokio::spawn(async move {
|
||
+ on_patch_approval_response(event_id, on_response, codex).await;
|
||
+ });
|
||
+ }
|
||
+}
|
||
+
|
||
+pub(crate) async fn on_patch_approval_response(
|
||
+ event_id: String,
|
||
+ receiver: tokio::sync::oneshot::Receiver<mcp_types::Result>,
|
||
+ codex: Arc<Codex>,
|
||
+) {
|
||
+ let response = receiver.await;
|
||
+ let value = match response {
|
||
+ Ok(value) => value,
|
||
+ Err(err) => {
|
||
+ error!("request failed: {err:?}");
|
||
```
|
||
|
||
> Shouldn't we send `Denied` in this case, too?
|
||
|
||
### codex-rs/mcp-server/tests/elicitation.rs
|
||
|
||
- Created: 2025-07-21 23:52:32 UTC | Link: https://github.com/openai/codex/pull/1642#discussion_r2220597057
|
||
|
||
```diff
|
||
@@ -145,7 +150,7 @@ fn create_config_toml(codex_home: &Path, server_uri: String) -> std::io::Result<
|
||
config_toml,
|
||
format!(
|
||
r#"
|
||
-model = "mock-model"
|
||
+model = "gpt-4.1-mock-model"
|
||
```
|
||
|
||
> Please keep the existing value and update the `apply_patch` call so it does not require the special 4.1 workaround.
|
||
|
||
- Created: 2025-07-22 06:04:15 UTC | Link: https://github.com/openai/codex/pull/1642#discussion_r2221323362
|
||
|
||
```diff
|
||
@@ -193,3 +168,208 @@ fn create_expected_elicitation_request(
|
||
})?),
|
||
})
|
||
}
|
||
+
|
||
+/// Test that patch approval triggers an elicitation request to the MCP and that
|
||
+/// sending the approval applies the patch, as expected.
|
||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
+async fn test_patch_approval_triggers_elicitation() {
|
||
+ if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||
+ println!(
|
||
+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||
+ );
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ if let Err(err) = patch_approval_triggers_elicitation().await {
|
||
+ panic!("failure: {err}");
|
||
+ }
|
||
+}
|
||
+
|
||
+struct TempCwdFile {
|
||
+ path: PathBuf,
|
||
+}
|
||
+
|
||
+impl TempCwdFile {
|
||
+ fn new(file_name: &str, content: &str) -> anyhow::Result<Self> {
|
||
+ // This file must be in a path that is a writable root on both macos and linux.
|
||
+ // TmpDir is writable by default on macos but not linux.
|
||
+ let path = env::current_dir()?.join(file_name);
|
||
+ std::fs::write(&path, content)?;
|
||
+ Ok(Self { path })
|
||
+ }
|
||
+}
|
||
```
|
||
|
||
> Please do not write a file to the current directory.
|
||
>
|
||
> Instead of this `TempCwdFile` thing, you should be setting `cwd` for the tool call here:
|
||
>
|
||
> https://github.com/openai/codex/blob/6cf4b96f9dbbef8a94acc1ff703eb118481514d8/codex-rs/mcp-server/tests/common/mcp_process.rs#L149
|
||
>
|
||
> so you'll have to update `send_codex_tool_call()` to take `cwd: Option<PathBuf>`.
|
||
|
||
- Created: 2025-07-22 06:05:43 UTC | Link: https://github.com/openai/codex/pull/1642#discussion_r2221325362
|
||
|
||
```diff
|
||
@@ -193,3 +168,208 @@ fn create_expected_elicitation_request(
|
||
})?),
|
||
})
|
||
}
|
||
+
|
||
+/// Test that patch approval triggers an elicitation request to the MCP and that
|
||
+/// sending the approval applies the patch, as expected.
|
||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
+async fn test_patch_approval_triggers_elicitation() {
|
||
+ if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||
+ println!(
|
||
+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||
+ );
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ if let Err(err) = patch_approval_triggers_elicitation().await {
|
||
+ panic!("failure: {err}");
|
||
+ }
|
||
+}
|
||
+
|
||
+struct TempCwdFile {
|
||
+ path: PathBuf,
|
||
+}
|
||
+
|
||
+impl TempCwdFile {
|
||
+ fn new(file_name: &str, content: &str) -> anyhow::Result<Self> {
|
||
+ // This file must be in a path that is a writable root on both macos and linux.
|
||
+ // TmpDir is writable by default on macos but not linux.
|
||
+ let path = env::current_dir()?.join(file_name);
|
||
+ std::fs::write(&path, content)?;
|
||
+ Ok(Self { path })
|
||
+ }
|
||
+}
|
||
+
|
||
+impl Drop for TempCwdFile {
|
||
+ fn drop(&mut self) {
|
||
+ let _ = std::fs::remove_file(&self.path);
|
||
+ }
|
||
+}
|
||
+
|
||
+async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||
+ let test_file = TempCwdFile::new("test_patch_file.txt", "original content")?;
|
||
+
|
||
+ let patch_content = format!(
|
||
+ "*** Begin Patch\n*** Update File: {}\n-original content\n+modified content\n*** End Patch",
|
||
+ test_file.path.to_string_lossy()
|
||
+ );
|
||
+
|
||
+ let McpHandle {
|
||
+ process: mut mcp_process,
|
||
+ _server,
|
||
+ _dir,
|
||
+ } = create_mcp_process(vec![
|
||
+ create_apply_patch_sse_response(&patch_content, "call1234")?,
|
||
+ create_final_assistant_message_sse_response("Patch has been applied successfully!")?,
|
||
+ ])
|
||
+ .await?;
|
||
+
|
||
+ // Send a "codex" tool request that will trigger the apply_patch command
|
||
+ let codex_request_id = mcp_process
|
||
+ .send_codex_tool_call("please modify the test file")
|
||
+ .await?;
|
||
+ let elicitation_request = timeout(
|
||
+ DEFAULT_READ_TIMEOUT,
|
||
+ mcp_process.read_stream_until_request_message(),
|
||
+ )
|
||
+ .await??;
|
||
+
|
||
+ let elicitation_request_id = RequestId::Integer(0);
|
||
+
|
||
+ let mut expected_changes = HashMap::new();
|
||
+ expected_changes.insert(
|
||
+ test_file.path.clone(),
|
||
+ FileChange::Update {
|
||
+ unified_diff:
|
||
+ "@@ -1 +1 @@\n-original content\n\\ No newline at end of file\n+modified content\n"
|
||
+ .to_string(),
|
||
+ move_path: None,
|
||
+ },
|
||
+ );
|
||
+
|
||
+ let expected_elicitation_request = create_expected_patch_approval_elicitation_request(
|
||
+ elicitation_request_id.clone(),
|
||
+ expected_changes,
|
||
+ None, // No grant_root expected
|
||
+ None, // No reason expected
|
||
+ codex_request_id.to_string(),
|
||
+ "1".to_string(),
|
||
+ )?;
|
||
+ assert_eq!(expected_elicitation_request, elicitation_request);
|
||
+
|
||
+ // Accept the patch approval request by responding to the elicitation
|
||
+ mcp_process
|
||
+ .send_response(
|
||
+ elicitation_request_id,
|
||
+ serde_json::to_value(PatchApprovalResponse {
|
||
+ decision: ReviewDecision::Approved,
|
||
+ })?,
|
||
+ )
|
||
+ .await?;
|
||
+
|
||
+ // Verify the original `codex` tool call completes
|
||
+ let codex_response = timeout(
|
||
+ DEFAULT_READ_TIMEOUT,
|
||
+ mcp_process.read_stream_until_response_message(RequestId::Integer(codex_request_id)),
|
||
+ )
|
||
+ .await??;
|
||
+ assert_eq!(
|
||
+ JSONRPCResponse {
|
||
+ jsonrpc: JSONRPC_VERSION.into(),
|
||
+ id: RequestId::Integer(codex_request_id),
|
||
+ result: json!({
|
||
+ "content": [
|
||
+ {
|
||
+ "text": "Patch has been applied successfully!",
|
||
+ "type": "text"
|
||
+ }
|
||
+ ]
|
||
+ }),
|
||
+ },
|
||
+ codex_response
|
||
+ );
|
||
+
|
||
+ let file_contents = std::fs::read_to_string(&test_file.path)?;
|
||
+ assert_eq!(file_contents, "modified content\n");
|
||
+
|
||
+ Ok(())
|
||
+}
|
||
+
|
||
+fn create_expected_patch_approval_elicitation_request(
|
||
+ elicitation_request_id: RequestId,
|
||
+ changes: HashMap<PathBuf, FileChange>,
|
||
+ grant_root: Option<PathBuf>,
|
||
+ reason: Option<String>,
|
||
+ codex_mcp_tool_call_id: String,
|
||
+ codex_event_id: String,
|
||
+) -> anyhow::Result<JSONRPCRequest> {
|
||
+ let mut message_lines = Vec::new();
|
||
+ if let Some(r) = &reason {
|
||
+ message_lines.push(r.clone());
|
||
+ }
|
||
+ message_lines.push("Allow Codex to apply proposed code changes?".to_string());
|
||
+
|
||
+ Ok(JSONRPCRequest {
|
||
+ jsonrpc: JSONRPC_VERSION.into(),
|
||
+ id: elicitation_request_id,
|
||
+ method: ElicitRequest::METHOD.to_string(),
|
||
+ params: Some(serde_json::to_value(&PatchApprovalElicitRequestParams {
|
||
+ message: message_lines.join("\n"),
|
||
+ requested_schema: ElicitRequestParamsRequestedSchema {
|
||
+ r#type: "object".to_string(),
|
||
+ properties: json!({}),
|
||
+ required: None,
|
||
+ },
|
||
+ codex_elicitation: "patch-approval".to_string(),
|
||
+ codex_mcp_tool_call_id,
|
||
+ codex_event_id,
|
||
+ codex_reason: reason,
|
||
+ codex_grant_root: grant_root,
|
||
+ codex_changes: changes,
|
||
+ })?),
|
||
+ })
|
||
+}
|
||
+
|
||
+pub struct McpHandle {
|
||
+ pub process: McpProcess,
|
||
+ _server: MockServer,
|
||
```
|
||
|
||
> Rather than prefix these with underscores, I favor doing something like the following:
|
||
>
|
||
> https://github.com/openai/codex/blob/6cf4b96f9dbbef8a94acc1ff703eb118481514d8/codex-rs/mcp-client/src/mcp_client.rs#L63-L67
|
||
>
|
||
|
||
- Created: 2025-07-22 06:06:05 UTC | Link: https://github.com/openai/codex/pull/1642#discussion_r2221325943
|
||
|
||
```diff
|
||
@@ -193,3 +168,208 @@ fn create_expected_elicitation_request(
|
||
})?),
|
||
})
|
||
}
|
||
+
|
||
+/// Test that patch approval triggers an elicitation request to the MCP and that
|
||
+/// sending the approval applies the patch, as expected.
|
||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
+async fn test_patch_approval_triggers_elicitation() {
|
||
+ if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||
+ println!(
|
||
+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||
+ );
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ if let Err(err) = patch_approval_triggers_elicitation().await {
|
||
+ panic!("failure: {err}");
|
||
+ }
|
||
+}
|
||
+
|
||
+struct TempCwdFile {
|
||
+ path: PathBuf,
|
||
+}
|
||
+
|
||
+impl TempCwdFile {
|
||
+ fn new(file_name: &str, content: &str) -> anyhow::Result<Self> {
|
||
+ // This file must be in a path that is a writable root on both macos and linux.
|
||
+ // TmpDir is writable by default on macos but not linux.
|
||
+ let path = env::current_dir()?.join(file_name);
|
||
+ std::fs::write(&path, content)?;
|
||
+ Ok(Self { path })
|
||
+ }
|
||
+}
|
||
+
|
||
+impl Drop for TempCwdFile {
|
||
+ fn drop(&mut self) {
|
||
+ let _ = std::fs::remove_file(&self.path);
|
||
+ }
|
||
+}
|
||
+
|
||
+async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||
+ let test_file = TempCwdFile::new("test_patch_file.txt", "original content")?;
|
||
+
|
||
+ let patch_content = format!(
|
||
+ "*** Begin Patch\n*** Update File: {}\n-original content\n+modified content\n*** End Patch",
|
||
+ test_file.path.to_string_lossy()
|
||
+ );
|
||
+
|
||
+ let McpHandle {
|
||
+ process: mut mcp_process,
|
||
+ _server,
|
||
+ _dir,
|
||
+ } = create_mcp_process(vec![
|
||
+ create_apply_patch_sse_response(&patch_content, "call1234")?,
|
||
+ create_final_assistant_message_sse_response("Patch has been applied successfully!")?,
|
||
+ ])
|
||
+ .await?;
|
||
+
|
||
+ // Send a "codex" tool request that will trigger the apply_patch command
|
||
+ let codex_request_id = mcp_process
|
||
+ .send_codex_tool_call("please modify the test file")
|
||
+ .await?;
|
||
+ let elicitation_request = timeout(
|
||
+ DEFAULT_READ_TIMEOUT,
|
||
+ mcp_process.read_stream_until_request_message(),
|
||
+ )
|
||
+ .await??;
|
||
+
|
||
+ let elicitation_request_id = RequestId::Integer(0);
|
||
+
|
||
+ let mut expected_changes = HashMap::new();
|
||
+ expected_changes.insert(
|
||
+ test_file.path.clone(),
|
||
+ FileChange::Update {
|
||
+ unified_diff:
|
||
+ "@@ -1 +1 @@\n-original content\n\\ No newline at end of file\n+modified content\n"
|
||
+ .to_string(),
|
||
+ move_path: None,
|
||
+ },
|
||
+ );
|
||
+
|
||
+ let expected_elicitation_request = create_expected_patch_approval_elicitation_request(
|
||
+ elicitation_request_id.clone(),
|
||
+ expected_changes,
|
||
+ None, // No grant_root expected
|
||
+ None, // No reason expected
|
||
+ codex_request_id.to_string(),
|
||
+ "1".to_string(),
|
||
+ )?;
|
||
+ assert_eq!(expected_elicitation_request, elicitation_request);
|
||
+
|
||
+ // Accept the patch approval request by responding to the elicitation
|
||
+ mcp_process
|
||
+ .send_response(
|
||
+ elicitation_request_id,
|
||
+ serde_json::to_value(PatchApprovalResponse {
|
||
+ decision: ReviewDecision::Approved,
|
||
+ })?,
|
||
+ )
|
||
+ .await?;
|
||
+
|
||
+ // Verify the original `codex` tool call completes
|
||
+ let codex_response = timeout(
|
||
+ DEFAULT_READ_TIMEOUT,
|
||
+ mcp_process.read_stream_until_response_message(RequestId::Integer(codex_request_id)),
|
||
+ )
|
||
+ .await??;
|
||
+ assert_eq!(
|
||
+ JSONRPCResponse {
|
||
+ jsonrpc: JSONRPC_VERSION.into(),
|
||
+ id: RequestId::Integer(codex_request_id),
|
||
+ result: json!({
|
||
+ "content": [
|
||
+ {
|
||
+ "text": "Patch has been applied successfully!",
|
||
+ "type": "text"
|
||
+ }
|
||
+ ]
|
||
+ }),
|
||
+ },
|
||
+ codex_response
|
||
+ );
|
||
+
|
||
+ let file_contents = std::fs::read_to_string(&test_file.path)?;
|
||
+ assert_eq!(file_contents, "modified content\n");
|
||
+
|
||
+ Ok(())
|
||
+}
|
||
+
|
||
+fn create_expected_patch_approval_elicitation_request(
|
||
+ elicitation_request_id: RequestId,
|
||
+ changes: HashMap<PathBuf, FileChange>,
|
||
+ grant_root: Option<PathBuf>,
|
||
+ reason: Option<String>,
|
||
+ codex_mcp_tool_call_id: String,
|
||
+ codex_event_id: String,
|
||
+) -> anyhow::Result<JSONRPCRequest> {
|
||
+ let mut message_lines = Vec::new();
|
||
+ if let Some(r) = &reason {
|
||
+ message_lines.push(r.clone());
|
||
+ }
|
||
+ message_lines.push("Allow Codex to apply proposed code changes?".to_string());
|
||
+
|
||
+ Ok(JSONRPCRequest {
|
||
+ jsonrpc: JSONRPC_VERSION.into(),
|
||
+ id: elicitation_request_id,
|
||
+ method: ElicitRequest::METHOD.to_string(),
|
||
+ params: Some(serde_json::to_value(&PatchApprovalElicitRequestParams {
|
||
+ message: message_lines.join("\n"),
|
||
+ requested_schema: ElicitRequestParamsRequestedSchema {
|
||
+ r#type: "object".to_string(),
|
||
+ properties: json!({}),
|
||
+ required: None,
|
||
+ },
|
||
+ codex_elicitation: "patch-approval".to_string(),
|
||
+ codex_mcp_tool_call_id,
|
||
+ codex_event_id,
|
||
+ codex_reason: reason,
|
||
+ codex_grant_root: grant_root,
|
||
+ codex_changes: changes,
|
||
+ })?),
|
||
+ })
|
||
+}
|
||
+
|
||
+pub struct McpHandle {
|
||
```
|
||
|
||
> Maybe add a doc comment that this is here to ensure certain objects stay alive for the lifetime of the test?
|
||
|
||
- Created: 2025-07-22 06:51:44 UTC | Link: https://github.com/openai/codex/pull/1642#discussion_r2221404847
|
||
|
||
```diff
|
||
@@ -49,12 +58,11 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||
let shell_command = vec!["git".to_string(), "init".to_string()];
|
||
let workdir_for_shell_function_call = TempDir::new()?;
|
||
|
||
- // Configure the mock server so it makes two responses:
|
||
- // 1. The first response is a shell function call that will trigger an
|
||
- // elicitation request.
|
||
- // 2. The second response is the final assistant message that should be
|
||
- // returned after the elicitation is approved and the command is run.
|
||
- let server = create_mock_chat_completions_server(vec![
|
||
+ let McpHandle {
|
||
+ process: mut mcp_process,
|
||
+ server: _server,
|
||
+ dir: _dir,
|
||
+ } = create_mcp_process(vec![
|
||
```
|
||
|
||
> Fewer underscores:
|
||
>
|
||
> ```suggestion
|
||
> let McpHandle { process: mut mcp_process, .. } = create_mcp_process(vec![
|
||
> ```
|
||
|
||
- Created: 2025-07-22 06:53:07 UTC | Link: https://github.com/openai/codex/pull/1642#discussion_r2221408077
|
||
|
||
```diff
|
||
@@ -193,3 +171,197 @@ fn create_expected_elicitation_request(
|
||
})?),
|
||
})
|
||
}
|
||
+
|
||
+/// Test that patch approval triggers an elicitation request to the MCP and that
|
||
+/// sending the approval applies the patch, as expected.
|
||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
+async fn test_patch_approval_triggers_elicitation() {
|
||
+ if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||
+ println!(
|
||
+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||
+ );
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ if let Err(err) = patch_approval_triggers_elicitation().await {
|
||
+ panic!("failure: {err}");
|
||
+ }
|
||
+}
|
||
+
|
||
+async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||
+ let cwd = TempDir::new()?;
|
||
+ let test_file = NamedTempFile::new_in(cwd.path())?;
|
||
```
|
||
|
||
> Consider eliminating the need for `NamedTempFile` with something like:
|
||
>
|
||
> ```suggestion
|
||
> let test_file = cwd.path().join("destination_file.txt");
|
||
> ```
|
||
|
||
- Created: 2025-07-22 06:53:35 UTC | Link: https://github.com/openai/codex/pull/1642#discussion_r2221408942
|
||
|
||
```diff
|
||
@@ -193,3 +171,197 @@ fn create_expected_elicitation_request(
|
||
})?),
|
||
})
|
||
}
|
||
+
|
||
+/// Test that patch approval triggers an elicitation request to the MCP and that
|
||
+/// sending the approval applies the patch, as expected.
|
||
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
+async fn test_patch_approval_triggers_elicitation() {
|
||
+ if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||
+ println!(
|
||
+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||
+ );
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ if let Err(err) = patch_approval_triggers_elicitation().await {
|
||
+ panic!("failure: {err}");
|
||
+ }
|
||
+}
|
||
+
|
||
+async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||
+ let cwd = TempDir::new()?;
|
||
+ let test_file = NamedTempFile::new_in(cwd.path())?;
|
||
+ std::fs::write(&test_file, "original content\n")?;
|
||
+
|
||
+ let patch_content = format!(
|
||
+ "*** Begin Patch\n*** Update File: {}\n-original content\n+modified content\n*** End Patch",
|
||
+ test_file.path().to_string_lossy()
|
||
+ );
|
||
+
|
||
+ let McpHandle {
|
||
+ process: mut mcp_process,
|
||
+ server: _server,
|
||
+ dir: _dir,
|
||
+ } = create_mcp_process(vec![
|
||
```
|
||
|
||
> Again:
|
||
>
|
||
> ```suggestion
|
||
> let McpHandle {
|
||
> process: mut mcp_process,
|
||
> ..
|
||
> } = create_mcp_process(vec![
|
||
> ``` |