mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(acp): integrate ACP support and rename MCP server
This commit is contained in:
Generated
+59
-32
@@ -27,6 +27,17 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "agent-client-protocol"
|
||||
version = "0.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4255a06cc2414033d1fe4baf1968bcc8f16d7e5814f272b97779b5806d129142"
|
||||
dependencies = [
|
||||
"schemars 1.0.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -785,6 +796,36 @@ version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21"
|
||||
|
||||
[[package]]
|
||||
name = "code-mcp-server"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
"codex-arg0",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-login",
|
||||
"codex-protocol",
|
||||
"codex-version",
|
||||
"mcp-types",
|
||||
"mcp_test_support",
|
||||
"os_info",
|
||||
"pretty_assertions",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shlex",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml 0.9.5",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-ansi-escape"
|
||||
version = "0.0.0"
|
||||
@@ -805,6 +846,7 @@ dependencies = [
|
||||
"similar",
|
||||
"tempfile",
|
||||
"thiserror 2.0.16",
|
||||
"tokio",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
]
|
||||
@@ -872,13 +914,13 @@ dependencies = [
|
||||
"assert_cmd",
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"code-mcp-server",
|
||||
"codex-arg0",
|
||||
"codex-chatgpt",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-exec",
|
||||
"codex-login",
|
||||
"codex-mcp-server",
|
||||
"codex-protocol",
|
||||
"codex-protocol-ts",
|
||||
"codex-tui",
|
||||
@@ -916,6 +958,7 @@ dependencies = [
|
||||
name = "codex-core"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
"askama",
|
||||
"assert_cmd",
|
||||
@@ -1089,35 +1132,6 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-mcp-server"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
"codex-arg0",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-login",
|
||||
"codex-protocol",
|
||||
"codex-version",
|
||||
"mcp-types",
|
||||
"mcp_test_support",
|
||||
"os_info",
|
||||
"pretty_assertions",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shlex",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml 0.9.5",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-mcp-smoke"
|
||||
version = "0.0.0"
|
||||
@@ -3353,8 +3367,8 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
"code-mcp-server",
|
||||
"codex-core",
|
||||
"codex-mcp-server",
|
||||
"codex-protocol",
|
||||
"mcp-types",
|
||||
"os_info",
|
||||
@@ -4836,7 +4850,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"schemars_derive",
|
||||
"schemars_derive 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -4861,6 +4875,7 @@ checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"schemars_derive 1.0.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -4877,6 +4892,18 @@ dependencies = [
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars_derive"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
|
||||
@@ -21,6 +21,7 @@ thiserror = "2.0.16"
|
||||
tree-sitter = "0.25.9"
|
||||
tree-sitter-bash = "0.25.0"
|
||||
once_cell = "1"
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread"] }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
|
||||
+220
-652
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,6 @@
|
||||
//!
|
||||
//! The parser below is a little more lenient than the explicit spec and allows for
|
||||
//! leading/trailing whitespace around patch markers.
|
||||
use crate::ApplyPatchArgs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -103,7 +102,7 @@ pub struct UpdateFileChunk {
|
||||
pub is_end_of_file: bool,
|
||||
}
|
||||
|
||||
pub fn parse_patch(patch: &str) -> Result<ApplyPatchArgs, ParseError> {
|
||||
pub fn parse_patch(patch: &str) -> Result<Vec<Hunk>, ParseError> {
|
||||
let mode = if PARSE_IN_STRICT_MODE {
|
||||
ParseMode::Strict
|
||||
} else {
|
||||
@@ -151,7 +150,7 @@ enum ParseMode {
|
||||
Lenient,
|
||||
}
|
||||
|
||||
fn parse_patch_text(patch: &str, mode: ParseMode) -> Result<ApplyPatchArgs, ParseError> {
|
||||
fn parse_patch_text(patch: &str, mode: ParseMode) -> Result<Vec<Hunk>, ParseError> {
|
||||
let lines: Vec<&str> = patch.trim().lines().collect();
|
||||
let lines: &[&str] = match check_patch_boundaries_strict(&lines) {
|
||||
Ok(()) => &lines,
|
||||
@@ -174,12 +173,7 @@ fn parse_patch_text(patch: &str, mode: ParseMode) -> Result<ApplyPatchArgs, Pars
|
||||
line_number += hunk_lines;
|
||||
remaining_lines = &remaining_lines[hunk_lines..]
|
||||
}
|
||||
let patch = lines.join("\n");
|
||||
Ok(ApplyPatchArgs {
|
||||
hunks,
|
||||
patch,
|
||||
workdir: None,
|
||||
})
|
||||
Ok(hunks)
|
||||
}
|
||||
|
||||
/// Checks the start and end lines of the patch text for `apply_patch`,
|
||||
@@ -461,10 +455,8 @@ fn test_parse_patch() {
|
||||
"*** Begin Patch\n\
|
||||
*** End Patch",
|
||||
ParseMode::Strict
|
||||
)
|
||||
.unwrap()
|
||||
.hunks,
|
||||
Vec::new()
|
||||
),
|
||||
Ok(Vec::new())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_patch_text(
|
||||
@@ -480,10 +472,8 @@ fn test_parse_patch() {
|
||||
+ return 123\n\
|
||||
*** End Patch",
|
||||
ParseMode::Strict
|
||||
)
|
||||
.unwrap()
|
||||
.hunks,
|
||||
vec![
|
||||
),
|
||||
Ok(vec![
|
||||
AddFile {
|
||||
path: PathBuf::from("path/add.py"),
|
||||
contents: "abc\ndef\n".to_string()
|
||||
@@ -501,7 +491,7 @@ fn test_parse_patch() {
|
||||
is_end_of_file: false
|
||||
}]
|
||||
}
|
||||
]
|
||||
])
|
||||
);
|
||||
// Update hunk followed by another hunk (Add File).
|
||||
assert_eq!(
|
||||
@@ -514,10 +504,8 @@ fn test_parse_patch() {
|
||||
+content\n\
|
||||
*** End Patch",
|
||||
ParseMode::Strict
|
||||
)
|
||||
.unwrap()
|
||||
.hunks,
|
||||
vec![
|
||||
),
|
||||
Ok(vec![
|
||||
UpdateFile {
|
||||
path: PathBuf::from("file.py"),
|
||||
move_path: None,
|
||||
@@ -532,7 +520,7 @@ fn test_parse_patch() {
|
||||
path: PathBuf::from("other.py"),
|
||||
contents: "content\n".to_string()
|
||||
}
|
||||
]
|
||||
])
|
||||
);
|
||||
|
||||
// Update hunk without an explicit @@ header for the first chunk should parse.
|
||||
@@ -545,10 +533,8 @@ fn test_parse_patch() {
|
||||
+bar
|
||||
*** End Patch"#,
|
||||
ParseMode::Strict
|
||||
)
|
||||
.unwrap()
|
||||
.hunks,
|
||||
vec![UpdateFile {
|
||||
),
|
||||
Ok(vec![UpdateFile {
|
||||
path: PathBuf::from("file2.py"),
|
||||
move_path: None,
|
||||
chunks: vec![UpdateFileChunk {
|
||||
@@ -557,7 +543,7 @@ fn test_parse_patch() {
|
||||
new_lines: vec!["import foo".to_string(), "bar".to_string()],
|
||||
is_end_of_file: false,
|
||||
}],
|
||||
}]
|
||||
}])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -588,11 +574,7 @@ fn test_parse_patch_lenient() {
|
||||
);
|
||||
assert_eq!(
|
||||
parse_patch_text(&patch_text_in_heredoc, ParseMode::Lenient),
|
||||
Ok(ApplyPatchArgs {
|
||||
hunks: expected_patch.clone(),
|
||||
patch: patch_text.to_string(),
|
||||
workdir: None,
|
||||
})
|
||||
Ok(expected_patch.clone())
|
||||
);
|
||||
|
||||
let patch_text_in_single_quoted_heredoc = format!("<<'EOF'\n{patch_text}\nEOF\n");
|
||||
@@ -602,11 +584,7 @@ fn test_parse_patch_lenient() {
|
||||
);
|
||||
assert_eq!(
|
||||
parse_patch_text(&patch_text_in_single_quoted_heredoc, ParseMode::Lenient),
|
||||
Ok(ApplyPatchArgs {
|
||||
hunks: expected_patch.clone(),
|
||||
patch: patch_text.to_string(),
|
||||
workdir: None,
|
||||
})
|
||||
Ok(expected_patch.clone())
|
||||
);
|
||||
|
||||
let patch_text_in_double_quoted_heredoc = format!("<<\"EOF\"\n{patch_text}\nEOF\n");
|
||||
@@ -616,11 +594,7 @@ fn test_parse_patch_lenient() {
|
||||
);
|
||||
assert_eq!(
|
||||
parse_patch_text(&patch_text_in_double_quoted_heredoc, ParseMode::Lenient),
|
||||
Ok(ApplyPatchArgs {
|
||||
hunks: expected_patch,
|
||||
patch: patch_text.to_string(),
|
||||
workdir: None,
|
||||
})
|
||||
Ok(expected_patch.clone())
|
||||
);
|
||||
|
||||
let patch_text_in_mismatched_quotes_heredoc = format!("<<\"EOF'\n{patch_text}\nEOF\n");
|
||||
@@ -637,7 +611,7 @@ fn test_parse_patch_lenient() {
|
||||
"<<EOF\n*** Begin Patch\n*** Update File: file2.py\nEOF\n".to_string();
|
||||
assert_eq!(
|
||||
parse_patch_text(&patch_text_with_missing_closing_heredoc, ParseMode::Strict),
|
||||
Err(expected_error)
|
||||
Err(expected_error.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_patch_text(&patch_text_with_missing_closing_heredoc, ParseMode::Lenient),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
pub fn main() -> ! {
|
||||
let exit_code = run_main();
|
||||
@@ -48,7 +49,9 @@ pub fn run_main() -> i32 {
|
||||
|
||||
let mut stdout = std::io::stdout();
|
||||
let mut stderr = std::io::stderr();
|
||||
match crate::apply_patch(&patch_arg, &mut stdout, &mut stderr) {
|
||||
let fs = crate::StdFileSystem;
|
||||
let runtime = Runtime::new().expect("failed to create tokio runtime for apply_patch");
|
||||
match runtime.block_on(crate::apply_patch(&patch_arg, &mut stdout, &mut stderr, &fs)) {
|
||||
Ok(()) => {
|
||||
// Flush to ensure output ordering when used in pipelines.
|
||||
let _ = stdout.flush();
|
||||
|
||||
@@ -61,7 +61,17 @@ where
|
||||
Some(patch_arg) => {
|
||||
let mut stdout = std::io::stdout();
|
||||
let mut stderr = std::io::stderr();
|
||||
match codex_apply_patch::apply_patch(&patch_arg, &mut stdout, &mut stderr) {
|
||||
let fs = codex_apply_patch::StdFileSystem;
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("failed to build apply_patch runtime");
|
||||
match rt.block_on(codex_apply_patch::apply_patch(
|
||||
&patch_arg,
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
&fs,
|
||||
)) {
|
||||
Ok(()) => 0,
|
||||
Err(_) => 1,
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ codex-common = { path = "../common", features = ["cli"] }
|
||||
codex-core = { path = "../core" }
|
||||
codex-exec = { path = "../exec" }
|
||||
codex-login = { path = "../login" }
|
||||
codex-mcp-server = { path = "../mcp-server" }
|
||||
code-mcp-server = { path = "../mcp-server" }
|
||||
codex-protocol = { path = "../protocol" }
|
||||
codex-tui = { path = "../tui" }
|
||||
codex-version = { path = "../codex-version" }
|
||||
|
||||
@@ -96,7 +96,7 @@ impl McpCli {
|
||||
|
||||
match subcommand {
|
||||
McpSubcommand::Serve => {
|
||||
codex_mcp_server::run_main(codex_linux_sandbox_exe, config_overrides).await?;
|
||||
code_mcp_server::run_main(codex_linux_sandbox_exe, config_overrides).await?;
|
||||
}
|
||||
McpSubcommand::List(args) => {
|
||||
run_list(&config_overrides, args)?;
|
||||
|
||||
@@ -22,6 +22,7 @@ codex-apply-patch = { path = "../apply-patch" }
|
||||
codex-file-search = { path = "../file-search" }
|
||||
codex-mcp-client = { path = "../mcp-client" }
|
||||
codex-protocol = { path = "../protocol" }
|
||||
agent-client-protocol = "0.0.13"
|
||||
dirs = "6"
|
||||
env-flags = "0.1.1"
|
||||
eventsource-stream = "0.2.3"
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use anyhow::Context as _;
|
||||
use anyhow::Result;
|
||||
use codex_apply_patch::FileSystem;
|
||||
use codex_apply_patch::StdFileSystem;
|
||||
use mcp_types::CallToolResult;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::protocol::FileChange;
|
||||
use crate::protocol::ReviewDecision;
|
||||
use crate::util::strip_bash_lc_and_escape;
|
||||
|
||||
pub(crate) struct AcpFileSystem<'a> {
|
||||
session_id: Uuid,
|
||||
mcp_connection_manager: &'a McpConnectionManager,
|
||||
tools: &'a acp::ClientTools,
|
||||
}
|
||||
|
||||
impl<'a> AcpFileSystem<'a> {
|
||||
pub fn new(
|
||||
session_id: Uuid,
|
||||
tools: &'a acp::ClientTools,
|
||||
mcp_connection_manager: &'a McpConnectionManager,
|
||||
) -> Self {
|
||||
Self {
|
||||
session_id,
|
||||
mcp_connection_manager,
|
||||
tools,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_text_file_impl(
|
||||
&self,
|
||||
tool: &acp::McpToolId,
|
||||
path: &Path,
|
||||
) -> Result<String> {
|
||||
let arguments = acp::ReadTextFileArguments {
|
||||
session_id: acp::SessionId(self.session_id.to_string().into()),
|
||||
path: path.to_path_buf(),
|
||||
line: None,
|
||||
limit: None,
|
||||
};
|
||||
|
||||
let CallToolResult {
|
||||
structured_content,
|
||||
is_error,
|
||||
..
|
||||
} = self
|
||||
.mcp_connection_manager
|
||||
.call_tool(
|
||||
&tool.mcp_server,
|
||||
&tool.tool_name,
|
||||
Some(serde_json::to_value(arguments).unwrap_or_default()),
|
||||
Some(Duration::from_secs(15)),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if is_error.unwrap_or_default() {
|
||||
anyhow::bail!("Error reading text file: {:?}", structured_content);
|
||||
}
|
||||
|
||||
let output = serde_json::from_value::<acp::ReadTextFileOutput>(
|
||||
structured_content.context("No output from read_text_file tool")?,
|
||||
)?;
|
||||
|
||||
Ok(output.content)
|
||||
}
|
||||
|
||||
async fn write_text_file_impl(
|
||||
&self,
|
||||
tool: &acp::McpToolId,
|
||||
path: &Path,
|
||||
content: String,
|
||||
) -> Result<()> {
|
||||
let arguments = acp::WriteTextFileArguments {
|
||||
session_id: acp::SessionId(self.session_id.to_string().into()),
|
||||
path: path.to_path_buf(),
|
||||
content,
|
||||
};
|
||||
|
||||
let CallToolResult {
|
||||
structured_content,
|
||||
is_error,
|
||||
..
|
||||
} = self
|
||||
.mcp_connection_manager
|
||||
.call_tool(
|
||||
&tool.mcp_server,
|
||||
&tool.tool_name,
|
||||
Some(serde_json::to_value(arguments).unwrap_or_default()),
|
||||
Some(Duration::from_secs(15)),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if is_error.unwrap_or_default() {
|
||||
anyhow::bail!("Error writing text file: {:?}", structured_content);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> FileSystem for AcpFileSystem<'a> {
|
||||
async fn read_text_file(&self, path: &Path) -> std::io::Result<String> {
|
||||
if let Some(tool) = self.tools.read_text_file.as_ref() {
|
||||
self.read_text_file_impl(tool, path)
|
||||
.await
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))
|
||||
} else {
|
||||
StdFileSystem.read_text_file(path).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_text_file(&self, path: &Path, contents: String) -> std::io::Result<()> {
|
||||
if let Some(tool) = self.tools.write_text_file.as_ref() {
|
||||
self.write_text_file_impl(tool, path, contents)
|
||||
.await
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))
|
||||
} else {
|
||||
StdFileSystem.write_text_file(path, contents).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn request_permission(
|
||||
permission_tool: &acp::McpToolId,
|
||||
tool_call: acp::ToolCall,
|
||||
session_id: Uuid,
|
||||
mcp_connection_manager: &McpConnectionManager,
|
||||
) -> Result<ReviewDecision> {
|
||||
let approve_for_session_id = acp::PermissionOptionId("approve_for_session".into());
|
||||
let approve_id = acp::PermissionOptionId("approve".into());
|
||||
let deny_id = acp::PermissionOptionId("deny".into());
|
||||
|
||||
let arguments = acp::RequestPermissionArguments {
|
||||
session_id: acp::SessionId(session_id.to_string().into()),
|
||||
tool_call,
|
||||
options: vec![
|
||||
acp::PermissionOption {
|
||||
id: approve_for_session_id.clone(),
|
||||
label: "Approve for Session".into(),
|
||||
kind: acp::PermissionOptionKind::AllowAlways,
|
||||
},
|
||||
acp::PermissionOption {
|
||||
id: approve_id.clone(),
|
||||
label: "Approve".into(),
|
||||
kind: acp::PermissionOptionKind::AllowOnce,
|
||||
},
|
||||
acp::PermissionOption {
|
||||
id: deny_id.clone(),
|
||||
label: "Deny".into(),
|
||||
kind: acp::PermissionOptionKind::RejectOnce,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let CallToolResult {
|
||||
structured_content, ..
|
||||
} = mcp_connection_manager
|
||||
.call_tool(
|
||||
&permission_tool.mcp_server,
|
||||
&permission_tool.tool_name,
|
||||
Some(serde_json::to_value(arguments).unwrap_or_default()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result = structured_content.context("No output from permission tool")?;
|
||||
let result = serde_json::from_value::<acp::RequestPermissionOutput>(result)?;
|
||||
|
||||
use acp::RequestPermissionOutcome::*;
|
||||
let decision = match result.outcome {
|
||||
Selected { option_id } => {
|
||||
if option_id == approve_id {
|
||||
ReviewDecision::Approved
|
||||
} else if option_id == approve_for_session_id {
|
||||
ReviewDecision::ApprovedForSession
|
||||
} else if option_id == deny_id {
|
||||
ReviewDecision::Denied
|
||||
} else {
|
||||
anyhow::bail!("Unexpected permission option: {}", option_id);
|
||||
}
|
||||
}
|
||||
Canceled => ReviewDecision::Abort,
|
||||
};
|
||||
|
||||
Ok(decision)
|
||||
}
|
||||
|
||||
pub fn new_execute_tool_call(
|
||||
call_id: &str,
|
||||
command: &[String],
|
||||
status: acp::ToolCallStatus,
|
||||
) -> acp::ToolCall {
|
||||
acp::ToolCall {
|
||||
id: acp::ToolCallId(call_id.into()),
|
||||
label: format!("`{}`", strip_bash_lc_and_escape(command)),
|
||||
kind: acp::ToolKind::Execute,
|
||||
status,
|
||||
content: vec![],
|
||||
locations: vec![],
|
||||
raw_input: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_patch_tool_call(
|
||||
call_id: &str,
|
||||
changes: &HashMap<PathBuf, FileChange>,
|
||||
status: acp::ToolCallStatus,
|
||||
) -> acp::ToolCall {
|
||||
let label = if changes.len() == 1
|
||||
&& let Some((path, change)) = changes.iter().next()
|
||||
{
|
||||
let file_name = path.file_name().unwrap_or_default().display().to_string();
|
||||
|
||||
match change {
|
||||
FileChange::Delete => {
|
||||
return acp::ToolCall {
|
||||
id: acp::ToolCallId(call_id.into()),
|
||||
label: format!("Delete “`{file_name}`”"),
|
||||
kind: acp::ToolKind::Delete,
|
||||
status,
|
||||
content: vec![],
|
||||
locations: vec![],
|
||||
raw_input: None,
|
||||
};
|
||||
}
|
||||
FileChange::Update {
|
||||
move_path: Some(new_path),
|
||||
original_content,
|
||||
new_content,
|
||||
..
|
||||
} if original_content == new_content => {
|
||||
return acp::ToolCall {
|
||||
id: acp::ToolCallId(call_id.into()),
|
||||
label: move_path_label(path, new_path),
|
||||
kind: acp::ToolKind::Move,
|
||||
status,
|
||||
content: vec![],
|
||||
locations: vec![],
|
||||
raw_input: None,
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
format!("Edit “`{file_name}`”")
|
||||
} else {
|
||||
format!("Edit {} files", changes.len())
|
||||
};
|
||||
|
||||
let mut locations = Vec::with_capacity(changes.len());
|
||||
let mut content = Vec::with_capacity(changes.len());
|
||||
|
||||
for (path, change) in changes.iter() {
|
||||
match change {
|
||||
FileChange::Add { content: new_content } => {
|
||||
content.push(acp::ToolCallContent::Diff {
|
||||
diff: acp::Diff {
|
||||
path: path.clone(),
|
||||
old_text: None,
|
||||
new_text: new_content.clone(),
|
||||
},
|
||||
});
|
||||
|
||||
locations.push(acp::ToolCallLocation {
|
||||
path: path.clone(),
|
||||
line: None,
|
||||
});
|
||||
}
|
||||
FileChange::Delete => {
|
||||
content.push(
|
||||
format!(
|
||||
"Delete “`{}`”\n\n",
|
||||
path.file_name().unwrap_or(path.as_os_str()).display()
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
FileChange::Update {
|
||||
move_path,
|
||||
new_content,
|
||||
original_content,
|
||||
unified_diff: _,
|
||||
} => {
|
||||
if let Some(new_path) = move_path
|
||||
&& changes.len() > 1
|
||||
{
|
||||
content.push(move_path_label(path, new_path).into());
|
||||
|
||||
if status == acp::ToolCallStatus::Completed {
|
||||
locations.push(acp::ToolCallLocation {
|
||||
path: new_path.clone(),
|
||||
line: None,
|
||||
});
|
||||
} else {
|
||||
locations.push(acp::ToolCallLocation {
|
||||
path: path.clone(),
|
||||
line: None,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
locations.push(acp::ToolCallLocation {
|
||||
path: path.clone(),
|
||||
line: None,
|
||||
});
|
||||
}
|
||||
|
||||
if original_content != new_content {
|
||||
content.push(acp::ToolCallContent::Diff {
|
||||
diff: acp::Diff {
|
||||
path: path.clone(),
|
||||
old_text: Some(original_content.clone()),
|
||||
new_text: new_content.clone(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
acp::ToolCall {
|
||||
id: acp::ToolCallId(call_id.into()),
|
||||
label,
|
||||
kind: acp::ToolKind::Edit,
|
||||
status,
|
||||
content,
|
||||
locations,
|
||||
raw_input: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn move_path_label(old: &Path, new: &Path) -> String {
|
||||
if old.parent() == new.parent() {
|
||||
let old_name = old.file_name().unwrap_or(old.as_os_str()).display();
|
||||
let new_name = new.file_name().unwrap_or(new.as_os_str()).display();
|
||||
|
||||
format!("Rename “`{old_name}`” to “`{new_name}`”")
|
||||
} else {
|
||||
format!("Move “`{}`” to “`{}`”", old.display(), new.display())
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
use anyhow::Context as _;
|
||||
use anyhow::Result;
|
||||
use crate::acp::AcpFileSystem;
|
||||
use crate::codex::Session;
|
||||
use crate::patch_harness::run_patch_harness;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use crate::protocol::FileChange;
|
||||
use crate::protocol::ReviewDecision;
|
||||
use crate::safety::SafetyCheck;
|
||||
use crate::safety::assess_patch_safety;
|
||||
use crate::safety::SafetyCheck;
|
||||
use codex_apply_patch::AffectedPaths;
|
||||
use codex_apply_patch::ApplyPatchAction;
|
||||
use codex_apply_patch::ApplyPatchFileChange;
|
||||
use codex_apply_patch::FileSystem;
|
||||
use codex_apply_patch::StdFileSystem;
|
||||
use codex_apply_patch::print_summary;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
@@ -15,31 +22,17 @@ use std::path::PathBuf;
|
||||
|
||||
pub const CODEX_APPLY_PATCH_ARG1: &str = "--codex-run-as-apply-patch";
|
||||
|
||||
pub(crate) enum InternalApplyPatchInvocation {
|
||||
/// The `apply_patch` call was handled programmatically, without any sort
|
||||
/// of sandbox, because the user explicitly approved it. This is the
|
||||
/// result to use with the `shell` function call that contained `apply_patch`.
|
||||
Output(ResponseInputItem),
|
||||
|
||||
/// The `apply_patch` call was approved, either automatically because it
|
||||
/// appears that it should be allowed based on the user's sandbox policy
|
||||
/// *or* because the user explicitly approved it. In either case, we use
|
||||
/// exec with [`CODEX_APPLY_PATCH_ARG1`] to realize the `apply_patch` call,
|
||||
/// but [`ApplyPatchExec::auto_approved`] is used to determine the sandbox
|
||||
/// used with the `exec()`.
|
||||
DelegateToExec(ApplyPatchExec),
|
||||
pub(crate) struct ApplyPatchRun {
|
||||
pub auto_approved: bool,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub success: bool,
|
||||
pub harness_summary_json: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct ApplyPatchExec {
|
||||
pub(crate) action: ApplyPatchAction,
|
||||
pub(crate) user_explicitly_approved_this_action: bool,
|
||||
pub(crate) harness_summary_json: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ResponseInputItem> for InternalApplyPatchInvocation {
|
||||
fn from(item: ResponseInputItem) -> Self {
|
||||
InternalApplyPatchInvocation::Output(item)
|
||||
}
|
||||
pub(crate) enum ApplyPatchResult {
|
||||
Applied(ApplyPatchRun),
|
||||
Reply(ResponseInputItem),
|
||||
}
|
||||
|
||||
pub(crate) async fn apply_patch(
|
||||
@@ -47,9 +40,7 @@ pub(crate) async fn apply_patch(
|
||||
sub_id: &str,
|
||||
call_id: &str,
|
||||
action: ApplyPatchAction,
|
||||
) -> InternalApplyPatchInvocation {
|
||||
// Run the validation harness before assessing safety so we can surface a
|
||||
// concise status message and capture structured findings for the model.
|
||||
) -> ApplyPatchResult {
|
||||
let (harness_summary_json, harness_status_message) = {
|
||||
let mut summary_json: Option<String> = None;
|
||||
let mut status_message: Option<String> = None;
|
||||
@@ -142,59 +133,61 @@ pub(crate) async fn apply_patch(
|
||||
sess.notify_background_event(sub_id, message.clone()).await;
|
||||
}
|
||||
|
||||
match assess_patch_safety(
|
||||
let auto_approved = match assess_patch_safety(
|
||||
&action,
|
||||
sess.get_approval_policy(),
|
||||
sess.get_sandbox_policy(),
|
||||
sess.get_cwd(),
|
||||
) {
|
||||
SafetyCheck::AutoApprove { .. } => {
|
||||
InternalApplyPatchInvocation::DelegateToExec(ApplyPatchExec {
|
||||
action,
|
||||
user_explicitly_approved_this_action: false,
|
||||
harness_summary_json: harness_summary_json.clone(),
|
||||
})
|
||||
}
|
||||
SafetyCheck::AutoApprove { .. } => true,
|
||||
SafetyCheck::AskUser => {
|
||||
// Compute a readable summary of path changes to include in the
|
||||
// approval request so the user can make an informed decision.
|
||||
//
|
||||
// Note that it might be worth expanding this approval request to
|
||||
// give the user the option to expand the set of writable roots so
|
||||
// that similar patches can be auto-approved in the future during
|
||||
// this session.
|
||||
let rx_approve = sess
|
||||
let rx = sess
|
||||
.request_patch_approval(sub_id.to_owned(), call_id.to_owned(), &action, None, None)
|
||||
.await;
|
||||
match rx_approve.await.unwrap_or_default() {
|
||||
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {
|
||||
InternalApplyPatchInvocation::DelegateToExec(ApplyPatchExec {
|
||||
action,
|
||||
user_explicitly_approved_this_action: true,
|
||||
harness_summary_json,
|
||||
})
|
||||
}
|
||||
match rx.await.unwrap_or_default() {
|
||||
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => false,
|
||||
ReviewDecision::Denied | ReviewDecision::Abort => {
|
||||
ResponseInputItem::FunctionCallOutput {
|
||||
return ApplyPatchResult::Reply(ResponseInputItem::FunctionCallOutput {
|
||||
call_id: call_id.to_owned(),
|
||||
output: FunctionCallOutputPayload {
|
||||
content: "patch rejected by user".to_string(),
|
||||
success: Some(false),
|
||||
},
|
||||
}
|
||||
.into()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
SafetyCheck::Reject { reason } => ResponseInputItem::FunctionCallOutput {
|
||||
call_id: call_id.to_owned(),
|
||||
output: FunctionCallOutputPayload {
|
||||
content: format!("patch rejected: {reason}"),
|
||||
success: Some(false),
|
||||
},
|
||||
SafetyCheck::Reject { reason } => {
|
||||
return ApplyPatchResult::Reply(ResponseInputItem::FunctionCallOutput {
|
||||
call_id: call_id.to_owned(),
|
||||
output: FunctionCallOutputPayload {
|
||||
content: format!("patch rejected: {reason}"),
|
||||
success: Some(false),
|
||||
},
|
||||
});
|
||||
}
|
||||
.into(),
|
||||
}
|
||||
};
|
||||
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
let result = if let Some(client_tools) = sess.client_tools() {
|
||||
let fs = AcpFileSystem::new(sess.session_uuid(), client_tools, sess.mcp_connection_manager());
|
||||
apply_changes_from_apply_patch_and_report(&action, &mut stdout, &mut stderr, &fs).await
|
||||
} else {
|
||||
apply_changes_from_apply_patch_and_report(&action, &mut stdout, &mut stderr, &StdFileSystem).await
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&stderr).to_string();
|
||||
let success = result.is_ok();
|
||||
|
||||
ApplyPatchResult::Applied(ApplyPatchRun {
|
||||
auto_approved,
|
||||
stdout,
|
||||
stderr,
|
||||
success,
|
||||
harness_summary_json,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn convert_apply_patch_to_protocol(
|
||||
@@ -207,14 +200,17 @@ pub(crate) fn convert_apply_patch_to_protocol(
|
||||
ApplyPatchFileChange::Add { content } => FileChange::Add {
|
||||
content: content.clone(),
|
||||
},
|
||||
ApplyPatchFileChange::Delete { .. } => FileChange::Delete {},
|
||||
ApplyPatchFileChange::Delete => FileChange::Delete,
|
||||
ApplyPatchFileChange::Update {
|
||||
unified_diff,
|
||||
move_path,
|
||||
new_content: _new_content,
|
||||
original_content,
|
||||
new_content,
|
||||
} => FileChange::Update {
|
||||
unified_diff: unified_diff.clone(),
|
||||
move_path: move_path.clone(),
|
||||
original_content: original_content.clone(),
|
||||
new_content: new_content.clone(),
|
||||
},
|
||||
};
|
||||
result.insert(path.clone(), protocol_change);
|
||||
@@ -225,19 +221,8 @@ pub(crate) fn convert_apply_patch_to_protocol(
|
||||
pub(crate) fn get_writable_roots(cwd: &Path) -> Vec<PathBuf> {
|
||||
let mut writable_roots = Vec::new();
|
||||
if cfg!(target_os = "macos") {
|
||||
// On macOS, $TMPDIR is private to the user.
|
||||
writable_roots.push(std::env::temp_dir());
|
||||
|
||||
// Allow pyenv to update its shims directory. Without this, any tool
|
||||
// that happens to be managed by `pyenv` will fail with an error like:
|
||||
//
|
||||
// pyenv: cannot rehash: $HOME/.pyenv/shims isn't writable
|
||||
//
|
||||
// which is emitted every time `pyenv` tries to run `rehash` (for
|
||||
// example, after installing a new Python package that drops an entry
|
||||
// point). Although the sandbox is intentionally read‑only by default,
|
||||
// writing to the user's local `pyenv` directory is safe because it
|
||||
// is already user‑writable and scoped to the current user account.
|
||||
if let Ok(home_dir) = std::env::var("HOME") {
|
||||
let pyenv_dir = PathBuf::from(home_dir).join(".pyenv");
|
||||
writable_roots.push(pyenv_dir);
|
||||
@@ -248,3 +233,83 @@ pub(crate) fn get_writable_roots(cwd: &Path) -> Vec<PathBuf> {
|
||||
|
||||
writable_roots
|
||||
}
|
||||
|
||||
async fn apply_changes_from_apply_patch_and_report(
|
||||
action: &ApplyPatchAction,
|
||||
stdout: &mut impl std::io::Write,
|
||||
stderr: &mut impl std::io::Write,
|
||||
fs: &impl FileSystem,
|
||||
) -> std::io::Result<()> {
|
||||
match apply_changes_from_apply_patch(action, fs).await {
|
||||
Ok(affected_paths) => {
|
||||
print_summary(&affected_paths, stdout)?;
|
||||
}
|
||||
Err(err) => {
|
||||
writeln!(stderr, "{err:#}")?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_changes_from_apply_patch(
|
||||
action: &ApplyPatchAction,
|
||||
fs: &impl FileSystem,
|
||||
) -> Result<AffectedPaths> {
|
||||
let mut added: Vec<PathBuf> = Vec::new();
|
||||
let mut modified: Vec<PathBuf> = Vec::new();
|
||||
let mut deleted: Vec<PathBuf> = Vec::new();
|
||||
|
||||
for (path, change) in action.changes() {
|
||||
match change {
|
||||
ApplyPatchFileChange::Add { content } => {
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!("Failed to create parent directories for {}", path.display())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
fs.write_text_file(path, content.clone())
|
||||
.await
|
||||
.with_context(|| format!("Failed to write file {}", path.display()))?;
|
||||
added.push(path.clone());
|
||||
}
|
||||
ApplyPatchFileChange::Delete => {
|
||||
std::fs::remove_file(path)
|
||||
.with_context(|| format!("Failed to delete file {}", path.display()))?;
|
||||
deleted.push(path.clone());
|
||||
}
|
||||
ApplyPatchFileChange::Update {
|
||||
move_path,
|
||||
new_content,
|
||||
..
|
||||
} => {
|
||||
if let Some(move_path) = move_path {
|
||||
if let Some(parent) = move_path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!("Failed to create parent directories for {}", move_path.display())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
std::fs::rename(path, move_path)
|
||||
.with_context(|| format!("Failed to rename file {}", path.display()))?;
|
||||
fs.write_text_file(move_path, new_content.clone()).await?;
|
||||
modified.push(move_path.clone());
|
||||
deleted.push(path.clone());
|
||||
} else {
|
||||
fs.write_text_file(path, new_content.clone()).await?;
|
||||
modified.push(path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(AffectedPaths {
|
||||
added,
|
||||
modified,
|
||||
deleted,
|
||||
})
|
||||
}
|
||||
|
||||
+145
-241
@@ -17,9 +17,10 @@ use async_channel::Sender;
|
||||
use base64::Engine;
|
||||
use codex_apply_patch::ApplyPatchAction;
|
||||
use codex_apply_patch::MaybeApplyPatchVerified;
|
||||
use codex_apply_patch::maybe_parse_apply_patch_verified;
|
||||
use codex_apply_patch::StdFileSystem;
|
||||
use codex_browser::BrowserConfig as CodexBrowserConfig;
|
||||
use codex_browser::BrowserManager;
|
||||
use agent_client_protocol::ClientTools;
|
||||
// unused: AuthManager
|
||||
// unused: ConversationHistoryResponseEvent
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
@@ -38,6 +39,7 @@ use tracing::trace;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
use crate::CodexAuth;
|
||||
use crate::acp::AcpFileSystem;
|
||||
use crate::agent_tool::AgentStatusUpdatePayload;
|
||||
use crate::protocol::ApprovedCommandMatchKind;
|
||||
use crate::protocol::WebSearchBeginEvent;
|
||||
@@ -449,12 +451,9 @@ use crate::agent_tool::GetAgentResultParams;
|
||||
use crate::agent_tool::ListAgentsParams;
|
||||
use crate::agent_tool::RunAgentParams;
|
||||
use crate::agent_tool::WaitForAgentParams;
|
||||
use crate::apply_patch::ApplyPatchExec;
|
||||
use crate::apply_patch::CODEX_APPLY_PATCH_ARG1;
|
||||
use crate::apply_patch::InternalApplyPatchInvocation;
|
||||
use crate::apply_patch::convert_apply_patch_to_protocol;
|
||||
use crate::apply_patch::get_writable_roots;
|
||||
use crate::apply_patch::{self};
|
||||
use crate::apply_patch::{self, ApplyPatchResult};
|
||||
use crate::client::ModelClient;
|
||||
use crate::client_common::Prompt;
|
||||
use crate::client_common::ResponseEvent;
|
||||
@@ -790,6 +789,7 @@ struct BackgroundExecState {
|
||||
///
|
||||
/// A session has at most 1 running agent at a time, and can be interrupted by user input.
|
||||
pub(crate) struct Session {
|
||||
id: Uuid,
|
||||
client: ModelClient,
|
||||
tx_event: Sender<Event>,
|
||||
|
||||
@@ -808,6 +808,7 @@ pub(crate) struct Session {
|
||||
|
||||
/// Manager for external MCP servers/tools.
|
||||
mcp_connection_manager: McpConnectionManager,
|
||||
client_tools: Option<ClientTools>,
|
||||
#[allow(dead_code)]
|
||||
session_manager: ExecSessionManager,
|
||||
|
||||
@@ -895,6 +896,10 @@ impl Session {
|
||||
&self.sandbox_policy
|
||||
}
|
||||
|
||||
pub(crate) fn session_uuid(&self) -> Uuid {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub(crate) fn get_github_config(&self) -> Arc<RwLock<crate::config_types::GithubConfig>> {
|
||||
Arc::clone(&self.github)
|
||||
}
|
||||
@@ -903,6 +908,14 @@ impl Session {
|
||||
Arc::clone(&self.validation)
|
||||
}
|
||||
|
||||
pub(crate) fn client_tools(&self) -> Option<&ClientTools> {
|
||||
self.client_tools.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn mcp_connection_manager(&self) -> &McpConnectionManager {
|
||||
&self.mcp_connection_manager
|
||||
}
|
||||
|
||||
pub(crate) fn update_validation_patch_harness(&self, enabled: bool) {
|
||||
if let Ok(mut cfg) = self.validation.write() {
|
||||
cfg.patch_harness = enabled;
|
||||
@@ -938,6 +951,20 @@ impl Session {
|
||||
.map_or_else(|| self.cwd.clone(), |p| self.cwd.join(p))
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_parse_apply_patch_verified(
|
||||
&self,
|
||||
argv: &[String],
|
||||
cwd: &Path,
|
||||
) -> MaybeApplyPatchVerified {
|
||||
if let Some(client_tools) = self.client_tools.as_ref() {
|
||||
let fs = AcpFileSystem::new(self.id, client_tools, &self.mcp_connection_manager);
|
||||
codex_apply_patch::maybe_parse_apply_patch_verified(argv, cwd, &fs).await
|
||||
} else {
|
||||
let fs = StdFileSystem;
|
||||
codex_apply_patch::maybe_parse_apply_patch_verified(argv, cwd, &fs).await
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────
|
||||
// Scratchpad helpers
|
||||
// ────────────────────────────
|
||||
@@ -2655,8 +2682,25 @@ async fn submission_loop(
|
||||
|
||||
// Error messages to dispatch after SessionConfigured is sent.
|
||||
let mut mcp_connection_errors = Vec::<String>::new();
|
||||
let mut excluded_tools = HashSet::new();
|
||||
if let Some(client_tools) = config.experimental_client_tools.as_ref() {
|
||||
for tool in [
|
||||
client_tools.request_permission.as_ref(),
|
||||
client_tools.read_text_file.as_ref(),
|
||||
client_tools.write_text_file.as_ref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
excluded_tools.insert((
|
||||
tool.mcp_server.to_string(),
|
||||
tool.tool_name.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let (mcp_connection_manager, failed_clients) =
|
||||
match McpConnectionManager::new(config.mcp_servers.clone()).await {
|
||||
match McpConnectionManager::new(config.mcp_servers.clone(), excluded_tools).await {
|
||||
Ok((mgr, failures)) => (mgr, failures),
|
||||
Err(e) => {
|
||||
let message = format!("Failed to create MCP connection manager: {e:#}");
|
||||
@@ -2690,6 +2734,7 @@ async fn submission_loop(
|
||||
config.tools_web_search_allowed_domains.clone();
|
||||
|
||||
sess = Some(Arc::new(Session {
|
||||
id: session_id,
|
||||
client,
|
||||
tools_config,
|
||||
tx_event: tx_event.clone(),
|
||||
@@ -2700,8 +2745,9 @@ async fn submission_loop(
|
||||
shell_environment_policy: config.shell_environment_policy.clone(),
|
||||
cwd,
|
||||
_writable_roots: writable_roots,
|
||||
mcp_connection_manager,
|
||||
session_manager: crate::exec_command::ExecSessionManager::default(),
|
||||
mcp_connection_manager,
|
||||
client_tools: config.experimental_client_tools.clone(),
|
||||
session_manager: crate::exec_command::ExecSessionManager::default(),
|
||||
agents: config.agents.clone(),
|
||||
notify,
|
||||
state: Mutex::new(state),
|
||||
@@ -6427,7 +6473,7 @@ async fn handle_container_exec_with_params(
|
||||
|
||||
// If the argv is a shell wrapper, analyze and optionally strip `confirm:`.
|
||||
let mut params = params;
|
||||
let mut seq_hint_for_exec = seq_hint;
|
||||
let seq_hint_for_exec = seq_hint;
|
||||
if let Some((script_index, script)) = extract_shell_script_from_wrapper(¶ms.command) {
|
||||
let trimmed = script.trim_start();
|
||||
let confirm_prefixes = ["confirm:", "CONFIRM:"];
|
||||
@@ -6520,148 +6566,6 @@ async fn handle_container_exec_with_params(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect an embedded `apply_patch <<EOF ... EOF` in a larger script and split it out
|
||||
// so the UI can render a distinct "Updated" block before the "Run" block.
|
||||
//
|
||||
// If present, we will:
|
||||
// 1) Execute the patch as a standalone apply_patch exec (with proper events)
|
||||
// 2) Remove the statement from the script and continue with the remainder
|
||||
// (only if the patch succeeded)
|
||||
if let Ok(Some(found)) = codex_apply_patch::find_embedded_apply_patch(¶ms.command[script_index]) {
|
||||
// Build a synthetic minimal script for verified parsing of the patch action,
|
||||
// preserving an optional cd path for correct path resolution.
|
||||
let synthetic_script = if let Some(cd) = &found.cd_path {
|
||||
format!("cd {} && apply_patch <<'EOF'\n{}\nEOF\n", cd, found.patch_body)
|
||||
} else {
|
||||
format!("apply_patch <<'EOF'\n{}\nEOF\n", found.patch_body)
|
||||
};
|
||||
|
||||
// Resolve into an ApplyPatchAction using the verified path
|
||||
let cwd_path = std::path::Path::new(¶ms.cwd);
|
||||
let verified = codex_apply_patch::maybe_parse_apply_patch_verified(
|
||||
&vec!["bash".to_string(), "-lc".to_string(), synthetic_script.clone()],
|
||||
cwd_path,
|
||||
);
|
||||
if let codex_apply_patch::MaybeApplyPatchVerified::Body(action) = verified {
|
||||
// First, run the patch apply as its own Exec with proper events
|
||||
let path_to_codex = std::env::current_exe()
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().to_string());
|
||||
if let Some(path_to_codex) = path_to_codex {
|
||||
let patch_params = ExecParams {
|
||||
command: vec![
|
||||
path_to_codex,
|
||||
CODEX_APPLY_PATCH_ARG1.to_string(),
|
||||
action.patch.clone(),
|
||||
],
|
||||
cwd: action.cwd.clone(),
|
||||
timeout_ms: params.timeout_ms,
|
||||
env: HashMap::new(),
|
||||
with_escalated_permissions: params.with_escalated_permissions,
|
||||
justification: params.justification.clone(),
|
||||
};
|
||||
|
||||
// Safety for patch step mirrors normal patch handling
|
||||
let safety = assess_safety_for_untrusted_command(
|
||||
sess.approval_policy,
|
||||
&sess.sandbox_policy,
|
||||
patch_params.with_escalated_permissions.unwrap_or(false),
|
||||
);
|
||||
|
||||
let exec_command_context = ExecCommandContext {
|
||||
sub_id: sub_id.clone(),
|
||||
call_id: format!("{call_id}.apply_patch"),
|
||||
command_for_display: vec!["apply_patch".to_string(), action.patch.clone()],
|
||||
cwd: patch_params.cwd.clone(),
|
||||
apply_patch: Some(ApplyPatchCommandContext {
|
||||
user_explicitly_approved_this_action: matches!(safety, SafetyCheck::AutoApprove { .. }),
|
||||
changes: convert_apply_patch_to_protocol(&action),
|
||||
}),
|
||||
};
|
||||
|
||||
let patch_result = sess
|
||||
.run_exec_with_events(
|
||||
turn_diff_tracker,
|
||||
exec_command_context,
|
||||
ExecInvokeArgs {
|
||||
params: patch_params.clone(),
|
||||
sandbox_type: match safety { SafetyCheck::AutoApprove { sandbox_type } => sandbox_type, SafetyCheck::AskUser => SandboxType::None, SafetyCheck::Reject { .. } => SandboxType::None },
|
||||
sandbox_policy: &sess.sandbox_policy,
|
||||
sandbox_cwd: sess.get_cwd(),
|
||||
codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
|
||||
stdout_stream: None,
|
||||
},
|
||||
seq_hint, // occupy the provided sequence range first
|
||||
output_index,
|
||||
attempt_req,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut should_continue = false;
|
||||
if let Ok(ref out) = patch_result { should_continue = out.exit_code == 0; }
|
||||
|
||||
// If patch step succeeded, strip it from the original script and proceed.
|
||||
if should_continue {
|
||||
let (start, end) = found.stmt_byte_range;
|
||||
let mut residual = String::new();
|
||||
residual.push_str(¶ms.command[script_index][..start]);
|
||||
residual.push_str(¶ms.command[script_index][end..]);
|
||||
// Clean leading/trailing separators to avoid syntax errors
|
||||
let mut residual = residual.trim().to_string();
|
||||
// Remove leading connectors like &&, ||, ; and trailing ones
|
||||
let trim_connectors = |s: &str| -> String {
|
||||
let mut s = s.trim().to_string();
|
||||
// Leading
|
||||
for _ in 0..2 {
|
||||
let st = s.trim_start();
|
||||
let new = if st.starts_with("&&") { &st[2..] } else if st.starts_with("||") { &st[2..] } else if st.starts_with(';') { &st[1..] } else { st };
|
||||
s = new.trim_start().to_string();
|
||||
}
|
||||
// Trailing
|
||||
for _ in 0..2 {
|
||||
let st = s.trim_end();
|
||||
let new = if st.ends_with("&&") { &st[..st.len()-2] } else if st.ends_with("||") { &st[..st.len()-2] } else if st.ends_with(';') { &st[..st.len()-1] } else { st };
|
||||
s = new.trim_end().to_string();
|
||||
}
|
||||
s
|
||||
};
|
||||
residual = trim_connectors(&residual);
|
||||
|
||||
if !residual.is_empty() {
|
||||
params.command[script_index] = residual;
|
||||
// Bump the seq hint for the following run cell
|
||||
seq_hint_for_exec = seq_hint.map(|h| h.saturating_add(2));
|
||||
|
||||
// Continue with normal flow using updated params (fallthrough below)
|
||||
// We overwrite `script` in local scope to avoid borrow issues
|
||||
} else {
|
||||
// No more commands to run; return the patch result as the tool output
|
||||
let ok = patch_result
|
||||
.as_ref()
|
||||
.map(|o| o.exit_code == 0)
|
||||
.unwrap_or(false);
|
||||
let content = match patch_result {
|
||||
Ok(out) => format_exec_output_with_limit(sess, &sub_id, &call_id, &out),
|
||||
Err(e) => get_error_message_ui(&e),
|
||||
};
|
||||
return ResponseInputItem::FunctionCallOutput { call_id, output: FunctionCallOutputPayload { content, success: Some(ok) } };
|
||||
}
|
||||
} else {
|
||||
// Patch failed; return immediately with patch output (do not run remainder)
|
||||
let ok = patch_result
|
||||
.as_ref()
|
||||
.map(|o| o.exit_code == 0)
|
||||
.unwrap_or(false);
|
||||
let content = match patch_result {
|
||||
Ok(out) => format_exec_output_with_limit(sess, &sub_id, &call_id, &out),
|
||||
Err(e) => get_error_message_ui(&e),
|
||||
};
|
||||
return ResponseInputItem::FunctionCallOutput { call_id, output: FunctionCallOutputPayload { content, success: Some(ok) } };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
strip_leading_confirm_prefix(&mut params.command);
|
||||
@@ -6801,20 +6705,85 @@ async fn handle_container_exec_with_params(
|
||||
}
|
||||
}
|
||||
|
||||
// check if this was a patch, and apply it if so
|
||||
let apply_patch_exec = match maybe_parse_apply_patch_verified(¶ms.command, ¶ms.cwd) {
|
||||
MaybeApplyPatchVerified::Body(changes) => {
|
||||
match apply_patch::apply_patch(sess, &sub_id, &call_id, changes).await {
|
||||
InternalApplyPatchInvocation::Output(item) => return item,
|
||||
InternalApplyPatchInvocation::DelegateToExec(apply_patch_exec) => {
|
||||
Some(apply_patch_exec)
|
||||
// Check if this was a patch, and apply it in-process if so.
|
||||
match sess
|
||||
.maybe_parse_apply_patch_verified(¶ms.command, ¶ms.cwd)
|
||||
.await
|
||||
{
|
||||
MaybeApplyPatchVerified::Body(action) => {
|
||||
let changes = convert_apply_patch_to_protocol(&action);
|
||||
turn_diff_tracker.on_patch_begin(&changes);
|
||||
|
||||
match apply_patch::apply_patch(sess, &sub_id, &call_id, action).await {
|
||||
ApplyPatchResult::Reply(item) => return item,
|
||||
ApplyPatchResult::Applied(run) => {
|
||||
let order_begin = crate::protocol::OrderMeta {
|
||||
request_ordinal: attempt_req,
|
||||
output_index,
|
||||
sequence_number: seq_hint,
|
||||
};
|
||||
let begin_event = EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
||||
call_id: call_id.clone(),
|
||||
auto_approved: run.auto_approved,
|
||||
changes,
|
||||
});
|
||||
let event = sess.make_event_with_order(&sub_id, begin_event, order_begin, seq_hint);
|
||||
let _ = sess.tx_event.send(event).await;
|
||||
|
||||
let order_end = crate::protocol::OrderMeta {
|
||||
request_ordinal: attempt_req,
|
||||
output_index,
|
||||
sequence_number: seq_hint.map(|h| h.saturating_add(1)),
|
||||
};
|
||||
let end_event = EventMsg::PatchApplyEnd(PatchApplyEndEvent {
|
||||
call_id: call_id.clone(),
|
||||
stdout: run.stdout.clone(),
|
||||
stderr: run.stderr.clone(),
|
||||
success: run.success,
|
||||
});
|
||||
let event = sess.make_event_with_order(
|
||||
&sub_id,
|
||||
end_event,
|
||||
order_end,
|
||||
seq_hint.map(|h| h.saturating_add(1)),
|
||||
);
|
||||
let _ = sess.tx_event.send(event).await;
|
||||
|
||||
if let Ok(Some(unified_diff)) = turn_diff_tracker.get_unified_diff() {
|
||||
let diff_event = sess.make_event(
|
||||
&sub_id,
|
||||
EventMsg::TurnDiff(TurnDiffEvent { unified_diff }),
|
||||
);
|
||||
let _ = sess.tx_event.send(diff_event).await;
|
||||
}
|
||||
|
||||
let mut content = run.stdout;
|
||||
if !run.success && !run.stderr.is_empty() {
|
||||
if !content.is_empty() {
|
||||
content.push('\n');
|
||||
}
|
||||
content.push_str(&format!("stderr: {}", run.stderr));
|
||||
}
|
||||
if let Some(summary) = run.harness_summary_json {
|
||||
if !summary.is_empty() {
|
||||
if !content.is_empty() {
|
||||
content.push('\n');
|
||||
}
|
||||
content.push_str(&summary);
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseInputItem::FunctionCallOutput {
|
||||
call_id,
|
||||
output: FunctionCallOutputPayload {
|
||||
content,
|
||||
success: Some(run.success),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
MaybeApplyPatchVerified::CorrectnessError(parse_error) => {
|
||||
// It looks like an invocation of `apply_patch`, but we
|
||||
// could not resolve it into a patch that would apply
|
||||
// cleanly. Return to model for resample.
|
||||
return ResponseInputItem::FunctionCallOutput {
|
||||
call_id,
|
||||
output: FunctionCallOutputPayload {
|
||||
@@ -6825,78 +6794,22 @@ async fn handle_container_exec_with_params(
|
||||
}
|
||||
MaybeApplyPatchVerified::ShellParseError(error) => {
|
||||
trace!("Failed to parse shell command, {error:?}");
|
||||
None
|
||||
}
|
||||
MaybeApplyPatchVerified::NotApplyPatch => None,
|
||||
MaybeApplyPatchVerified::NotApplyPatch => {}
|
||||
}
|
||||
|
||||
let safety = {
|
||||
let state = sess.state.lock().unwrap();
|
||||
assess_command_safety(
|
||||
¶ms.command,
|
||||
sess.approval_policy,
|
||||
&sess.sandbox_policy,
|
||||
&state.approved_commands,
|
||||
params.with_escalated_permissions.unwrap_or(false),
|
||||
)
|
||||
};
|
||||
|
||||
let (params, safety, command_for_display) = match &apply_patch_exec {
|
||||
Some(ApplyPatchExec {
|
||||
action: ApplyPatchAction { patch, cwd, .. },
|
||||
user_explicitly_approved_this_action,
|
||||
..
|
||||
}) => {
|
||||
let path_to_codex = std::env::current_exe()
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().to_string());
|
||||
let Some(path_to_codex) = path_to_codex else {
|
||||
return ResponseInputItem::FunctionCallOutput {
|
||||
call_id,
|
||||
output: FunctionCallOutputPayload {
|
||||
content: "failed to determine path to codex executable".to_string(),
|
||||
success: None,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
let params = ExecParams {
|
||||
command: vec![
|
||||
path_to_codex,
|
||||
CODEX_APPLY_PATCH_ARG1.to_string(),
|
||||
patch.clone(),
|
||||
],
|
||||
cwd: cwd.clone(),
|
||||
timeout_ms: params.timeout_ms,
|
||||
env: HashMap::new(),
|
||||
with_escalated_permissions: params.with_escalated_permissions,
|
||||
justification: params.justification.clone(),
|
||||
};
|
||||
let safety = if *user_explicitly_approved_this_action {
|
||||
SafetyCheck::AutoApprove {
|
||||
sandbox_type: SandboxType::None,
|
||||
}
|
||||
} else {
|
||||
assess_safety_for_untrusted_command(
|
||||
sess.approval_policy,
|
||||
&sess.sandbox_policy,
|
||||
params.with_escalated_permissions.unwrap_or(false),
|
||||
)
|
||||
};
|
||||
(
|
||||
params,
|
||||
safety,
|
||||
vec!["apply_patch".to_string(), patch.clone()],
|
||||
)
|
||||
}
|
||||
None => {
|
||||
let safety = {
|
||||
let state = sess.state.lock().unwrap();
|
||||
assess_command_safety(
|
||||
¶ms.command,
|
||||
sess.approval_policy,
|
||||
&sess.sandbox_policy,
|
||||
&state.approved_commands,
|
||||
params.with_escalated_permissions.unwrap_or(false),
|
||||
)
|
||||
};
|
||||
let command_for_display = params.command.clone();
|
||||
(params, safety, command_for_display)
|
||||
}
|
||||
};
|
||||
|
||||
let harness_summary_json = apply_patch_exec
|
||||
.as_ref()
|
||||
.and_then(|exec| exec.harness_summary_json.clone());
|
||||
let command_for_display = params.command.clone();
|
||||
let harness_summary_json: Option<String> = None;
|
||||
|
||||
let sandbox_type = match safety {
|
||||
SafetyCheck::AutoApprove { sandbox_type } => sandbox_type,
|
||||
@@ -6951,16 +6864,7 @@ async fn handle_container_exec_with_params(
|
||||
call_id: call_id.clone(),
|
||||
command_for_display: command_for_display.clone(),
|
||||
cwd: params.cwd.clone(),
|
||||
apply_patch: apply_patch_exec.map(
|
||||
|ApplyPatchExec {
|
||||
action,
|
||||
user_explicitly_approved_this_action,
|
||||
..
|
||||
}| ApplyPatchCommandContext {
|
||||
user_explicitly_approved_this_action,
|
||||
changes: convert_apply_patch_to_protocol(&action),
|
||||
},
|
||||
),
|
||||
apply_patch: None,
|
||||
};
|
||||
|
||||
let display_label = exec_command_context.command_for_display.join(" ");
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::config_types::ShellEnvironmentPolicyToml;
|
||||
use crate::config_types::TextVerbosity;
|
||||
use crate::config_types::Tui;
|
||||
use crate::config_types::UriBasedFileOpener;
|
||||
use agent_client_protocol::ClientTools;
|
||||
use crate::config_types::ConfirmGuardConfig;
|
||||
use crate::git_info::resolve_root_git_project_for_trust;
|
||||
use crate::model_family::ModelFamily;
|
||||
@@ -172,6 +173,9 @@ pub struct Config {
|
||||
/// Definition for MCP servers that Codex can reach out to for tool calls.
|
||||
pub mcp_servers: HashMap<String, McpServerConfig>,
|
||||
|
||||
/// Optional ACP client tool identifiers supplied by the host IDE.
|
||||
pub experimental_client_tools: Option<ClientTools>,
|
||||
|
||||
/// Configuration for available agent models
|
||||
pub agents: Vec<AgentConfig>,
|
||||
|
||||
@@ -1354,6 +1358,10 @@ pub struct ConfigToml {
|
||||
#[serde(default)]
|
||||
pub mcp_servers: HashMap<String, McpServerConfig>,
|
||||
|
||||
/// Optional ACP client tool identifiers supplied by the host IDE.
|
||||
#[serde(default)]
|
||||
pub experimental_client_tools: Option<ClientTools>,
|
||||
|
||||
/// Configuration for available agent models
|
||||
#[serde(default)]
|
||||
pub agents: Vec<AgentConfig>,
|
||||
@@ -1569,6 +1577,8 @@ pub struct ConfigOverrides {
|
||||
pub show_raw_agent_reasoning: Option<bool>,
|
||||
pub debug: Option<bool>,
|
||||
pub tools_web_search_request: Option<bool>,
|
||||
pub mcp_servers: Option<HashMap<String, McpServerConfig>>,
|
||||
pub experimental_client_tools: Option<ClientTools>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -1581,6 +1591,8 @@ impl Config {
|
||||
) -> std::io::Result<Self> {
|
||||
let user_instructions = Self::load_instructions(Some(&codex_home));
|
||||
|
||||
let mut cfg = cfg;
|
||||
|
||||
// Destructure ConfigOverrides fully to ensure all overrides are applied.
|
||||
let ConfigOverrides {
|
||||
model,
|
||||
@@ -1599,8 +1611,18 @@ impl Config {
|
||||
show_raw_agent_reasoning,
|
||||
debug,
|
||||
tools_web_search_request: override_tools_web_search_request,
|
||||
mcp_servers,
|
||||
experimental_client_tools,
|
||||
} = overrides;
|
||||
|
||||
if let Some(mcp_servers) = mcp_servers {
|
||||
cfg.mcp_servers = mcp_servers;
|
||||
}
|
||||
|
||||
if let Some(client_tools) = experimental_client_tools {
|
||||
cfg.experimental_client_tools = Some(client_tools);
|
||||
}
|
||||
|
||||
let (active_profile_name, config_profile) =
|
||||
match config_profile_key.as_ref().or(cfg.profile.as_ref()) {
|
||||
Some(key) => {
|
||||
@@ -1857,6 +1879,7 @@ impl Config {
|
||||
user_instructions,
|
||||
base_instructions,
|
||||
mcp_servers: cfg.mcp_servers,
|
||||
experimental_client_tools: cfg.experimental_client_tools.clone(),
|
||||
agents,
|
||||
model_providers,
|
||||
project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES),
|
||||
@@ -2547,6 +2570,7 @@ model_verbosity = "high"
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
mcp_servers: HashMap::new(),
|
||||
experimental_client_tools: None,
|
||||
model_providers: fixture.model_provider_map.clone(),
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
codex_home: fixture.codex_home(),
|
||||
@@ -2616,6 +2640,7 @@ model_verbosity = "high"
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
mcp_servers: HashMap::new(),
|
||||
experimental_client_tools: None,
|
||||
model_providers: fixture.model_provider_map.clone(),
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
codex_home: fixture.codex_home(),
|
||||
@@ -2700,6 +2725,7 @@ model_verbosity = "high"
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
mcp_servers: HashMap::new(),
|
||||
experimental_client_tools: None,
|
||||
model_providers: fixture.model_provider_map.clone(),
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
codex_home: fixture.codex_home(),
|
||||
@@ -2769,6 +2795,7 @@ model_verbosity = "high"
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
mcp_servers: HashMap::new(),
|
||||
experimental_client_tools: None,
|
||||
model_providers: fixture.model_provider_map.clone(),
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
codex_home: fixture.codex_home(),
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod config_edit;
|
||||
pub mod config_profile;
|
||||
pub mod config_types;
|
||||
mod conversation_history;
|
||||
pub mod acp;
|
||||
pub mod custom_prompts;
|
||||
pub mod debug_logger;
|
||||
mod environment_context;
|
||||
|
||||
@@ -105,6 +105,7 @@ impl McpConnectionManager {
|
||||
/// user should be informed about these errors.
|
||||
pub async fn new(
|
||||
mcp_servers: HashMap<String, McpServerConfig>,
|
||||
excluded_tools: HashSet<(String, String)>,
|
||||
) -> Result<(Self, ClientStartErrors)> {
|
||||
// Early exit if no servers are configured.
|
||||
if mcp_servers.is_empty() {
|
||||
@@ -201,7 +202,7 @@ impl McpConnectionManager {
|
||||
// Query tools from each server. Do not fail the entire manager if a
|
||||
// server fails to list tools within its startup timeout; instead,
|
||||
// record the error and continue.
|
||||
let (all_tools, list_errors) = list_all_tools(&clients, &per_server_timeout).await;
|
||||
let (all_tools, list_errors) = list_all_tools(&clients, &per_server_timeout, &excluded_tools).await;
|
||||
|
||||
// Remove clients that failed to list tools so they are not used later.
|
||||
for (server_name, err) in list_errors {
|
||||
@@ -255,6 +256,7 @@ impl McpConnectionManager {
|
||||
async fn list_all_tools(
|
||||
clients: &HashMap<String, std::sync::Arc<McpClient>>,
|
||||
timeouts: &HashMap<String, Duration>,
|
||||
excluded_tools: &HashSet<(String, String)>,
|
||||
) -> (Vec<ToolInfo>, HashMap<String, anyhow::Error>) {
|
||||
let mut join_set = JoinSet::new();
|
||||
|
||||
@@ -283,6 +285,10 @@ async fn list_all_tools(
|
||||
match join_res {
|
||||
Ok((server_name, Ok(list_result))) => {
|
||||
for tool in list_result.tools {
|
||||
if excluded_tools.contains(&(server_name.clone(), tool.name.clone())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let tool_info = ToolInfo {
|
||||
server_name: server_name.clone(),
|
||||
tool_name: tool.name.clone(),
|
||||
|
||||
@@ -1146,6 +1146,8 @@ pub enum FileChange {
|
||||
Update {
|
||||
unified_diff: String,
|
||||
move_path: Option<PathBuf>,
|
||||
original_content: String,
|
||||
new_content: String,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -564,12 +564,12 @@ mod tests_windows {
|
||||
bash_exe_fallback: Some(PathBuf::from("bash.exe")),
|
||||
}),
|
||||
vec![
|
||||
"codex-mcp-server.exe",
|
||||
"code-mcp-server.exe",
|
||||
"--codex-run-as-apply-patch",
|
||||
"*** Begin Patch\n*** Update File: C:\\Users\\person\\destination_file.txt\n-original content\n+modified content\n*** End Patch",
|
||||
],
|
||||
vec![
|
||||
"codex-mcp-server.exe",
|
||||
"code-mcp-server.exe",
|
||||
"--codex-run-as-apply-patch",
|
||||
"*** Begin Patch\n*** Update File: C:\\Users\\person\\destination_file.txt\n-original content\n+modified content\n*** End Patch",
|
||||
],
|
||||
|
||||
@@ -545,6 +545,8 @@ index {ZERO_OID}..{right_oid}
|
||||
FileChange::Update {
|
||||
unified_diff: "".to_owned(),
|
||||
move_path: None,
|
||||
original_content: String::new(),
|
||||
new_content: String::new(),
|
||||
},
|
||||
)]);
|
||||
acc.on_patch_begin(&update_changes);
|
||||
@@ -620,6 +622,8 @@ index {left_oid}..{ZERO_OID}
|
||||
FileChange::Update {
|
||||
unified_diff: "".to_owned(),
|
||||
move_path: Some(dest.clone()),
|
||||
original_content: String::new(),
|
||||
new_content: String::new(),
|
||||
},
|
||||
)]);
|
||||
acc.on_patch_begin(&mv_changes);
|
||||
@@ -660,6 +664,8 @@ index {left_oid}..{right_oid}
|
||||
FileChange::Update {
|
||||
unified_diff: "".to_owned(),
|
||||
move_path: Some(dest.clone()),
|
||||
original_content: String::new(),
|
||||
new_content: String::new(),
|
||||
},
|
||||
)]);
|
||||
acc.on_patch_begin(&mv_changes);
|
||||
@@ -682,6 +688,8 @@ index {left_oid}..{right_oid}
|
||||
FileChange::Update {
|
||||
unified_diff: "".into(),
|
||||
move_path: Some(dest.clone()),
|
||||
original_content: String::new(),
|
||||
new_content: String::new(),
|
||||
},
|
||||
)]);
|
||||
acc.on_patch_begin(&mv);
|
||||
@@ -722,6 +730,8 @@ index {ZERO_OID}..{right_oid}
|
||||
FileChange::Update {
|
||||
unified_diff: "".to_owned(),
|
||||
move_path: None,
|
||||
original_content: String::new(),
|
||||
new_content: String::new(),
|
||||
},
|
||||
)]);
|
||||
acc.on_patch_begin(&update_a);
|
||||
@@ -802,6 +812,8 @@ index {left_oid_b}..{ZERO_OID}
|
||||
FileChange::Update {
|
||||
unified_diff: "".to_owned(),
|
||||
move_path: None,
|
||||
original_content: String::new(),
|
||||
new_content: String::new(),
|
||||
},
|
||||
)]);
|
||||
acc.on_patch_begin(&update_changes);
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rand::Rng;
|
||||
use shlex::try_join;
|
||||
use tokio::sync::Notify;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
const INITIAL_DELAY_MS: u64 = 200;
|
||||
const BACKOFF_FACTOR: f64 = 2.0;
|
||||
@@ -11,3 +18,49 @@ pub(crate) fn backoff(attempt: u64) -> Duration {
|
||||
let jitter = rand::rng().random_range(0.9..1.1);
|
||||
Duration::from_millis((base as f64 * jitter) as u64)
|
||||
}
|
||||
|
||||
pub fn escape_command(command: &[String]) -> String {
|
||||
try_join(command.iter().map(|s| s.as_str())).unwrap_or_else(|_| command.join(" "))
|
||||
}
|
||||
|
||||
pub fn strip_bash_lc_and_escape(command: &[String]) -> String {
|
||||
match command {
|
||||
[first, second, third] if first == "bash" && second == "-lc" => third.clone(),
|
||||
_ => escape_command(command),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn notify_on_sigint() -> Arc<Notify> {
|
||||
let notify = Arc::new(Notify::new());
|
||||
|
||||
tokio::spawn({
|
||||
let notify = Arc::clone(¬ify);
|
||||
async move {
|
||||
loop {
|
||||
tokio::signal::ctrl_c().await.ok();
|
||||
debug!("Keyboard interrupt");
|
||||
notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
notify
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_inside_git_repo(config: &Config) -> bool {
|
||||
let mut dir = config.cwd.to_path_buf();
|
||||
|
||||
loop {
|
||||
if dir.join(".git").exists() {
|
||||
return true;
|
||||
}
|
||||
|
||||
if !dir.pop() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ async fn mcp_manager_skips_slow_server_on_timeout() {
|
||||
servers.insert("slow".to_string(), slow_cfg);
|
||||
servers.insert("fast".to_string(), fast_cfg);
|
||||
|
||||
let (mgr, errs) = McpConnectionManager::new(servers)
|
||||
let (mgr, errs) = McpConnectionManager::new(servers, std::collections::HashSet::new())
|
||||
.await
|
||||
.expect("manager creation should not fail entirely");
|
||||
|
||||
@@ -89,7 +89,7 @@ async fn mcp_manager_respects_extended_startup_timeout() {
|
||||
let mut servers = HashMap::new();
|
||||
servers.insert("slow_ok".to_string(), slow_ok);
|
||||
|
||||
let (mgr, errs) = McpConnectionManager::new(servers)
|
||||
let (mgr, errs) = McpConnectionManager::new(servers, std::collections::HashSet::new())
|
||||
.await
|
||||
.expect("manager creation should not fail");
|
||||
|
||||
@@ -97,4 +97,3 @@ async fn mcp_manager_respects_extended_startup_timeout() {
|
||||
let tools = mgr.list_all_tools();
|
||||
assert!(tools.keys().any(|k| k.starts_with("slow_ok__")));
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
This document describes Codex’s experimental MCP interface: a JSON‑RPC API that runs over the Model Context Protocol (MCP) transport to control a local Codex engine.
|
||||
|
||||
- Status: experimental and subject to change without notice
|
||||
- Server binary: `codex mcp` (or `codex-mcp-server`)
|
||||
- Server binary: `codex mcp` (or `code-mcp-server`)
|
||||
- Transport: standard MCP over stdio (JSON‑RPC 2.0, line‑delimited)
|
||||
|
||||
## Overview
|
||||
@@ -120,4 +120,3 @@ While processing, the server emits `codex/event` notifications containing agent
|
||||
## Compatibility and stability
|
||||
|
||||
This interface is experimental. Method names, fields, and event shapes may evolve. For the authoritative schema, consult `protocol/src/mcp_protocol.rs` and the corresponding server wiring in `mcp-server/`.
|
||||
|
||||
|
||||
@@ -541,6 +541,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
FileChange::Update {
|
||||
unified_diff,
|
||||
move_path,
|
||||
..
|
||||
} => {
|
||||
let header = if let Some(dest) = move_path {
|
||||
format!(
|
||||
|
||||
@@ -165,6 +165,8 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
debug: None,
|
||||
show_raw_agent_reasoning: oss.then_some(true),
|
||||
tools_web_search_request: None,
|
||||
mcp_servers: None,
|
||||
experimental_client_tools: None,
|
||||
};
|
||||
// Parse `-c` overrides.
|
||||
let cli_kv_overrides = match config_overrides.parse_overrides() {
|
||||
|
||||
+1
-1
@@ -40,4 +40,4 @@ test:
|
||||
|
||||
# Run the MCP server
|
||||
mcp-server-run *args:
|
||||
cargo run -p codex-mcp-server -- "$@"
|
||||
cargo run -p code-mcp-server -- "$@"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Example usage:
|
||||
//!
|
||||
//! ```bash
|
||||
//! cargo run -p codex-mcp-client -- `codex-mcp-server`
|
||||
//! cargo run -p codex-mcp-client -- `code-mcp-server`
|
||||
//! ```
|
||||
//!
|
||||
//! Any additional arguments after the first one are forwarded to the spawned
|
||||
@@ -41,7 +41,7 @@ async fn main() -> Result<()> {
|
||||
let mut args: Vec<OsString> = std::env::args_os().skip(1).collect();
|
||||
|
||||
if args.is_empty() || args[0] == "--help" || args[0] == "-h" {
|
||||
eprintln!("Usage: mcp-client <program> [args..]\n\nExample: mcp-client codex-mcp-server");
|
||||
eprintln!("Usage: mcp-client <program> [args..]\n\nExample: mcp-client code-mcp-server");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let original_args = args.clone();
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
[package]
|
||||
edition = "2024"
|
||||
name = "codex-mcp-server"
|
||||
name = "code-mcp-server"
|
||||
version = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
name = "codex-mcp-server"
|
||||
name = "code-mcp-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "codex_mcp_server"
|
||||
name = "code_mcp_server"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
@@ -16,6 +16,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
agent-client-protocol = "0.0.13"
|
||||
codex-arg0 = { path = "../arg0" }
|
||||
codex-common = { path = "../common", features = ["cli"] }
|
||||
codex-core = { path = "../core" }
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
//! Asynchronous worker that executes an ACP tool-call inside a spawned Tokio task.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use agent_client_protocol::ToolCallUpdateFields;
|
||||
use anyhow::{Context as _, Result};
|
||||
use codex_core::CodexConversation;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::NewConversation;
|
||||
use codex_core::config::Config as CodexConfig;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
use mcp_types::CallToolResult;
|
||||
use mcp_types::ContentBlock;
|
||||
use mcp_types::RequestId;
|
||||
use mcp_types::TextContent;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
use crate::outgoing_message::OutgoingNotification;
|
||||
use serde_json;
|
||||
|
||||
pub async fn new_session(
|
||||
request_id: RequestId,
|
||||
config: CodexConfig,
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
session_map: Arc<Mutex<HashMap<Uuid, Arc<CodexConversation>>>>,
|
||||
conversation_manager: Arc<ConversationManager>,
|
||||
) -> Option<Uuid> {
|
||||
let NewConversation {
|
||||
conversation_id,
|
||||
conversation,
|
||||
session_configured: _,
|
||||
} = match conversation_manager.new_conversation(config).await {
|
||||
Ok(conv) => conv,
|
||||
Err(err) => {
|
||||
let result = CallToolResult {
|
||||
content: vec![ContentBlock::TextContent(TextContent {
|
||||
r#type: "text".to_string(),
|
||||
text: format!("Failed to start Codex session: {err}"),
|
||||
annotations: None,
|
||||
})],
|
||||
is_error: Some(true),
|
||||
structured_content: None,
|
||||
};
|
||||
outgoing
|
||||
.send_response(request_id, serde_json::to_value(result).unwrap_or_default())
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let session_uuid: Uuid = conversation_id.into();
|
||||
session_map
|
||||
.lock()
|
||||
.await
|
||||
.insert(session_uuid, conversation);
|
||||
|
||||
Some(session_uuid)
|
||||
}
|
||||
|
||||
pub async fn prompt(
|
||||
acp_session_id: acp::SessionId,
|
||||
codex: Arc<CodexConversation>,
|
||||
prompt: Vec<acp::ContentBlock>,
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
) -> Result<()> {
|
||||
let items: Vec<InputItem> = prompt
|
||||
.into_iter()
|
||||
.filter_map(acp_content_block_to_item)
|
||||
.collect();
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput { items })
|
||||
.await
|
||||
.context("failed to submit prompt to Codex")?;
|
||||
|
||||
loop {
|
||||
let event = codex.next_event().await?;
|
||||
|
||||
let acp_update = match event.msg {
|
||||
EventMsg::Error(error_event) => {
|
||||
anyhow::bail!("Error: {}", error_event.message);
|
||||
}
|
||||
EventMsg::AgentMessage(_) | EventMsg::AgentReasoning(_) => None,
|
||||
EventMsg::AgentMessageDelta(event) => Some(acp::SessionUpdate::AgentMessageChunk {
|
||||
content: event.delta.into(),
|
||||
}),
|
||||
EventMsg::AgentReasoningDelta(event) => Some(acp::SessionUpdate::AgentThoughtChunk {
|
||||
content: event.delta.into(),
|
||||
}),
|
||||
EventMsg::McpToolCallBegin(event) => {
|
||||
let invocation = event.invocation.clone();
|
||||
Some(acp::SessionUpdate::ToolCall(acp::ToolCall {
|
||||
id: acp::ToolCallId(event.call_id.into()),
|
||||
label: format!("{}: {}", invocation.server, invocation.tool),
|
||||
kind: acp::ToolKind::Other,
|
||||
status: acp::ToolCallStatus::InProgress,
|
||||
content: vec![],
|
||||
locations: vec![],
|
||||
raw_input: invocation.arguments,
|
||||
}))
|
||||
}
|
||||
EventMsg::McpToolCallEnd(event) => {
|
||||
let call_id = acp::ToolCallId(event.call_id.clone().into());
|
||||
let content = match event.result.clone() {
|
||||
Ok(result) => Some(
|
||||
result
|
||||
.content
|
||||
.into_iter()
|
||||
.map(|block| to_acp_content_block(block).into())
|
||||
.collect(),
|
||||
),
|
||||
Err(err) => Some(vec![err.into()]),
|
||||
};
|
||||
Some(acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate {
|
||||
id: call_id,
|
||||
fields: ToolCallUpdateFields {
|
||||
status: if event.is_success() {
|
||||
Some(acp::ToolCallStatus::Completed)
|
||||
} else {
|
||||
Some(acp::ToolCallStatus::Failed)
|
||||
},
|
||||
content,
|
||||
..Default::default()
|
||||
},
|
||||
}))
|
||||
}
|
||||
EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) => None,
|
||||
EventMsg::ExecCommandBegin(event) => Some(acp::SessionUpdate::ToolCall(
|
||||
codex_core::acp::new_execute_tool_call(
|
||||
&event.call_id,
|
||||
&event.command,
|
||||
acp::ToolCallStatus::InProgress,
|
||||
),
|
||||
)),
|
||||
EventMsg::ExecCommandEnd(event) => {
|
||||
let call_id = acp::ToolCallId(event.call_id.clone().into());
|
||||
Some(acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate {
|
||||
id: call_id,
|
||||
fields: ToolCallUpdateFields {
|
||||
status: if event.exit_code == 0 {
|
||||
Some(acp::ToolCallStatus::Completed)
|
||||
} else {
|
||||
Some(acp::ToolCallStatus::Failed)
|
||||
},
|
||||
content: Some(vec![event.stdout.into(), event.stderr.into()]),
|
||||
..Default::default()
|
||||
},
|
||||
}))
|
||||
}
|
||||
EventMsg::PatchApplyBegin(event) => Some(acp::SessionUpdate::ToolCall(
|
||||
codex_core::acp::new_patch_tool_call(
|
||||
&event.call_id,
|
||||
&event.changes,
|
||||
acp::ToolCallStatus::InProgress,
|
||||
),
|
||||
)),
|
||||
EventMsg::PatchApplyEnd(event) => {
|
||||
let call_id = acp::ToolCallId(event.call_id.clone().into());
|
||||
Some(acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate {
|
||||
id: call_id,
|
||||
fields: ToolCallUpdateFields {
|
||||
status: if event.success {
|
||||
Some(acp::ToolCallStatus::Completed)
|
||||
} else {
|
||||
Some(acp::ToolCallStatus::Failed)
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
}))
|
||||
}
|
||||
EventMsg::TaskComplete(_) => return Ok(()),
|
||||
EventMsg::SessionConfigured(_)
|
||||
| EventMsg::TokenCount(_)
|
||||
| EventMsg::TaskStarted
|
||||
| EventMsg::GetHistoryEntryResponse(_)
|
||||
| EventMsg::BackgroundEvent(_)
|
||||
| EventMsg::ShutdownComplete => None,
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(update) = acp_update {
|
||||
let notification = OutgoingNotification {
|
||||
method: acp::AGENT_METHODS.session_update.to_string(),
|
||||
params: Some(
|
||||
serde_json::to_value(acp::SessionNotification {
|
||||
session_id: acp_session_id.clone(),
|
||||
update,
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
};
|
||||
outgoing.send_notification(notification).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn acp_content_block_to_item(block: acp::ContentBlock) -> Option<InputItem> {
|
||||
match block {
|
||||
acp::ContentBlock::Text(text_content) => Some(InputItem::Text {
|
||||
text: text_content.text,
|
||||
}),
|
||||
acp::ContentBlock::ResourceLink(link) => Some(InputItem::Text {
|
||||
text: format!("@{}", link.uri),
|
||||
}),
|
||||
acp::ContentBlock::Image(image_content) => Some(InputItem::Image {
|
||||
image_url: image_content.data,
|
||||
}),
|
||||
acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_acp_annotations(annotations: mcp_types::Annotations) -> acp::Annotations {
|
||||
acp::Annotations {
|
||||
audience: annotations.audience.map(|roles| {
|
||||
roles
|
||||
.into_iter()
|
||||
.map(|role| match role {
|
||||
mcp_types::Role::User => acp::Role::User,
|
||||
mcp_types::Role::Assistant => acp::Role::Assistant,
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
last_modified: annotations.last_modified,
|
||||
priority: annotations.priority,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_acp_embedded_resource_resource(
|
||||
resource: mcp_types::EmbeddedResourceResource,
|
||||
) -> acp::EmbeddedResourceResource {
|
||||
match resource {
|
||||
mcp_types::EmbeddedResourceResource::TextResourceContents(text_contents) => {
|
||||
acp::EmbeddedResourceResource::TextResourceContents(acp::TextResourceContents {
|
||||
mime_type: text_contents.mime_type,
|
||||
text: text_contents.text,
|
||||
uri: text_contents.uri,
|
||||
})
|
||||
}
|
||||
mcp_types::EmbeddedResourceResource::BlobResourceContents(blob_contents) => {
|
||||
acp::EmbeddedResourceResource::BlobResourceContents(acp::BlobResourceContents {
|
||||
blob: blob_contents.blob,
|
||||
mime_type: blob_contents.mime_type,
|
||||
uri: blob_contents.uri,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn to_acp_content_block(block: mcp_types::ContentBlock) -> acp::ContentBlock {
|
||||
match block {
|
||||
ContentBlock::TextContent(text_content) => acp::ContentBlock::Text(acp::TextContent {
|
||||
annotations: text_content.annotations.map(to_acp_annotations),
|
||||
text: text_content.text,
|
||||
}),
|
||||
ContentBlock::ImageContent(image_content) => acp::ContentBlock::Image(acp::ImageContent {
|
||||
annotations: image_content.annotations.map(to_acp_annotations),
|
||||
data: image_content.data,
|
||||
mime_type: image_content.mime_type,
|
||||
}),
|
||||
ContentBlock::AudioContent(audio_content) => acp::ContentBlock::Audio(acp::AudioContent {
|
||||
annotations: audio_content.annotations.map(to_acp_annotations),
|
||||
data: audio_content.data,
|
||||
mime_type: audio_content.mime_type,
|
||||
}),
|
||||
ContentBlock::ResourceLink(resource_link) => {
|
||||
acp::ContentBlock::ResourceLink(acp::ResourceLink {
|
||||
annotations: resource_link.annotations.map(to_acp_annotations),
|
||||
uri: resource_link.uri,
|
||||
description: resource_link.description,
|
||||
mime_type: resource_link.mime_type,
|
||||
name: resource_link.name,
|
||||
size: resource_link.size,
|
||||
title: resource_link.title,
|
||||
})
|
||||
}
|
||||
ContentBlock::EmbeddedResource(embedded_resource) => {
|
||||
acp::ContentBlock::Resource(acp::EmbeddedResource {
|
||||
annotations: embedded_resource.annotations.map(to_acp_annotations),
|
||||
resource: to_acp_embedded_resource_resource(embedded_resource.resource),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -461,8 +461,18 @@ async fn apply_bespoke_event_handling(
|
||||
codex_core::protocol::FileChange::Delete => {
|
||||
codex_protocol::protocol::FileChange::Delete { content: String::new() }
|
||||
}
|
||||
codex_core::protocol::FileChange::Update { unified_diff, move_path } => {
|
||||
codex_protocol::protocol::FileChange::Update { unified_diff, move_path }
|
||||
codex_core::protocol::FileChange::Update {
|
||||
unified_diff,
|
||||
move_path,
|
||||
original_content,
|
||||
new_content,
|
||||
} => {
|
||||
codex_protocol::protocol::FileChange::Update {
|
||||
unified_diff,
|
||||
move_path,
|
||||
original_content,
|
||||
new_content,
|
||||
}
|
||||
}
|
||||
};
|
||||
(p, mapped)
|
||||
@@ -531,7 +541,7 @@ fn derive_config_from_params(
|
||||
include_plan_tool,
|
||||
..
|
||||
} = params;
|
||||
let overrides = ConfigOverrides {
|
||||
let overrides = ConfigOverrides {
|
||||
model,
|
||||
review_model: None,
|
||||
config_profile: profile,
|
||||
@@ -548,6 +558,8 @@ fn derive_config_from_params(
|
||||
show_raw_agent_reasoning: None,
|
||||
debug: None,
|
||||
tools_web_search_request: None,
|
||||
mcp_servers: None,
|
||||
experimental_client_tools: None,
|
||||
};
|
||||
|
||||
let cli_overrides = cli_overrides
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Configuration object accepted by the `codex` MCP tool-call.
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use mcp_types::Tool;
|
||||
use mcp_types::ToolInputSchema;
|
||||
use mcp_types::ToolOutputSchema;
|
||||
use schemars::JsonSchema;
|
||||
use schemars::r#gen::SchemaSettings;
|
||||
use serde::Deserialize;
|
||||
@@ -130,6 +132,50 @@ pub(crate) fn create_tool_for_codex_tool_call_param() -> Tool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a `Tool` definition for the `acp/new_session` tool-call.
|
||||
pub(crate) fn create_tool_for_acp_new_session() -> Tool {
|
||||
#[expect(clippy::expect_used)]
|
||||
let input_schema_value = serde_json::to_value(acp::NewSessionArguments::schema())
|
||||
.expect("ACP new_session input schema must serialise to JSON");
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
let output_schema_value = serde_json::to_value(acp::NewSessionOutput::schema())
|
||||
.expect("ACP new_session output schema must serialise to JSON");
|
||||
|
||||
let input_schema = serde_json::from_value::<ToolInputSchema>(input_schema_value)
|
||||
.unwrap_or_else(|e| panic!("failed to create Tool input schema: {e}"));
|
||||
let output_schema = serde_json::from_value::<ToolOutputSchema>(output_schema_value)
|
||||
.unwrap_or_else(|e| panic!("failed to create Tool output schema: {e}"));
|
||||
|
||||
Tool {
|
||||
name: acp::AGENT_METHODS.new_session.to_string(),
|
||||
title: Some(acp::AGENT_METHODS.new_session.to_string()),
|
||||
input_schema,
|
||||
output_schema: Some(output_schema),
|
||||
description: Some("Start a Codex session over ACP.".to_string()),
|
||||
annotations: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a `Tool` definition for the `acp/prompt` tool-call.
|
||||
pub(crate) fn create_tool_for_acp_prompt() -> Tool {
|
||||
#[expect(clippy::expect_used)]
|
||||
let input_schema_value = serde_json::to_value(acp::PromptArguments::schema())
|
||||
.expect("ACP prompt input schema must serialise to JSON");
|
||||
|
||||
let input_schema = serde_json::from_value::<ToolInputSchema>(input_schema_value)
|
||||
.unwrap_or_else(|e| panic!("failed to create Tool input schema: {e}"));
|
||||
|
||||
Tool {
|
||||
name: acp::AGENT_METHODS.prompt.to_string(),
|
||||
title: Some(acp::AGENT_METHODS.prompt.to_string()),
|
||||
input_schema,
|
||||
output_schema: None,
|
||||
description: Some("Send a prompt to an existing ACP Codex session.".to_string()),
|
||||
annotations: None,
|
||||
}
|
||||
}
|
||||
|
||||
impl CodexToolCallParam {
|
||||
/// Returns the initial user prompt to start the Codex conversation and the
|
||||
/// effective Config object generated from the supplied parameters.
|
||||
@@ -167,6 +213,8 @@ impl CodexToolCallParam {
|
||||
show_raw_agent_reasoning: None,
|
||||
debug: None,
|
||||
tools_web_search_request: None,
|
||||
mcp_servers: None,
|
||||
experimental_client_tools: None,
|
||||
};
|
||||
|
||||
let cli_overrides = cli_overrides
|
||||
|
||||
@@ -42,6 +42,7 @@ pub async fn run_codex_tool_session(
|
||||
initial_prompt: String,
|
||||
config: CodexConfig,
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
session_map: Arc<Mutex<HashMap<Uuid, Arc<CodexConversation>>>>,
|
||||
conversation_manager: Arc<ConversationManager>,
|
||||
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, Uuid>>>,
|
||||
) {
|
||||
@@ -66,6 +67,12 @@ pub async fn run_codex_tool_session(
|
||||
}
|
||||
};
|
||||
|
||||
let session_uuid: Uuid = conversation_id.into();
|
||||
session_map
|
||||
.lock()
|
||||
.await
|
||||
.insert(session_uuid, conversation.clone());
|
||||
|
||||
let session_configured_event = Event {
|
||||
// Use a fake id value for now.
|
||||
id: "".to_string(),
|
||||
@@ -90,7 +97,7 @@ pub async fn run_codex_tool_session(
|
||||
running_requests_id_to_codex_uuid
|
||||
.lock()
|
||||
.await
|
||||
.insert(id.clone(), conversation_id.into());
|
||||
.insert(id.clone(), session_uuid);
|
||||
let submission = Submission {
|
||||
id: sub_id.clone(),
|
||||
op: Op::UserInput {
|
||||
|
||||
@@ -20,6 +20,7 @@ use tracing::error;
|
||||
use tracing::info;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
mod acp_tool_runner;
|
||||
mod codex_message_processor;
|
||||
mod codex_tool_config;
|
||||
mod codex_tool_runner;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use codex_arg0::arg0_dispatch_or_else;
|
||||
use codex_common::CliConfigOverrides;
|
||||
use codex_mcp_server::run_main;
|
||||
use code_mcp_server::run_main;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
|
||||
|
||||
@@ -6,15 +6,22 @@ use crate::codex_tool_config::CodexToolCallParam;
|
||||
use crate::codex_tool_config::CodexToolCallReplyParam;
|
||||
use crate::codex_tool_config::create_tool_for_codex_tool_call_param;
|
||||
use crate::codex_tool_config::create_tool_for_codex_tool_call_reply_param;
|
||||
use crate::codex_tool_config::create_tool_for_acp_new_session;
|
||||
use crate::codex_tool_config::create_tool_for_acp_prompt;
|
||||
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
use agent_client_protocol as acp;
|
||||
use anyhow::Context as _;
|
||||
use codex_protocol::mcp_protocol::ClientRequest;
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::config_types::McpServerConfig;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::default_client::USER_AGENT_SUFFIX;
|
||||
use codex_core::default_client::get_codex_user_agent_default;
|
||||
use codex_core::CodexConversation;
|
||||
use codex_core::protocol::Submission;
|
||||
use codex_protocol::mcp_protocol::AuthMode;
|
||||
use mcp_types::CallToolRequestParams;
|
||||
@@ -44,6 +51,7 @@ pub(crate) struct MessageProcessor {
|
||||
initialized: bool,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
conversation_manager: Arc<ConversationManager>,
|
||||
session_map: Arc<Mutex<HashMap<Uuid, Arc<CodexConversation>>>>,
|
||||
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, Uuid>>>,
|
||||
}
|
||||
|
||||
@@ -75,6 +83,7 @@ impl MessageProcessor {
|
||||
initialized: false,
|
||||
codex_linux_sandbox_exe,
|
||||
conversation_manager,
|
||||
session_map: Arc::new(Mutex::new(HashMap::new())),
|
||||
running_requests_id_to_codex_uuid: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
@@ -238,7 +247,7 @@ impl MessageProcessor {
|
||||
instructions: None,
|
||||
protocol_version: params.protocol_version.clone(),
|
||||
server_info: mcp_types::Implementation {
|
||||
name: "codex-mcp-server".to_string(),
|
||||
name: "code-mcp-server".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
title: Some("Codex".to_string()),
|
||||
user_agent: Some(get_codex_user_agent_default()),
|
||||
@@ -327,6 +336,8 @@ impl MessageProcessor {
|
||||
tools: vec![
|
||||
create_tool_for_codex_tool_call_param(),
|
||||
create_tool_for_codex_tool_call_reply_param(),
|
||||
create_tool_for_acp_new_session(),
|
||||
create_tool_for_acp_prompt(),
|
||||
],
|
||||
next_cursor: None,
|
||||
};
|
||||
@@ -349,6 +360,12 @@ impl MessageProcessor {
|
||||
self.handle_tool_call_codex_session_reply(id, arguments)
|
||||
.await
|
||||
}
|
||||
_ if name == acp::AGENT_METHODS.new_session => {
|
||||
self.handle_tool_call_acp_new_session(id, arguments).await
|
||||
}
|
||||
_ if name == acp::AGENT_METHODS.prompt => {
|
||||
self.handle_tool_call_acp_prompt(id, arguments).await
|
||||
}
|
||||
_ => {
|
||||
let result = CallToolResult {
|
||||
content: vec![ContentBlock::TextContent(TextContent {
|
||||
@@ -419,9 +436,10 @@ impl MessageProcessor {
|
||||
}
|
||||
};
|
||||
|
||||
// Clone outgoing and server to move into async task.
|
||||
// Clone outgoing and session map to move into async task.
|
||||
let outgoing = self.outgoing.clone();
|
||||
let conversation_manager = self.conversation_manager.clone();
|
||||
let session_map = self.session_map.clone();
|
||||
let running_requests_id_to_codex_uuid = self.running_requests_id_to_codex_uuid.clone();
|
||||
|
||||
// Spawn an async task to handle the Codex session so that we do not
|
||||
@@ -433,6 +451,7 @@ impl MessageProcessor {
|
||||
initial_prompt,
|
||||
config,
|
||||
outgoing,
|
||||
session_map,
|
||||
conversation_manager,
|
||||
running_requests_id_to_codex_uuid,
|
||||
)
|
||||
@@ -504,17 +523,17 @@ impl MessageProcessor {
|
||||
}
|
||||
};
|
||||
|
||||
// Clone outgoing to move into async task.
|
||||
let outgoing = self.outgoing.clone();
|
||||
let running_requests_id_to_codex_uuid = self.running_requests_id_to_codex_uuid.clone();
|
||||
let session_map = self.session_map.clone();
|
||||
|
||||
let codex = match self
|
||||
.conversation_manager
|
||||
.get_conversation(codex_protocol::mcp_protocol::ConversationId::from(session_id))
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
tokio::spawn(async move {
|
||||
let codex = {
|
||||
let map = session_map.lock().await;
|
||||
map.get(&session_id).cloned()
|
||||
};
|
||||
|
||||
let Some(codex) = codex else {
|
||||
tracing::warn!("Session not found for session_id: {session_id}");
|
||||
let result = CallToolResult {
|
||||
content: vec![ContentBlock::TextContent(TextContent {
|
||||
@@ -527,27 +546,17 @@ impl MessageProcessor {
|
||||
};
|
||||
outgoing.send_response(request_id, result).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Spawn the long-running reply handler.
|
||||
tokio::spawn({
|
||||
let codex = codex.clone();
|
||||
let outgoing = outgoing.clone();
|
||||
let prompt = prompt.clone();
|
||||
let running_requests_id_to_codex_uuid = running_requests_id_to_codex_uuid.clone();
|
||||
|
||||
async move {
|
||||
crate::codex_tool_runner::run_codex_tool_session_reply(
|
||||
codex,
|
||||
outgoing,
|
||||
request_id,
|
||||
prompt,
|
||||
running_requests_id_to_codex_uuid,
|
||||
session_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
crate::codex_tool_runner::run_codex_tool_session_reply(
|
||||
codex,
|
||||
outgoing,
|
||||
request_id,
|
||||
prompt,
|
||||
running_requests_id_to_codex_uuid,
|
||||
session_id,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -593,16 +602,20 @@ impl MessageProcessor {
|
||||
};
|
||||
tracing::info!("session_id: {session_id}");
|
||||
|
||||
// Obtain the Codex conversation from the server.
|
||||
let codex_arc = match self
|
||||
.conversation_manager
|
||||
.get_conversation(codex_protocol::mcp_protocol::ConversationId::from(session_id))
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
tracing::warn!("Session not found for session_id: {session_id}");
|
||||
return;
|
||||
// Obtain the Codex conversation from the session map, falling back to the conversation manager.
|
||||
let codex_arc = if let Some(conv) = self.session_map.lock().await.get(&session_id).cloned() {
|
||||
conv
|
||||
} else {
|
||||
match self
|
||||
.conversation_manager
|
||||
.get_conversation(ConversationId::from(session_id))
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
tracing::warn!("Session not found for session_id: {session_id}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -641,6 +654,185 @@ impl MessageProcessor {
|
||||
);
|
||||
}
|
||||
|
||||
async fn handle_tool_call_acp_new_session(
|
||||
&self,
|
||||
request_id: RequestId,
|
||||
arguments: Option<serde_json::Value>,
|
||||
) {
|
||||
let config = match Self::acp_new_session_cfg(arguments) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to construct new session config: {}", err);
|
||||
let result = CallToolResult {
|
||||
content: vec![ContentBlock::TextContent(TextContent {
|
||||
r#type: "text".to_owned(),
|
||||
text: format!("Failed to construct new session config: {err}"),
|
||||
annotations: None,
|
||||
})],
|
||||
is_error: Some(true),
|
||||
structured_content: None,
|
||||
};
|
||||
self.outgoing.send_response(request_id, result).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let outgoing = self.outgoing.clone();
|
||||
let session_map = self.session_map.clone();
|
||||
let conversation_manager = self.conversation_manager.clone();
|
||||
|
||||
task::spawn(async move {
|
||||
let Some(session_id) = crate::acp_tool_runner::new_session(
|
||||
request_id.clone(),
|
||||
config,
|
||||
outgoing.clone(),
|
||||
session_map,
|
||||
conversation_manager,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let acp_response = acp::NewSessionOutput {
|
||||
session_id: acp::SessionId(session_id.to_string().into()),
|
||||
};
|
||||
|
||||
let response = CallToolResult {
|
||||
content: vec![],
|
||||
is_error: None,
|
||||
structured_content: Some(serde_json::to_value(acp_response).unwrap_or_default()),
|
||||
};
|
||||
|
||||
outgoing.send_response(request_id, response).await;
|
||||
});
|
||||
}
|
||||
|
||||
fn acp_new_session_cfg(arguments: Option<serde_json::Value>) -> anyhow::Result<Config> {
|
||||
let arguments = arguments.context("Arguments required")?;
|
||||
let arguments = serde_json::from_value::<acp::NewSessionArguments>(arguments)?;
|
||||
|
||||
let mcp_servers: HashMap<String, McpServerConfig> = arguments
|
||||
.mcp_servers
|
||||
.into_iter()
|
||||
.map(|cfg| {
|
||||
let env: HashMap<String, String> = cfg
|
||||
.env
|
||||
.into_iter()
|
||||
.map(|var| (var.name, var.value))
|
||||
.collect();
|
||||
(
|
||||
cfg.name,
|
||||
McpServerConfig {
|
||||
command: cfg.command.display().to_string(),
|
||||
args: cfg.args,
|
||||
env: if env.is_empty() { None } else { Some(env) },
|
||||
startup_timeout_ms: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let overrides = codex_core::config::ConfigOverrides {
|
||||
cwd: Some(arguments.cwd),
|
||||
mcp_servers: Some(mcp_servers),
|
||||
experimental_client_tools: Some(arguments.client_tools),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg =
|
||||
codex_core::config::Config::load_with_cli_overrides(Default::default(), overrides)?;
|
||||
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
async fn handle_tool_call_acp_prompt(
|
||||
&self,
|
||||
request_id: RequestId,
|
||||
arguments: Option<serde_json::Value>,
|
||||
) {
|
||||
let (session_id, acp_session_id, prompt) = match Self::acp_prompt_arguments(arguments) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to parse arguments: {}", err);
|
||||
let result = CallToolResult {
|
||||
content: vec![ContentBlock::TextContent(TextContent {
|
||||
r#type: "text".to_owned(),
|
||||
text: format!("Failed to parse arguments: {err}"),
|
||||
annotations: None,
|
||||
})],
|
||||
is_error: Some(true),
|
||||
structured_content: None,
|
||||
};
|
||||
self.outgoing.send_response(request_id, result).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let session = {
|
||||
let map = self.session_map.lock().await;
|
||||
map.get(&session_id).cloned()
|
||||
};
|
||||
|
||||
let Some(session) = session else {
|
||||
tracing::warn!("Unknown session id: {}", session_id);
|
||||
let result = CallToolResult {
|
||||
content: vec![ContentBlock::TextContent(TextContent {
|
||||
r#type: "text".to_owned(),
|
||||
text: format!("Unknown session id: {session_id}"),
|
||||
annotations: None,
|
||||
})],
|
||||
is_error: Some(true),
|
||||
structured_content: None,
|
||||
};
|
||||
self.outgoing.send_response(request_id, result).await;
|
||||
return;
|
||||
};
|
||||
|
||||
let outgoing = self.outgoing.clone();
|
||||
let requests_codex_map = self.running_requests_id_to_codex_uuid.clone();
|
||||
|
||||
task::spawn(async move {
|
||||
requests_codex_map
|
||||
.lock()
|
||||
.await
|
||||
.insert(request_id.clone(), session_id);
|
||||
|
||||
let result = crate::acp_tool_runner::prompt(acp_session_id, session, prompt, outgoing.clone()).await;
|
||||
|
||||
let result = match result {
|
||||
Ok(()) => CallToolResult {
|
||||
content: vec![],
|
||||
is_error: Some(false),
|
||||
structured_content: None,
|
||||
},
|
||||
Err(err) => CallToolResult {
|
||||
content: vec![ContentBlock::TextContent(TextContent {
|
||||
annotations: None,
|
||||
text: err.to_string(),
|
||||
r#type: "text".to_owned(),
|
||||
})],
|
||||
is_error: Some(true),
|
||||
structured_content: None,
|
||||
},
|
||||
};
|
||||
|
||||
outgoing.send_response(request_id.clone(), result).await;
|
||||
|
||||
requests_codex_map.lock().await.remove(&request_id);
|
||||
});
|
||||
}
|
||||
|
||||
fn acp_prompt_arguments(
|
||||
arguments: Option<serde_json::Value>,
|
||||
) -> anyhow::Result<(Uuid, acp::SessionId, Vec<acp::ContentBlock>)> {
|
||||
let arguments = arguments.context("Arguments required")?;
|
||||
let arguments = serde_json::from_value::<acp::PromptArguments>(arguments)?;
|
||||
|
||||
let session_id = Uuid::parse_str(&arguments.session_id.0)?;
|
||||
Ok((session_id, arguments.session_id, arguments.prompt))
|
||||
}
|
||||
|
||||
fn handle_resource_updated(
|
||||
&self,
|
||||
params: <mcp_types::ResourceUpdatedNotification as mcp_types::ModelContextProtocolNotification>::Params,
|
||||
|
||||
@@ -57,6 +57,9 @@ pub(crate) async fn handle_create_conversation(
|
||||
disable_response_storage: None,
|
||||
show_raw_agent_reasoning: None,
|
||||
debug: None,
|
||||
tools_web_search_request: None,
|
||||
mcp_servers: None,
|
||||
experimental_client_tools: None,
|
||||
};
|
||||
|
||||
let cfg: CodexConfig = match CodexConfig::load_with_cli_overrides(cli_overrides, overrides) {
|
||||
|
||||
@@ -10,7 +10,7 @@ path = "lib.rs"
|
||||
anyhow = "1"
|
||||
assert_cmd = "2"
|
||||
codex-core = { path = "../../../core" }
|
||||
codex-mcp-server = { path = "../.." }
|
||||
code-mcp-server = { path = "../.." }
|
||||
codex-protocol = { path = "../../../protocol" }
|
||||
mcp-types = { path = "../../../mcp-types" }
|
||||
os_info = "3.12.0"
|
||||
|
||||
@@ -11,7 +11,7 @@ use tokio::process::ChildStdout;
|
||||
|
||||
use anyhow::Context;
|
||||
use assert_cmd::prelude::*;
|
||||
use codex_mcp_server::CodexToolCallParam;
|
||||
use code_mcp_server::CodexToolCallParam;
|
||||
use codex_protocol::mcp_protocol::AddConversationListenerParams;
|
||||
use codex_protocol::mcp_protocol::CancelLoginChatGptParams;
|
||||
use codex_protocol::mcp_protocol::GetAuthStatusParams;
|
||||
@@ -64,8 +64,8 @@ impl McpProcess {
|
||||
env_overrides: &[(&str, Option<&str>)],
|
||||
) -> anyhow::Result<Self> {
|
||||
// Use assert_cmd to locate the binary path and then switch to tokio::process::Command
|
||||
let std_cmd = StdCommand::cargo_bin("codex-mcp-server")
|
||||
.context("should find binary for codex-mcp-server")?;
|
||||
let std_cmd = StdCommand::cargo_bin("code-mcp-server")
|
||||
.context("should find binary for code-mcp-server")?;
|
||||
|
||||
let program = std_cmd.get_program().to_owned();
|
||||
|
||||
@@ -91,7 +91,7 @@ impl McpProcess {
|
||||
let mut process = cmd
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.context("codex-mcp-server proc should start")?;
|
||||
.context("code-mcp-server proc should start")?;
|
||||
let stdin = process
|
||||
.stdin
|
||||
.take()
|
||||
@@ -169,7 +169,7 @@ impl McpProcess {
|
||||
},
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "codex-mcp-server",
|
||||
"name": "code-mcp-server",
|
||||
"title": "Codex",
|
||||
"version": "0.0.0",
|
||||
"user_agent": user_agent
|
||||
@@ -208,6 +208,27 @@ impl McpProcess {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn send_tool_call(
|
||||
&mut self,
|
||||
name: &str,
|
||||
arguments: Option<serde_json::Value>,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = CallToolRequestParams {
|
||||
name: name.to_string(),
|
||||
arguments,
|
||||
};
|
||||
self.send_request(
|
||||
mcp_types::CallToolRequest::METHOD,
|
||||
Some(serde_json::to_value(params)?),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn send_list_tools_request(&mut self) -> anyhow::Result<i64> {
|
||||
self.send_request(mcp_types::ListToolsRequest::METHOD, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send a `newConversation` JSON-RPC request.
|
||||
pub async fn send_new_conversation_request(
|
||||
&mut self,
|
||||
@@ -336,7 +357,7 @@ impl McpProcess {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_jsonrpc_message(&mut self) -> anyhow::Result<JSONRPCMessage> {
|
||||
pub async fn read_jsonrpc_message(&mut self) -> anyhow::Result<JSONRPCMessage> {
|
||||
let mut line = String::new();
|
||||
self.stdout.read_line(&mut line).await?;
|
||||
let message = serde_json::from_str::<JSONRPCMessage>(&line)?;
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use agent_client_protocol as acp;
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use codex_core::protocol::FileChange;
|
||||
use codex_core::protocol::ReviewDecision;
|
||||
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
use codex_mcp_server::CodexToolCallParam;
|
||||
use codex_mcp_server::ExecApprovalElicitRequestParams;
|
||||
use codex_mcp_server::ExecApprovalResponse;
|
||||
use codex_mcp_server::PatchApprovalElicitRequestParams;
|
||||
use codex_mcp_server::PatchApprovalResponse;
|
||||
use code_mcp_server::CodexToolCallParam;
|
||||
use code_mcp_server::ExecApprovalElicitRequestParams;
|
||||
use code_mcp_server::ExecApprovalResponse;
|
||||
use code_mcp_server::PatchApprovalElicitRequestParams;
|
||||
use code_mcp_server::PatchApprovalResponse;
|
||||
use mcp_types::CallToolResult;
|
||||
use mcp_types::ElicitRequest;
|
||||
use mcp_types::ElicitRequestParamsRequestedSchema;
|
||||
use mcp_types::JSONRPCMessage;
|
||||
use mcp_types::JSONRPC_VERSION;
|
||||
use mcp_types::JSONRPCRequest;
|
||||
use mcp_types::JSONRPCResponse;
|
||||
use mcp_types::ListToolsResult;
|
||||
use mcp_types::ModelContextProtocolRequest;
|
||||
use mcp_types::RequestId;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -33,6 +38,46 @@ use mcp_test_support::create_shell_sse_response;
|
||||
// Allow ample time on slower CI or under load to avoid flakes.
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_tools_list_exposes_acp_entries() {
|
||||
if let Err(err) = tools_list_exposes_acp_entries().await {
|
||||
panic!("failure: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn tools_list_exposes_acp_entries() -> anyhow::Result<()> {
|
||||
let McpHandle {
|
||||
mut process,
|
||||
server: _server,
|
||||
dir: _dir,
|
||||
} = create_mcp_process(Vec::new()).await?;
|
||||
|
||||
let request_id = process.send_list_tools_request().await?;
|
||||
let response = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
process.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let list_result: ListToolsResult = serde_json::from_value(response.result)?;
|
||||
let tool_names: HashSet<&str> = list_result.tools.iter().map(|tool| tool.name.as_str()).collect();
|
||||
|
||||
for required in [
|
||||
"codex",
|
||||
"codex-reply",
|
||||
acp::AGENT_METHODS.new_session,
|
||||
acp::AGENT_METHODS.prompt,
|
||||
] {
|
||||
assert!(
|
||||
tool_names.contains(required),
|
||||
"expected tools/list to include {required}, got {:?}",
|
||||
tool_names
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that a shell command that is not on the "trusted" list triggers an
|
||||
/// elicitation request to the MCP and that sending the approval runs the
|
||||
/// command, as expected.
|
||||
@@ -52,6 +97,20 @@ async fn test_shell_command_approval_triggers_elicitation() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn test_acp_prompt_round_trip() {
|
||||
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) = acp_prompt_round_trip().await {
|
||||
panic!("failure: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||||
// Use a simple, untrusted command that creates a file so we can
|
||||
// observe a side-effect.
|
||||
@@ -163,6 +222,90 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn acp_prompt_round_trip() -> anyhow::Result<()> {
|
||||
let responses = vec![create_final_assistant_message_sse_response("All done!")?];
|
||||
let temp_cwd = TempDir::new()?;
|
||||
|
||||
let McpHandle {
|
||||
mut process,
|
||||
server: _server,
|
||||
dir: _dir,
|
||||
} = create_mcp_process(responses).await?;
|
||||
|
||||
let new_session_args = acp::NewSessionArguments {
|
||||
mcp_servers: Vec::new(),
|
||||
client_tools: acp::ClientTools {
|
||||
request_permission: None,
|
||||
write_text_file: None,
|
||||
read_text_file: None,
|
||||
},
|
||||
cwd: temp_cwd.path().to_path_buf(),
|
||||
};
|
||||
|
||||
let new_session_request_id = process
|
||||
.send_tool_call(
|
||||
acp::AGENT_METHODS.new_session,
|
||||
Some(serde_json::to_value(new_session_args)?),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let new_session_response = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
process.read_stream_until_response_message(RequestId::Integer(new_session_request_id)),
|
||||
)
|
||||
.await??;
|
||||
let new_session_result: CallToolResult = serde_json::from_value(new_session_response.result)?;
|
||||
let structured = new_session_result
|
||||
.structured_content
|
||||
.context("new_session should return structured content")?;
|
||||
let new_session_output: acp::NewSessionOutput = serde_json::from_value(structured)?;
|
||||
|
||||
let prompt_args = acp::PromptArguments {
|
||||
session_id: new_session_output.session_id.clone(),
|
||||
prompt: vec![acp::ContentBlock::Text(acp::TextContent {
|
||||
annotations: None,
|
||||
text: "Hello from ACP".to_string(),
|
||||
})],
|
||||
};
|
||||
|
||||
let prompt_request_id = process
|
||||
.send_tool_call(
|
||||
acp::AGENT_METHODS.prompt,
|
||||
Some(serde_json::to_value(prompt_args)?),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut saw_session_update = false;
|
||||
|
||||
loop {
|
||||
let message = timeout(DEFAULT_READ_TIMEOUT, process.read_jsonrpc_message()).await??;
|
||||
match message {
|
||||
JSONRPCMessage::Notification(notification) => {
|
||||
if notification.method == acp::AGENT_METHODS.session_update {
|
||||
saw_session_update = true;
|
||||
}
|
||||
}
|
||||
JSONRPCMessage::Request(_) => {
|
||||
// Codex emits event requests (codex/event). Ignore.
|
||||
}
|
||||
JSONRPCMessage::Response(response) => {
|
||||
if response.id == RequestId::Integer(prompt_request_id) {
|
||||
let prompt_result: CallToolResult = serde_json::from_value(response.result)?;
|
||||
assert_eq!(prompt_result.is_error, Some(false));
|
||||
break;
|
||||
}
|
||||
}
|
||||
JSONRPCMessage::Error(error) => {
|
||||
anyhow::bail!("unexpected MCP error: {error:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(saw_session_update, "acp/prompt should emit at least one session update notification");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_expected_elicitation_request(
|
||||
elicitation_request_id: RequestId,
|
||||
command: Vec<String>,
|
||||
@@ -254,6 +397,8 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||||
FileChange::Update {
|
||||
unified_diff: "@@ -1 +1 @@\n-original content\n+modified content\n".to_string(),
|
||||
move_path: None,
|
||||
original_content: "original content\n".to_string(),
|
||||
new_content: "modified content\n".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ async fn main() -> Result<()> {
|
||||
servers.insert("slow_ok".to_string(), slow_ok);
|
||||
servers.insert("slow_fail".to_string(), slow_fail);
|
||||
|
||||
let (mgr, errs) = McpConnectionManager::new(servers).await?;
|
||||
let (mgr, errs) = McpConnectionManager::new(servers, std::collections::HashSet::new()).await?;
|
||||
println!("Errors: {}", errs.len());
|
||||
for (name, e) in &errs {
|
||||
println!(" {}: {}", name, e);
|
||||
@@ -76,4 +76,3 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1293,6 +1293,8 @@ pub enum FileChange {
|
||||
Update {
|
||||
unified_diff: String,
|
||||
move_path: Option<PathBuf>,
|
||||
original_content: String,
|
||||
new_content: String,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1412,6 +1412,8 @@ impl App<'_> {
|
||||
FileChange::Update {
|
||||
unified_diff: "+test\n-test2".to_string(),
|
||||
move_path: None,
|
||||
original_content: "test2".to_string(),
|
||||
new_content: "test".to_string(),
|
||||
},
|
||||
),
|
||||
]),
|
||||
|
||||
@@ -6420,6 +6420,8 @@ impl ChatWidget<'_> {
|
||||
codex_core::protocol::FileChange::Update {
|
||||
unified_diff: unified.clone(),
|
||||
move_path: None,
|
||||
original_content: baseline.clone(),
|
||||
new_content: current.clone(),
|
||||
},
|
||||
);
|
||||
let detail = create_diff_details_only(&single);
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ expression: visible_after
|
||||
Main Causes
|
||||
|
||||
- Static linking style: Each bin (codex, codex-tui, codex-exec,
|
||||
codex-mcp-server, etc.) statically links its full dependency graph, so common
|
||||
code-mcp-server, etc.) statically links its full dependency graph, so common
|
||||
code isn’t shared at runtime across executables.
|
||||
- Heavy deps (HTTP/TLS): reqwest brings in Hyper, HTTP/2, compressors,
|
||||
and a TLS stack (rustls by default; OpenSSL on musl). In core, login, tui,
|
||||
|
||||
@@ -259,6 +259,7 @@ pub(super) fn create_diff_summary_with_width(
|
||||
FileChange::Update {
|
||||
unified_diff,
|
||||
move_path,
|
||||
..
|
||||
} => {
|
||||
let (added, removed) = count_from_unified(unified_diff);
|
||||
let display_path = if let Some(new_path) = move_path {
|
||||
@@ -415,6 +416,7 @@ fn render_patch_details_with_width(
|
||||
FileChange::Update {
|
||||
unified_diff,
|
||||
move_path: _,
|
||||
..
|
||||
} => {
|
||||
if let Ok(patch) = diffy::Patch::from_str(unified_diff) {
|
||||
let mut is_first_hunk = true;
|
||||
@@ -730,6 +732,8 @@ mod tests {
|
||||
FileChange::Update {
|
||||
unified_diff: patch,
|
||||
move_path: Some(PathBuf::from("src/lib_new.rs")),
|
||||
original_content: original.to_string(),
|
||||
new_content: modified.to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -771,6 +775,8 @@ mod tests {
|
||||
FileChange::Update {
|
||||
unified_diff: patch,
|
||||
move_path: None,
|
||||
original_content: original.to_string(),
|
||||
new_content: modified.to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -793,6 +799,8 @@ mod tests {
|
||||
FileChange::Update {
|
||||
unified_diff: patch,
|
||||
move_path: None,
|
||||
original_content: original.to_string(),
|
||||
new_content: modified.to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -816,6 +824,8 @@ mod tests {
|
||||
FileChange::Update {
|
||||
unified_diff: patch,
|
||||
move_path: None,
|
||||
original_content: original.to_string(),
|
||||
new_content: modified.to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -154,6 +154,8 @@ pub async fn run_main(
|
||||
debug: Some(cli.debug),
|
||||
// Enable web search by default (no CLI flag).
|
||||
tools_web_search_request: Some(true),
|
||||
mcp_servers: None,
|
||||
experimental_client_tools: None,
|
||||
};
|
||||
|
||||
// Parse `-c` overrides from the CLI.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Using Code with Zed via ACP
|
||||
|
||||
The Rust MCP server now exposes the Agent Client Protocol (ACP) tools that Zed expects. This section walks through a minimal setup.
|
||||
|
||||
## 1. Configure Code's MCP server
|
||||
|
||||
Update your `CODEX_HOME/config.toml` (defaults to `~/.code/config.toml`) so Code knows which "client tools" Zed will handle and how to launch Zed's MCP endpoint:
|
||||
|
||||
```toml
|
||||
[experimental_client_tools]
|
||||
request_permission = { mcp_server = "zed", tool_name = "permission/request" }
|
||||
read_text_file = { mcp_server = "zed", tool_name = "fs/read_text_file" }
|
||||
write_text_file = { mcp_server = "zed", tool_name = "fs/write_text_file" }
|
||||
|
||||
[mcp_servers.zed]
|
||||
command = "/Applications/Zed.app/Contents/MacOS/zed" # adjust for your OS
|
||||
args = ["mcp", "--stdio"]
|
||||
env = {}
|
||||
```
|
||||
|
||||
Any existing MCP servers can remain in this table; the exiting overrides simply add Zed as another entry.
|
||||
|
||||
## 2. Launch the MCP server under the new name
|
||||
|
||||
Build the workspace and run:
|
||||
|
||||
```bash
|
||||
cargo run -p code-mcp-server -- --stdio
|
||||
```
|
||||
|
||||
The server will advertise four tools during the handshake: `codex`, `codex-reply`, `acp/new_session`, and `acp/prompt`.
|
||||
|
||||
## 3. Point Zed at Code's MCP endpoint
|
||||
|
||||
Add an entry to Zed's MCP configuration (for example, `~/.config/zed/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"code": {
|
||||
"command": "/path/to/code-mcp-server",
|
||||
"args": ["--stdio"],
|
||||
"env": {
|
||||
"CODEX_HOME": "/Users/you/.code" ,
|
||||
"RUST_LOG": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When Zed starts the server it will discover the ACP tools, call `acp/new_session` to create a Codex conversation, and use `acp/prompt` for additional turns. Code streams `acp/session_update` notifications back to Zed, including exec/patch events and approval requests.
|
||||
|
||||
## 4. Permission flow summary
|
||||
|
||||
- File reads/writes go through the MCP tools you listed under `experimental_client_tools`.
|
||||
- Explicit approvals (for shell commands or apply_patch) are raised via `permission/request`.
|
||||
- Code retains confirm guards, sandbox policies, and validation harnesses; the server simply forwards the resulting events over ACP.
|
||||
|
||||
Once configured, Zed users can work with Code without any terminal interaction beyond starting `code-mcp-server`.
|
||||
@@ -0,0 +1,120 @@
|
||||
# Upstream ACP Integration – Design Notes
|
||||
|
||||
## Context
|
||||
|
||||
Upstream PR [openai/codex#1707](https://github.com/openai/codex/pull/1707) introduces an
|
||||
experimental Agent Client Protocol (ACP) bridge so Zed can drive Codex via new
|
||||
`acp/new_session` and `acp/prompt` MCP tools. The branch (fetched locally as
|
||||
`upstream-pr-1707`) rewrites large portions of `codex-rs/core`, the MCP server,
|
||||
and the TypeScript CLI/TUI to accommodate the new workflow.
|
||||
|
||||
### Current Status (2025-09-22)
|
||||
|
||||
- **Core** – Apply-patch execution now runs in-process with the ACP filesystem
|
||||
shim, preserving Code’s validation harness and approval prompts while
|
||||
emitting ACP tool-call metadata for downstream consumers.
|
||||
- **MCP Server** – `acp/new_session` and `acp/prompt` are available alongside
|
||||
existing Codex tools. The new `acp_tool_runner` bridges Codex events to ACP
|
||||
`SessionUpdate`s and reuses the existing conversation manager to track live
|
||||
sessions.
|
||||
- **Configuration** – `ConfigOverrides` and the TOML schema understand
|
||||
`experimental_client_tools` plus inline MCP server definitions, allowing IDE
|
||||
clients to announce their ACP capabilities without dropping legacy settings.
|
||||
- **Validation** – `./build-fast.sh` passes after the integration, and new MCP
|
||||
tests cover ACP list-tool discovery plus a prompt roundtrip.
|
||||
|
||||
Code diverges substantially from upstream in these same areas: we retain the
|
||||
Rust TUI, trimmed CLI scripts, and extended core features such as confirm
|
||||
guards, queued user input, plan tool updates, and sandbox policies tuned for our
|
||||
workflow. Directly rebasing onto upstream would drop or regress many of these
|
||||
capabilities.
|
||||
|
||||
## What Upstream Added
|
||||
|
||||
- **Core (`codex-rs/core`)**
|
||||
- New `acp.rs` module with helpers for translating Codex exec/patch events
|
||||
into ACP `ToolCall` updates, including an ACP-backed `FileSystem` shim and a
|
||||
permission-request flow (`acp::request_permission`).
|
||||
- `codex.rs` rewritten around `agent_client_protocol`, removing many existing
|
||||
operations (`QueueUserInput`, `RegisterApprovedCommand`, validation toggles,
|
||||
etc.) and introducing new event wiring for ACP tool calls.
|
||||
- `protocol.rs` pared down to match the simplified upstream surface (fewer
|
||||
`Op` variants, different `AskForApproval` options, new MCP call events).
|
||||
- **MCP server (`codex-rs/mcp-server`)**
|
||||
- New `acp_tool_runner.rs` that spawns Codex sessions on demand, relays MCP
|
||||
notifications, and surfaces ACP updates.
|
||||
- `message_processor.rs` extended to expose `acp/new_session` and
|
||||
`acp/prompt`, and to translate Codex events into ACP notifications.
|
||||
- **CLI/TUI (Node + Rust)**
|
||||
- TypeScript terminal UI completely replaced with ACP-first experience.
|
||||
- Rust TUI and associated tests removed.
|
||||
|
||||
## Code-Specific Functionality We Must Preserve
|
||||
|
||||
- **Protocol & Ops** – `QueueUserInput`, validation toggles, confirm guards,
|
||||
rich approval semantics (`ReviewDecision::ApprovedForSession`), and sandbox
|
||||
policy options currently in `codex-rs/core/src/protocol.rs:1`.
|
||||
- **Execution Safety & Logging** – Confirm-guard enforcement and the richer
|
||||
`EventMsg` variants emitted from our `codex-rs/core/src/codex.rs:1`.
|
||||
- **TUI** – Entire Rust TUI stack (`codex-rs/tui/src/chatwidget.rs:1`,
|
||||
`codex-rs/tui/src/history_cell.rs:1`, etc.) must remain functional and ignore
|
||||
unknown ACP events gracefully.
|
||||
- **CLI Footprint** – Our trimmed `codex-cli` structure and scripts differ from
|
||||
upstream’s wholesale replacement; we will not adopt the TypeScript overhaul.
|
||||
- **Config Schema** – Existing TOML fields (confirm guards, validation toggles,
|
||||
sandbox defaults) must stay intact.
|
||||
|
||||
## Integration Approach
|
||||
|
||||
1. **Introduce ACP Helpers Without Regressions**
|
||||
- Port `codex-rs/core/src/acp.rs:1` (and dependent structs) into Code, but
|
||||
adapt it to reuse the current `FileChange` representations and respect
|
||||
confirm guard / approval flows.
|
||||
- Extend `codex-rs/core/src/codex.rs:1` to emit ACP events while preserving
|
||||
the existing `Op` variants, queueing logic, and validation toggles.
|
||||
- Update `codex-rs/core/src/util.rs:1`, `codex-rs/core/src/apply_patch.rs:1`,
|
||||
and related modules so ACP tool-call generators can derive the same
|
||||
metadata our TUI already consumes.
|
||||
|
||||
2. **MCP Server Wiring**
|
||||
- Add `codex-rs/mcp-server/src/acp_tool_runner.rs:1` and integrate it with
|
||||
`message_processor.rs:1`, ensuring we keep Code-specific auth/sandbox setup
|
||||
and error reporting.
|
||||
- Maintain existing MCP tools and ensure ACP tools are opt-in (guarded by
|
||||
config or feature flag) so Code retains current behavior when ACP is
|
||||
unused.
|
||||
|
||||
3. **Event & Frontend Compatibility**
|
||||
- Extend our event enums (`codex-rs/core/src/protocol.rs:1`) with any new
|
||||
variants required by ACP while keeping deprecated ones for backward
|
||||
compatibility.
|
||||
- Teach the Rust TUI to ignore or minimally display ACP-specific events so
|
||||
terminal UX does not panic when ACP notifications flow through.
|
||||
|
||||
4. **Config & Build**
|
||||
- Add `agent-client-protocol` dependency where needed, updating
|
||||
`codex-rs/core/Cargo.toml:1`, `codex-rs/mcp-server/Cargo.toml:1`, and
|
||||
`Cargo.lock`.
|
||||
- Introduce configuration toggles (if any) in `codex-rs/core/src/config.rs:1`
|
||||
and `codex-rs/core/src/config_types.rs:1` without breaking existing TOML
|
||||
files.
|
||||
- Update documentation (targeted sections in `docs/experimental.md:1` or a
|
||||
new page) to describe ACP/Zed support plus Code-specific caveats.
|
||||
|
||||
5. **Testing & Validation**
|
||||
- Add focused tests covering ACP request flow (unit-level in core and MCP
|
||||
server). Reuse existing harnesses (e.g., `codex-rs/mcp-server/tests`) to
|
||||
simulate a session.
|
||||
- Validate end-to-end via `./build-fast.sh` only, honoring our policy against
|
||||
running additional formatters automatically.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- How should ACP be surfaced in Code’s configuration (auto-enabled vs per
|
||||
server flag)?
|
||||
- Do we expose ACP status in the Rust TUI, or treat it as headless-only? (Lean
|
||||
toward headless-first with optional UI indicators.)
|
||||
- Are there additional permission flows (e.g., review approvals) required for
|
||||
Zed beyond what `ReviewDecision` already provides?
|
||||
|
||||
Document last updated: 2025-09-22.
|
||||
Reference in New Issue
Block a user