feat(acp): sync upstream protocol updates

This commit is contained in:
James Peter
2025-09-22 18:38:16 +10:00
parent 282c8c5a0d
commit fbe8beb50c
21 changed files with 659 additions and 314 deletions
+21 -2
View File
@@ -29,10 +29,16 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "agent-client-protocol"
version = "0.0.13"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4255a06cc2414033d1fe4baf1968bcc8f16d7e5814f272b97779b5806d129142"
checksum = "cc2526e80463b9742afed4829aedd6ae5632d6db778c6cc1fecb80c960c3521b"
dependencies = [
"anyhow",
"async-broadcast",
"async-trait",
"futures",
"log",
"parking_lot",
"schemars 1.0.4",
"serde",
"serde_json",
@@ -325,6 +331,18 @@ dependencies = [
"wait-timeout",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
@@ -994,6 +1012,7 @@ dependencies = [
"rand 0.9.2",
"regex-lite",
"reqwest",
"schemars 0.8.22",
"seccompiler",
"serde",
"serde_bytes",
+2 -1
View File
@@ -22,7 +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"
agent-client-protocol = "0.4.0"
dirs = "6"
env-flags = "0.1.1"
eventsource-stream = "0.2.3"
@@ -34,6 +34,7 @@ portable-pty = "0.9.0"
rand = "0.9"
regex-lite = "0.1.7"
reqwest = { version = "0.12", features = ["json", "stream"] }
schemars = "0.8.22"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
+41 -20
View File
@@ -12,6 +12,7 @@ use std::path::PathBuf;
use std::time::Duration;
use uuid::Uuid;
use crate::config_types::{ClientTools, McpToolId};
use crate::mcp_connection_manager::McpConnectionManager;
use crate::protocol::FileChange;
use crate::protocol::ReviewDecision;
@@ -20,13 +21,13 @@ 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,
tools: &'a ClientTools,
}
impl<'a> AcpFileSystem<'a> {
pub fn new(
session_id: Uuid,
tools: &'a acp::ClientTools,
tools: &'a ClientTools,
mcp_connection_manager: &'a McpConnectionManager,
) -> Self {
Self {
@@ -38,14 +39,15 @@ impl<'a> AcpFileSystem<'a> {
async fn read_text_file_impl(
&self,
tool: &acp::McpToolId,
tool: &McpToolId,
path: &Path,
) -> Result<String> {
let arguments = acp::ReadTextFileArguments {
let arguments = acp::ReadTextFileRequest {
session_id: acp::SessionId(self.session_id.to_string().into()),
path: path.to_path_buf(),
line: None,
limit: None,
meta: None,
};
let CallToolResult {
@@ -66,7 +68,7 @@ impl<'a> AcpFileSystem<'a> {
anyhow::bail!("Error reading text file: {:?}", structured_content);
}
let output = serde_json::from_value::<acp::ReadTextFileOutput>(
let output = serde_json::from_value::<acp::ReadTextFileResponse>(
structured_content.context("No output from read_text_file tool")?,
)?;
@@ -75,14 +77,15 @@ impl<'a> AcpFileSystem<'a> {
async fn write_text_file_impl(
&self,
tool: &acp::McpToolId,
tool: &McpToolId,
path: &Path,
content: String,
) -> Result<()> {
let arguments = acp::WriteTextFileArguments {
let arguments = acp::WriteTextFileRequest {
session_id: acp::SessionId(self.session_id.to_string().into()),
path: path.to_path_buf(),
content,
meta: None,
};
let CallToolResult {
@@ -130,8 +133,8 @@ impl<'a> FileSystem for AcpFileSystem<'a> {
}
pub(crate) async fn request_permission(
permission_tool: &acp::McpToolId,
tool_call: acp::ToolCall,
permission_tool: &McpToolId,
tool_call: acp::ToolCallUpdate,
session_id: Uuid,
mcp_connection_manager: &McpConnectionManager,
) -> Result<ReviewDecision> {
@@ -139,26 +142,30 @@ pub(crate) async fn request_permission(
let approve_id = acp::PermissionOptionId("approve".into());
let deny_id = acp::PermissionOptionId("deny".into());
let arguments = acp::RequestPermissionArguments {
let arguments = acp::RequestPermissionRequest {
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(),
name: "Approve for Session".into(),
kind: acp::PermissionOptionKind::AllowAlways,
meta: None,
},
acp::PermissionOption {
id: approve_id.clone(),
label: "Approve".into(),
name: "Approve".into(),
kind: acp::PermissionOptionKind::AllowOnce,
meta: None,
},
acp::PermissionOption {
id: deny_id.clone(),
label: "Deny".into(),
name: "Deny".into(),
kind: acp::PermissionOptionKind::RejectOnce,
meta: None,
},
],
meta: None,
};
let CallToolResult {
@@ -173,7 +180,7 @@ pub(crate) async fn request_permission(
.await?;
let result = structured_content.context("No output from permission tool")?;
let result = serde_json::from_value::<acp::RequestPermissionOutput>(result)?;
let result = serde_json::from_value::<acp::RequestPermissionResponse>(result)?;
use acp::RequestPermissionOutcome::*;
let decision = match result.outcome {
@@ -188,7 +195,7 @@ pub(crate) async fn request_permission(
anyhow::bail!("Unexpected permission option: {}", option_id);
}
}
Canceled => ReviewDecision::Abort,
Cancelled => ReviewDecision::Abort,
};
Ok(decision)
@@ -201,12 +208,14 @@ pub fn new_execute_tool_call(
) -> acp::ToolCall {
acp::ToolCall {
id: acp::ToolCallId(call_id.into()),
label: format!("`{}`", strip_bash_lc_and_escape(command)),
title: format!("`{}`", strip_bash_lc_and_escape(command)),
kind: acp::ToolKind::Execute,
status,
content: vec![],
locations: vec![],
raw_input: None,
raw_output: None,
meta: None,
}
}
@@ -215,7 +224,7 @@ pub fn new_patch_tool_call(
changes: &HashMap<PathBuf, FileChange>,
status: acp::ToolCallStatus,
) -> acp::ToolCall {
let label = if changes.len() == 1
let title = if changes.len() == 1
&& let Some((path, change)) = changes.iter().next()
{
let file_name = path.file_name().unwrap_or_default().display().to_string();
@@ -224,12 +233,14 @@ pub fn new_patch_tool_call(
FileChange::Delete => {
return acp::ToolCall {
id: acp::ToolCallId(call_id.into()),
label: format!("Delete “`{file_name}`”"),
title: format!("Delete “`{file_name}`”"),
kind: acp::ToolKind::Delete,
status,
content: vec![],
locations: vec![],
raw_input: None,
raw_output: None,
meta: None,
};
}
FileChange::Update {
@@ -240,12 +251,14 @@ pub fn new_patch_tool_call(
} if original_content == new_content => {
return acp::ToolCall {
id: acp::ToolCallId(call_id.into()),
label: move_path_label(path, new_path),
title: move_path_label(path, new_path),
kind: acp::ToolKind::Move,
status,
content: vec![],
locations: vec![],
raw_input: None,
raw_output: None,
meta: None,
};
}
_ => {}
@@ -267,12 +280,14 @@ pub fn new_patch_tool_call(
path: path.clone(),
old_text: None,
new_text: new_content.clone(),
meta: None,
},
});
locations.push(acp::ToolCallLocation {
path: path.clone(),
line: None,
meta: None,
});
}
FileChange::Delete => {
@@ -299,17 +314,20 @@ pub fn new_patch_tool_call(
locations.push(acp::ToolCallLocation {
path: new_path.clone(),
line: None,
meta: None,
});
} else {
locations.push(acp::ToolCallLocation {
path: path.clone(),
line: None,
meta: None,
});
}
} else {
locations.push(acp::ToolCallLocation {
path: path.clone(),
line: None,
meta: None,
});
}
@@ -319,6 +337,7 @@ pub fn new_patch_tool_call(
path: path.clone(),
old_text: Some(original_content.clone()),
new_text: new_content.clone(),
meta: None,
},
});
}
@@ -328,12 +347,14 @@ pub fn new_patch_tool_call(
acp::ToolCall {
id: acp::ToolCallId(call_id.into()),
label,
title,
kind: acp::ToolKind::Edit,
status,
content,
locations,
raw_input: None,
raw_output: None,
meta: None,
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ use codex_apply_patch::MaybeApplyPatchVerified;
use codex_apply_patch::StdFileSystem;
use codex_browser::BrowserConfig as CodexBrowserConfig;
use codex_browser::BrowserManager;
use agent_client_protocol::ClientTools;
use crate::config_types::ClientTools;
// unused: AuthManager
// unused: ConversationHistoryResponseEvent
use codex_protocol::protocol::TurnAbortReason;
+1 -1
View File
@@ -5,6 +5,7 @@ use crate::config_types::AgentConfig;
use crate::config_types::AllowedCommand;
use crate::config_types::AllowedCommandMatchKind;
use crate::config_types::BrowserConfig;
use crate::config_types::ClientTools;
use crate::config_types::History;
use crate::config_types::GithubConfig;
use crate::config_types::ValidationConfig;
@@ -20,7 +21,6 @@ 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;
+21
View File
@@ -5,6 +5,7 @@
use std::collections::HashMap;
use std::path::PathBuf;
use schemars::JsonSchema;
use wildmatch::WildMatchPattern;
use shlex::split as shlex_split;
@@ -157,6 +158,26 @@ pub struct SubagentsToml {
pub commands: Vec<SubagentCommandConfig>,
}
/// MCP tool identifiers that the client exposes to the agent.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ClientTools {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_permission: Option<McpToolId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub write_text_file: Option<McpToolId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub read_text_file: Option<McpToolId>,
}
/// Identifier for a client-hosted MCP tool.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct McpToolId {
pub mcp_server: String,
pub tool_name: String,
}
/// Configuration for external agent models
#[derive(Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "kebab-case")]
+1 -1
View File
@@ -5,7 +5,7 @@ This document describes Codex’s experimental MCP interface: a JSON‑RPC API t
- Status: experimental and subject to change without notice
- Recommended entry point: `code mcp` (alias: `code acp`; both wrap the same binary used by the CLI)
- Underlying Rust binary (for development): `cargo run -p code-mcp-server`
- ACP compatibility: surfaces `acp/new_session` + `acp/prompt` as MCP tools and emits `acp/session_update` notifications
- ACP compatibility: surfaces `session/new` + `session/prompt` as MCP tools and emits `session/update` notifications
- Transport: standard MCP over stdio (JSON‑RPC 2.0, line‑delimited)
## Overview
+1 -1
View File
@@ -16,7 +16,7 @@ workspace = true
[dependencies]
anyhow = "1"
agent-client-protocol = "0.0.13"
agent-client-protocol = "0.4.0"
codex-arg0 = { path = "../arg0" }
codex-common = { path = "../common", features = ["cli"] }
codex-core = { path = "../core" }
+34 -4
View File
@@ -13,6 +13,7 @@ use codex_core::config::Config as CodexConfig;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_protocol::protocol::TurnAbortReason;
use mcp_types::CallToolResult;
use mcp_types::ContentBlock;
use mcp_types::RequestId;
@@ -68,7 +69,7 @@ pub async fn prompt(
codex: Arc<CodexConversation>,
prompt: Vec<acp::ContentBlock>,
outgoing: Arc<OutgoingMessageSender>,
) -> Result<()> {
) -> Result<acp::StopReason> {
let items: Vec<InputItem> = prompt
.into_iter()
.filter_map(acp_content_block_to_item)
@@ -79,6 +80,8 @@ pub async fn prompt(
.await
.context("failed to submit prompt to Codex")?;
let mut stop_reason = acp::StopReason::EndTurn;
loop {
let event = codex.next_event().await?;
@@ -97,12 +100,14 @@ pub async fn prompt(
let invocation = event.invocation.clone();
Some(acp::SessionUpdate::ToolCall(acp::ToolCall {
id: acp::ToolCallId(event.call_id.into()),
label: format!("{}: {}", invocation.server, invocation.tool),
title: format!("{}: {}", invocation.server, invocation.tool),
kind: acp::ToolKind::Other,
status: acp::ToolCallStatus::InProgress,
content: vec![],
locations: vec![],
raw_input: invocation.arguments,
raw_output: None,
meta: None,
}))
}
EventMsg::McpToolCallEnd(event) => {
@@ -117,6 +122,11 @@ pub async fn prompt(
),
Err(err) => Some(vec![err.into()]),
};
let raw_output = event
.result
.as_ref()
.ok()
.and_then(|result| result.structured_content.clone());
Some(acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate {
id: call_id,
fields: ToolCallUpdateFields {
@@ -126,8 +136,10 @@ pub async fn prompt(
Some(acp::ToolCallStatus::Failed)
},
content,
raw_output,
..Default::default()
},
meta: None,
}))
}
EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) => None,
@@ -151,6 +163,7 @@ pub async fn prompt(
content: Some(vec![event.stdout.into(), event.stderr.into()]),
..Default::default()
},
meta: None,
}))
}
EventMsg::PatchApplyBegin(event) => Some(acp::SessionUpdate::ToolCall(
@@ -172,9 +185,16 @@ pub async fn prompt(
},
..Default::default()
},
meta: None,
}))
}
EventMsg::TaskComplete(_) => return Ok(()),
EventMsg::TurnAborted(event) => {
if matches!(event.reason, TurnAbortReason::Interrupted) {
stop_reason = acp::StopReason::Cancelled;
}
None
}
EventMsg::TaskComplete(_) => return Ok(stop_reason),
EventMsg::SessionConfigured(_)
| EventMsg::TokenCount(_)
| EventMsg::TaskStarted
@@ -186,11 +206,12 @@ pub async fn prompt(
if let Some(update) = acp_update {
let notification = OutgoingNotification {
method: acp::AGENT_METHODS.session_update.to_string(),
method: acp::CLIENT_METHOD_NAMES.session_update.to_string(),
params: Some(
serde_json::to_value(acp::SessionNotification {
session_id: acp_session_id.clone(),
update,
meta: None,
})
.unwrap_or_default(),
),
@@ -228,6 +249,7 @@ fn to_acp_annotations(annotations: mcp_types::Annotations) -> acp::Annotations {
}),
last_modified: annotations.last_modified,
priority: annotations.priority,
meta: None,
}
}
@@ -240,6 +262,7 @@ fn to_acp_embedded_resource_resource(
mime_type: text_contents.mime_type,
text: text_contents.text,
uri: text_contents.uri,
meta: None,
})
}
mcp_types::EmbeddedResourceResource::BlobResourceContents(blob_contents) => {
@@ -247,6 +270,7 @@ fn to_acp_embedded_resource_resource(
blob: blob_contents.blob,
mime_type: blob_contents.mime_type,
uri: blob_contents.uri,
meta: None,
})
}
}
@@ -257,16 +281,20 @@ fn to_acp_content_block(block: mcp_types::ContentBlock) -> acp::ContentBlock {
ContentBlock::TextContent(text_content) => acp::ContentBlock::Text(acp::TextContent {
annotations: text_content.annotations.map(to_acp_annotations),
text: text_content.text,
meta: None,
}),
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,
uri: None,
meta: None,
}),
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,
meta: None,
}),
ContentBlock::ResourceLink(resource_link) => {
acp::ContentBlock::ResourceLink(acp::ResourceLink {
@@ -277,12 +305,14 @@ fn to_acp_content_block(block: mcp_types::ContentBlock) -> acp::ContentBlock {
name: resource_link.name,
size: resource_link.size,
title: resource_link.title,
meta: None,
})
}
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),
meta: None,
})
}
}
+48 -22
View File
@@ -1,6 +1,7 @@
//! Configuration object accepted by the `codex` MCP tool-call.
use agent_client_protocol as acp;
use codex_core::config_types::ClientTools;
use codex_core::protocol::AskForApproval;
use codex_protocol::config_types::SandboxMode;
use mcp_types::Tool;
@@ -12,6 +13,7 @@ use serde::Deserialize;
use serde::Serialize;
use std::collections::HashMap;
use std::path::PathBuf;
use serde_json::json;
use crate::json_to_toml::json_to_toml;
@@ -134,22 +136,24 @@ 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}"));
let input_schema = ToolInputSchema {
r#type: "object".to_string(),
required: Some(vec!["cwd".to_string()]),
properties: Some(json!({
"cwd": {"type": "string"},
"mcpServers": {"type": "array"},
"clientTools": {"type": "object"}
})),
};
let output_schema = ToolOutputSchema {
r#type: "object".to_string(),
properties: None,
required: None,
};
Tool {
name: acp::AGENT_METHODS.new_session.to_string(),
title: Some(acp::AGENT_METHODS.new_session.to_string()),
name: acp::AGENT_METHOD_NAMES.session_new.to_string(),
title: Some(acp::AGENT_METHOD_NAMES.session_new.to_string()),
input_schema,
output_schema: Some(output_schema),
description: Some("Start a Codex session over ACP.".to_string()),
@@ -159,16 +163,19 @@ pub(crate) fn create_tool_for_acp_new_session() -> Tool {
/// 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}"));
let input_schema = ToolInputSchema {
r#type: "object".to_string(),
required: Some(vec!["sessionId".to_string(), "prompt".to_string()]),
properties: Some(json!({
"sessionId": {"type": "string"},
"prompt": {"type": "array"},
"meta": {"type": "object"}
})),
};
Tool {
name: acp::AGENT_METHODS.prompt.to_string(),
title: Some(acp::AGENT_METHODS.prompt.to_string()),
name: acp::AGENT_METHOD_NAMES.session_prompt.to_string(),
title: Some(acp::AGENT_METHOD_NAMES.session_prompt.to_string()),
input_schema,
output_schema: None,
description: Some("Send a prompt to an existing ACP Codex session.".to_string()),
@@ -176,6 +183,25 @@ pub(crate) fn create_tool_for_acp_prompt() -> Tool {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcpNewSessionToolArgs {
#[serde(flatten)]
pub request: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_tools: Option<ClientTools>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcpPromptToolArgs {
#[serde(rename = "sessionId")]
pub session_id: acp::SessionId,
pub prompt: Vec<acp::ContentBlock>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Value>,
}
impl CodexToolCallParam {
/// Returns the initial user prompt to start the Codex conversation and the
/// effective Config object generated from the supplied parameters.
+308 -127
View File
@@ -2,15 +2,19 @@ use std::collections::HashMap;
use std::path::PathBuf;
use crate::codex_message_processor::CodexMessageProcessor;
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::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::AcpNewSessionToolArgs;
use crate::codex_tool_config::AcpPromptToolArgs;
use crate::codex_tool_config::CodexToolCallParam;
use crate::codex_tool_config::CodexToolCallReplyParam;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::outgoing_message::OutgoingMessageSender;
use agent_client_protocol as acp;
use anyhow::anyhow;
use anyhow::Context as _;
use codex_protocol::mcp_protocol::ClientRequest;
use codex_protocol::mcp_protocol::ConversationId;
@@ -18,11 +22,13 @@ use codex_protocol::mcp_protocol::ConversationId;
use codex_core::AuthManager;
use codex_core::ConversationManager;
use codex_core::config_types::McpServerConfig;
use codex_core::config_types::ClientTools;
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_core::protocol::Op;
use codex_protocol::mcp_protocol::AuthMode;
use mcp_types::CallToolRequestParams;
use mcp_types::CallToolResult;
@@ -38,7 +44,6 @@ use mcp_types::ModelContextProtocolRequest;
use mcp_types::RequestId;
use mcp_types::ServerNotification;
use mcp_types::TextContent;
use serde::Deserialize;
use serde_json::json;
use std::sync::Arc;
use tokio::sync::Mutex;
@@ -103,13 +108,14 @@ impl MessageProcessor {
}
}
tracing::trace!("processing JSON-RPC request: {}", request.method);
// Hold on to the ID so we can respond.
let request_id = request.id.clone();
if request.method == "session/new" {
if request.method == acp::AGENT_METHOD_NAMES.session_new {
tracing::info!("handling session/new via ACP shim");
if let Some(params) = request.params.clone() {
match serde_json::from_value::<SessionNewParams>(params) {
match serde_json::from_value::<AcpNewSessionToolArgs>(params) {
Ok(session_params) => {
self.handle_session_new(request_id, session_params)
.await;
@@ -135,6 +141,35 @@ impl MessageProcessor {
return;
}
if request.method == acp::AGENT_METHOD_NAMES.session_prompt {
tracing::info!("handling session/prompt via ACP shim");
if let Some(params) = request.params.clone() {
match serde_json::from_value::<AcpPromptToolArgs>(params) {
Ok(prompt_params) => {
self.handle_session_prompt(request_id, prompt_params)
.await;
}
Err(err) => {
tracing::warn!("Failed to parse session/prompt params: {err}");
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid session/prompt params: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
}
}
} else {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "session/prompt requires params".to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
}
return;
}
let mut request = request;
if request.method == mcp_types::InitializeRequest::METHOD {
@@ -240,6 +275,23 @@ impl MessageProcessor {
/// Handle a fire-and-forget JSON-RPC notification.
pub(crate) async fn process_notification(&mut self, notification: JSONRPCNotification) {
if notification.method == acp::AGENT_METHOD_NAMES.session_cancel {
tracing::info!("handling session/cancel via ACP shim");
if let Some(params) = notification.params {
match serde_json::from_value::<acp::CancelNotification>(params) {
Ok(cancel) => {
self.handle_session_cancel(cancel).await;
}
Err(err) => {
tracing::warn!("Failed to parse session/cancel params: {err}");
}
}
} else {
tracing::warn!("session/cancel notification missing params");
}
return;
}
let server_notification = match ServerNotification::try_from(notification) {
Ok(n) => n,
Err(e) => {
@@ -451,10 +503,10 @@ impl MessageProcessor {
self.handle_tool_call_codex_session_reply(id, arguments)
.await
}
_ if name == acp::AGENT_METHODS.new_session => {
_ if name == acp::AGENT_METHOD_NAMES.session_new => {
self.handle_tool_call_acp_new_session(id, arguments).await
}
_ if name == acp::AGENT_METHODS.prompt => {
_ if name == acp::AGENT_METHOD_NAMES.session_prompt => {
self.handle_tool_call_acp_prompt(id, arguments).await
}
_ => {
@@ -750,7 +802,7 @@ impl MessageProcessor {
request_id: RequestId,
arguments: Option<serde_json::Value>,
) {
let config = match Self::acp_new_session_cfg(arguments) {
let config = match self.acp_new_session_cfg(arguments) {
Ok(cfg) => cfg,
Err(err) => {
tracing::warn!("Failed to construct new session config: {}", err);
@@ -786,21 +838,14 @@ impl MessageProcessor {
};
let session_id_str = session_id.to_string();
let modes_payload = serde_json::json!({
"currentModeId": "default",
"availableModes": [
{
"id": "default",
"name": "Default",
"description": "Code prompts before executing tools or applying patches."
}
]
});
let response_struct = acp::NewSessionResponse {
session_id: acp::SessionId(Arc::from(session_id_str.clone())),
modes: Some(default_session_modes()),
meta: None,
};
let structured = serde_json::json!({
"sessionId": session_id_str,
"modes": modes_payload
});
let structured = serde_json::to_value(response_struct)
.unwrap_or_else(|_| json!({ "sessionId": session_id_str }));
let response = CallToolResult {
content: vec![],
@@ -812,50 +857,22 @@ impl MessageProcessor {
});
}
fn acp_new_session_cfg(arguments: Option<serde_json::Value>) -> anyhow::Result<Config> {
fn acp_new_session_cfg(
&self,
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)
let arguments = serde_json::from_value::<AcpNewSessionToolArgs>(arguments)?;
let request = serde_json::from_value::<acp::NewSessionRequest>(arguments.request)?;
self.build_new_session_config(request, arguments.client_tools)
}
async fn handle_session_new(
&self,
request_id: RequestId,
params: SessionNewParams,
params: AcpNewSessionToolArgs,
) {
let config = match self.session_new_config(params).await {
let config = match self.session_new_config(params) {
Ok(cfg) => cfg,
Err(err) => {
tracing::warn!("Failed to prepare session config: {err}");
@@ -886,59 +903,119 @@ impl MessageProcessor {
return;
};
let response = serde_json::json!({
"sessionId": session_id.to_string(),
"modes": {
"currentModeId": "default",
"availableModes": [
{
"id": "default",
"name": "Default",
"description": "Code prompts before executing tools or applying patches."
}
]
}
});
let response = acp::NewSessionResponse {
session_id: acp::SessionId(Arc::from(session_id.to_string())),
modes: Some(default_session_modes()),
meta: None,
};
outgoing.send_response(request_id, response).await;
let value = serde_json::to_value(response)
.unwrap_or_else(|_| json!({ "sessionId": session_id.to_string() }));
outgoing.send_response(request_id, value).await;
});
}
async fn session_new_config(&self, params: SessionNewParams) -> anyhow::Result<Config> {
let mcp_servers: HashMap<String, McpServerConfig> = params
.mcp_servers
.into_iter()
.map(|server| {
let env: HashMap<String, String> = server
.env
.into_iter()
.map(|var| (var.name, var.value))
.collect();
fn session_new_config(&self, params: AcpNewSessionToolArgs) -> anyhow::Result<Config> {
let request = serde_json::from_value::<acp::NewSessionRequest>(params.request)?;
self.build_new_session_config(request, params.client_tools)
}
(
server.name,
McpServerConfig {
command: server.command.display().to_string(),
args: server.args,
env: if env.is_empty() { None } else { Some(env) },
startup_timeout_ms: None,
},
)
})
.collect();
let base_tools = self.base_config.experimental_client_tools.clone();
fn build_new_session_config(
&self,
request: acp::NewSessionRequest,
override_tools: Option<ClientTools>,
) -> anyhow::Result<Config> {
let mcp_servers = convert_mcp_servers(request.mcp_servers)?;
let client_tools = override_tools
.or_else(|| self.base_config.experimental_client_tools.clone());
let overrides = codex_core::config::ConfigOverrides {
cwd: Some(params.cwd),
cwd: Some(request.cwd),
mcp_servers: Some(mcp_servers),
experimental_client_tools: base_tools,
experimental_client_tools: client_tools,
..Default::default()
};
Ok(Config::load_with_cli_overrides(Default::default(), overrides)?)
}
async fn handle_session_prompt(
&self,
request_id: RequestId,
params: AcpPromptToolArgs,
) {
let acp_session_id = params.session_id;
let session_uuid = match Uuid::parse_str(&acp_session_id.to_string()) {
Ok(id) => id,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid session id: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
let session = {
let map = self.session_map.lock().await;
map.get(&session_uuid).cloned()
};
let Some(session) = session else {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("unknown session id: {}", acp_session_id),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
};
let outgoing = self.outgoing.clone();
let requests_codex_map = self.running_requests_id_to_codex_uuid.clone();
let prompt_blocks = params.prompt;
task::spawn(async move {
requests_codex_map
.lock()
.await
.insert(request_id.clone(), session_uuid);
let result = crate::acp_tool_runner::prompt(
acp_session_id.clone(),
session,
prompt_blocks,
outgoing.clone(),
)
.await;
match result {
Ok(stop_reason) => {
let response = acp::PromptResponse {
stop_reason,
meta: None,
};
let value = serde_json::to_value(response)
.unwrap_or_else(|_| json!({ "stopReason": "end_turn" }));
outgoing.send_response(request_id.clone(), value).await;
}
Err(err) => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: err.to_string(),
data: None,
};
outgoing.send_error(request_id.clone(), error).await;
}
}
requests_codex_map.lock().await.remove(&request_id);
});
}
async fn handle_tool_call_acp_prompt(
&self,
request_id: RequestId,
@@ -994,11 +1071,19 @@ impl MessageProcessor {
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,
},
Ok(stop_reason) => {
let structured = serde_json::to_value(acp::PromptResponse {
stop_reason,
meta: None,
})
.unwrap_or_else(|_| json!({ "stopReason": "end_turn" }));
CallToolResult {
content: vec![],
is_error: Some(false),
structured_content: Some(structured),
}
}
Err(err) => CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
annotations: None,
@@ -1020,10 +1105,10 @@ impl MessageProcessor {
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 arguments = serde_json::from_value::<AcpPromptToolArgs>(arguments)?;
let session_id = Uuid::parse_str(&arguments.session_id.0)?;
Ok((session_id, arguments.session_id, arguments.prompt))
let session_uuid = Uuid::parse_str(&arguments.session_id.to_string())?;
Ok((session_uuid, arguments.session_id, arguments.prompt))
}
fn handle_resource_updated(
@@ -1040,6 +1125,63 @@ impl MessageProcessor {
tracing::info!("notifications/prompts/list_changed -> params: {:?}", params);
}
async fn handle_session_cancel(&self, params: acp::CancelNotification) {
let session_uuid = match Uuid::parse_str(&params.session_id.to_string()) {
Ok(uuid) => uuid,
Err(err) => {
tracing::warn!("received session/cancel with invalid session id: {err}");
return;
}
};
let conversation = {
let map = self.session_map.lock().await;
map.get(&session_uuid).cloned()
};
let Some(conversation) = conversation else {
tracing::warn!("session/cancel for unknown session: {}", params.session_id);
return;
};
let request_ids: Vec<RequestId> = {
let map = self.running_requests_id_to_codex_uuid.lock().await;
map.iter()
.filter_map(|(request_id, uuid)| if *uuid == session_uuid {
Some(request_id.clone())
} else {
None
})
.collect()
};
if request_ids.is_empty() {
if let Err(err) = conversation
.submit_with_id(Submission {
id: Uuid::new_v4().to_string(),
op: Op::Interrupt,
})
.await
{
tracing::error!("failed to interrupt session {}: {err}", params.session_id);
}
return;
}
for request_id in request_ids {
let submission_id = request_id_to_string(&request_id);
if let Err(err) = conversation
.submit_with_id(Submission {
id: submission_id,
op: Op::Interrupt,
})
.await
{
tracing::error!("failed to interrupt in-flight request: {err}");
}
}
}
fn handle_tool_list_changed(
&self,
params: <mcp_types::ToolListChangedNotification as mcp_types::ModelContextProtocolNotification>::Params,
@@ -1055,27 +1197,66 @@ fn handle_logging_message(
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SessionNewParams {
cwd: PathBuf,
#[serde(default, rename = "mcpServers")]
mcp_servers: Vec<SessionNewMcpServer>,
fn convert_mcp_servers(
servers: Vec<acp::McpServer>,
) -> anyhow::Result<HashMap<String, McpServerConfig>> {
let mut map = HashMap::with_capacity(servers.len());
for server in servers {
match server {
acp::McpServer::Stdio { name, command, args, env } => {
let env_map: HashMap<String, String> = env
.into_iter()
.map(|var| (var.name, var.value))
.collect();
let env_map = if env_map.is_empty() { None } else { Some(env_map) };
map.insert(
name,
McpServerConfig {
command: command.display().to_string(),
args,
env: env_map,
startup_timeout_ms: None,
},
);
}
acp::McpServer::Http { name, .. } => {
return Err(anyhow!(
"unsupported MCP transport for server '{}': HTTP servers are not yet supported",
name
));
}
acp::McpServer::Sse { name, .. } => {
return Err(anyhow!(
"unsupported MCP transport for server '{}': SSE servers are not yet supported",
name
));
}
}
}
Ok(map)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SessionNewMcpServer {
name: String,
command: PathBuf,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: Vec<SessionNewEnvVar>,
fn default_session_modes() -> acp::SessionModeState {
let mode_id = acp::SessionModeId(Arc::from("default".to_string()));
let mode = acp::SessionMode {
id: mode_id.clone(),
name: "Default".to_string(),
description: Some("Code prompts before executing tools or applying patches.".to_string()),
meta: None,
};
acp::SessionModeState {
current_mode_id: mode_id,
available_modes: vec![mode],
meta: None,
}
}
#[derive(Debug, Deserialize)]
struct SessionNewEnvVar {
name: String,
value: String,
fn request_id_to_string(request_id: &RequestId) -> String {
match request_id {
RequestId::String(value) => value.clone(),
RequestId::Integer(value) => value.to_string(),
}
}
+10 -15
View File
@@ -260,12 +260,10 @@ pub(crate) struct OutgoingError {
mod tests {
use codex_core::protocol::EventMsg;
use codex_core::protocol::SessionConfiguredEvent;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::mcp_protocol::LoginChatGptCompleteNotification;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::NamedTempFile;
use uuid::Uuid;
use super::*;
@@ -276,17 +274,16 @@ mod tests {
let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx);
let conversation_id = ConversationId::new();
let rollout_file = NamedTempFile::new().unwrap();
let session_uuid: Uuid = conversation_id.into();
let event = Event {
id: "1".to_string(),
event_seq: 0,
order: None,
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: conversation_id,
session_id: session_uuid,
model: "gpt-4o".to_string(),
reasoning_effort: Some(ReasoningEffort::default()),
history_log_id: 1,
history_entry_count: 1000,
initial_messages: None,
rollout_path: rollout_file.path().to_path_buf(),
}),
};
@@ -312,18 +309,17 @@ mod tests {
let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx);
let conversation_id = ConversationId::new();
let rollout_file = NamedTempFile::new().unwrap();
let session_uuid: Uuid = conversation_id.into();
let session_configured_event = SessionConfiguredEvent {
session_id: conversation_id,
session_id: session_uuid,
model: "gpt-4o".to_string(),
reasoning_effort: Some(ReasoningEffort::default()),
history_log_id: 1,
history_entry_count: 1000,
initial_messages: None,
rollout_path: rollout_file.path().to_path_buf(),
};
let event = Event {
id: "1".to_string(),
event_seq: 0,
order: None,
msg: EventMsg::SessionConfigured(session_configured_event.clone()),
};
let meta = OutgoingNotificationMeta {
@@ -344,14 +340,13 @@ mod tests {
"requestId": "123",
},
"id": "1",
"event_seq": 0,
"msg": {
"session_id": session_configured_event.session_id,
"model": session_configured_event.model,
"reasoning_effort": session_configured_event.reasoning_effort,
"history_log_id": session_configured_event.history_log_id,
"history_entry_count": session_configured_event.history_entry_count,
"type": "session_configured",
"rollout_path": rollout_file.path().to_path_buf(),
"type": "session_configured"
}
});
assert_eq!(params.unwrap(), expected_params);
+2 -4
View File
@@ -121,7 +121,6 @@ async fn get_auth_status_with_api_key() {
let status: GetAuthStatusResponse = to_response(resp).expect("deserialize status");
assert_eq!(status.auth_method, Some(AuthMode::ApiKey));
assert_eq!(status.auth_token, Some("sk-test-key".to_string()));
assert_eq!(status.preferred_auth_method, AuthMode::ChatGPT);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -130,6 +129,8 @@ async fn get_auth_status_with_api_key_when_auth_not_required() {
create_config_toml_custom_provider(codex_home.path(), false)
.unwrap_or_else(|err| panic!("write config.toml: {err}"));
login_with_api_key(codex_home.path(), "sk-test-key").expect("seed api key");
let mut mcp = McpProcess::new(codex_home.path())
.await
.expect("spawn mcp process");
@@ -138,8 +139,6 @@ async fn get_auth_status_with_api_key_when_auth_not_required() {
.expect("init timeout")
.expect("init failed");
login_with_api_key_via_request(&mut mcp, "sk-test-key").await;
let request_id = mcp
.send_get_auth_status_request(GetAuthStatusParams {
include_token: Some(true),
@@ -199,5 +198,4 @@ async fn get_auth_status_with_api_key_no_include_token() {
let status: GetAuthStatusResponse = to_response(resp).expect("deserialize status");
assert_eq!(status.auth_method, Some(AuthMode::ApiKey));
assert!(status.auth_token.is_none(), "token must be omitted");
assert_eq!(status.preferred_auth_method, AuthMode::ChatGPT);
}
@@ -1,7 +1,7 @@
use std::path::Path;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_core::protocol_config_types::ReasoningEffort;
use codex_core::protocol_config_types::ReasoningSummary;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
@@ -90,6 +90,7 @@ async fn test_codex_jsonrpc_conversation_flow() {
let NewConversationResponse {
conversation_id,
model,
..
} = new_conv_resp;
assert_eq!(model, "mock-model");
+21 -18
View File
@@ -65,8 +65,8 @@ async fn tools_list_exposes_acp_entries() -> anyhow::Result<()> {
for required in [
"codex",
"codex-reply",
acp::AGENT_METHODS.new_session,
acp::AGENT_METHODS.prompt,
acp::AGENT_METHOD_NAMES.session_new,
acp::AGENT_METHOD_NAMES.session_prompt,
] {
assert!(
tool_names.contains(required),
@@ -232,20 +232,15 @@ async fn acp_prompt_round_trip() -> anyhow::Result<()> {
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_args = json!({
"cwd": temp_cwd.path(),
"mcpServers": []
});
let new_session_request_id = process
.send_tool_call(
acp::AGENT_METHODS.new_session,
Some(serde_json::to_value(new_session_args)?),
acp::AGENT_METHOD_NAMES.session_new,
Some(new_session_args),
)
.await?;
@@ -258,19 +253,27 @@ async fn acp_prompt_round_trip() -> anyhow::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 new_session_output: serde_json::Value = serde_json::from_value(structured)?;
let session_id_value = new_session_output
.get("sessionId")
.and_then(|v| v.as_str())
.context("sessionId should be present")?
.to_string();
let session_id = acp::SessionId(session_id_value.clone().into());
let prompt_args = acp::PromptArguments {
session_id: new_session_output.session_id.clone(),
let prompt_args = acp::PromptRequest {
session_id: session_id.clone(),
prompt: vec![acp::ContentBlock::Text(acp::TextContent {
annotations: None,
text: "Hello from ACP".to_string(),
meta: None,
})],
meta: None,
};
let prompt_request_id = process
.send_tool_call(
acp::AGENT_METHODS.prompt,
acp::AGENT_METHOD_NAMES.session_prompt,
Some(serde_json::to_value(prompt_args)?),
)
.await?;
@@ -281,7 +284,7 @@ async fn acp_prompt_round_trip() -> anyhow::Result<()> {
let message = timeout(DEFAULT_READ_TIMEOUT, process.read_jsonrpc_message()).await??;
match message {
JSONRPCMessage::Notification(notification) => {
if notification.method == acp::AGENT_METHODS.session_update {
if notification.method == acp::CLIENT_METHOD_NAMES.session_update {
saw_session_update = true;
}
}
+15 -21
View File
@@ -1,16 +1,11 @@
use std::collections::HashMap;
use std::path::Path;
use codex_core::protocol::AskForApproval;
use codex_protocol::config_types::ConfigProfile;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::mcp_protocol::GetConfigTomlResponse;
use mcp_test_support::McpProcess;
use mcp_test_support::to_response;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -60,21 +55,20 @@ async fn get_config_toml_returns_subset() {
.expect("getConfigToml timeout")
.expect("getConfigToml response");
let config: GetConfigTomlResponse = to_response(resp).expect("deserialize config");
let expected = GetConfigTomlResponse {
approval_policy: Some(AskForApproval::OnRequest),
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
model_reasoning_effort: Some(ReasoningEffort::High),
profile: Some("test".to_string()),
profiles: Some(HashMap::from([(
"test".into(),
ConfigProfile {
model: Some("gpt-4o".into()),
approval_policy: Some(AskForApproval::OnRequest),
model_reasoning_effort: Some(ReasoningEffort::High),
},
)])),
};
let config: serde_json::Value = to_response(resp).expect("deserialize config");
let expected = json!({
"approval_policy": "on-request",
"sandbox_mode": "workspace-write",
"model_reasoning_effort": "high",
"profile": "test",
"profiles": {
"test": {
"model": "gpt-4o",
"approval_policy": "on-request",
"model_reasoning_effort": "high"
}
}
});
assert_eq!(expected, config);
}
@@ -59,6 +59,7 @@ async fn test_conversation_create_and_send_message_ok() {
let NewConversationResponse {
conversation_id,
model,
..
} = to_response::<NewConversationResponse>(new_conv_resp)
.expect("deserialize newConversation response");
assert_eq!(model, "o3");
+1 -1
View File
@@ -3,7 +3,7 @@
use std::path::Path;
use codex_core::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortReason;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_protocol::mcp_protocol::AddConversationListenerParams;
use codex_protocol::mcp_protocol::InterruptConversationParams;
+109 -59
View File
@@ -13,9 +13,43 @@ const UNUSED_CELL: &str = "░░";
const BAR_SLOTS: usize = 20;
const BAR_FILLED: &str = "▰";
const BAR_EMPTY: &str = "▱";
const SECTION_INDENT: &str = " ";
const FIELD_INDENT: &str = " ";
const CHART_INDENT: &str = " ";
struct IndentSpec {
header: &'static str,
label_extra: &'static str,
chart_extra: &'static str,
label_target_width: usize,
label_gap: usize,
}
const INDENTS: IndentSpec = IndentSpec {
header: "",
label_extra: " ",
chart_extra: " ",
label_target_width: 7,
label_gap: 2,
};
fn header_indent() -> &'static str {
INDENTS.header
}
fn label_indent() -> String {
format!("{}{}", INDENTS.header, INDENTS.label_extra)
}
fn chart_indent() -> String {
format!("{}{}", INDENTS.header, INDENTS.chart_extra)
}
fn chart_indent_width() -> usize {
INDENTS.header.len() + INDENTS.chart_extra.len()
}
fn label_text(text: &str) -> String {
let mut result = label_indent();
result.push_str(text);
result
}
/// Aggregated output used by the `/limits` command.
/// It contains the rendered summary lines, optional legend,
@@ -177,8 +211,11 @@ fn build_footer_lines(metrics: &RateLimitMetrics) -> Vec<Line<'static>> {
}
fn section_header(title: &str) -> Line<'static> {
let mut text = String::with_capacity(header_indent().len() + title.len());
text.push_str(header_indent());
text.push_str(title);
Line::from(vec![Span::styled(
format!("{SECTION_INDENT}{title}"),
text,
Style::default().add_modifier(Modifier::BOLD),
)])
}
@@ -252,31 +289,34 @@ fn build_hourly_reset_line(
last_reset: Option<DateTime<Utc>>,
) -> Line<'static> {
if let (Some(last), true) = (last_reset, window_minutes > 0) {
let prefix = field_prefix("Resets");
if let Some(timing) = compute_window_timing(window_minutes, last) {
let remaining = format_duration(timing.remaining);
return Line::from(vec![
Span::raw(format!("{FIELD_INDENT}Resets:")),
Span::raw(" "),
Span::raw("at "),
Span::raw(format!("{}", timing.next_reset_local)),
Span::styled(
format!(" (in {remaining})"),
Style::default().fg(colors::dim()),
),
]);
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::raw(prefix.clone()));
spans.push(Span::raw("at "));
spans.push(Span::raw(timing.next_reset_local));
spans.push(Span::styled(
format!(" (in {remaining})"),
Style::default().fg(colors::dim()),
));
return Line::from(spans);
}
return Line::from(vec![Span::raw(format!(
"{FIELD_INDENT}Reset timing updating…"
))]);
return Line::from(vec![
Span::raw(prefix),
Span::styled(
"timing updating…".to_string(),
Style::default().fg(colors::dim()),
),
]);
}
Line::from(vec![
Span::raw(format!("{FIELD_INDENT}Resets:")),
Span::raw(" "),
Span::styled(
"shown once next window detected".to_string(),
Style::default().fg(colors::dim()),
),
])
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::raw(field_prefix("Resets")));
spans.push(Span::styled(
"shown once next window detected".to_string(),
Style::default().fg(colors::dim()),
));
Line::from(spans)
}
fn build_weekly_window_line(
@@ -335,31 +375,34 @@ fn build_weekly_reset_line(
last_reset: Option<DateTime<Utc>>,
) -> Line<'static> {
if let (Some(last), true) = (last_reset, window_minutes > 0) {
let prefix = field_prefix("Resets");
if let Some(timing) = compute_window_timing(window_minutes, last) {
let remaining = format_duration(timing.remaining);
return Line::from(vec![
Span::raw(format!("{FIELD_INDENT}Resets:")),
Span::raw(" "),
Span::raw("at "),
Span::raw(format!("{}", timing.next_reset_local)),
Span::styled(
format!(" (in {remaining})"),
Style::default().fg(colors::dim()),
),
]);
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::raw(prefix.clone()));
spans.push(Span::raw("at "));
spans.push(Span::raw(timing.next_reset_local));
spans.push(Span::styled(
format!(" (in {remaining})"),
Style::default().fg(colors::dim()),
));
return Line::from(spans);
}
return Line::from(vec![Span::raw(format!(
"{FIELD_INDENT}Reset timing updating…"
))]);
return Line::from(vec![
Span::raw(prefix),
Span::styled(
"timing updating…".to_string(),
Style::default().fg(colors::dim()),
),
]);
}
Line::from(vec![
Span::raw(format!("{FIELD_INDENT}Resets:")),
Span::raw(" "),
Span::styled(
"shown once next window detected".to_string(),
Style::default().fg(colors::dim()),
),
])
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::raw(field_prefix("Resets")));
spans.push(Span::styled(
"shown once next window detected".to_string(),
Style::default().fg(colors::dim()),
));
Line::from(spans)
}
fn build_compact_lines(reset_info: &RateLimitResetInfo) -> Vec<Line<'static>> {
@@ -373,7 +416,7 @@ fn build_compact_lines(reset_info: &RateLimitResetInfo) -> Vec<Line<'static>> {
lines.push(build_compact_status_line(used, limit));
} else {
lines.push(Line::from(vec![Span::styled(
format!("{FIELD_INDENT}Session usage updating…"),
label_text("Session usage updating…"),
Style::default().fg(colors::dim()),
)]));
}
@@ -390,12 +433,12 @@ fn build_compact_lines(reset_info: &RateLimitResetInfo) -> Vec<Line<'static>> {
));
} else if reset_info.overflow_auto_compact {
lines.push(Line::from(vec![Span::styled(
format!("{FIELD_INDENT}Auto-compaction runs after overflow errors"),
label_text("Auto-compaction runs after overflow errors"),
Style::default().fg(colors::dim()),
)]));
} else {
lines.push(Line::from(vec![Span::styled(
format!("{FIELD_INDENT}Auto-compaction unavailable"),
label_text("Auto-compaction unavailable"),
Style::default().fg(colors::dim()),
)]));
}
@@ -597,7 +640,6 @@ fn build_status_line(metrics: &RateLimitMetrics) -> Line<'static> {
(false, false) => unreachable!(),
};
Line::from(vec![
Span::raw(SECTION_INDENT),
Span::styled(
format!("✕ Rate limited: {reason}"),
Style::default().fg(colors::error()),
@@ -605,7 +647,6 @@ fn build_status_line(metrics: &RateLimitMetrics) -> Line<'static> {
])
} else {
Line::from(vec![
Span::raw(SECTION_INDENT),
Span::styled(
"✓ Within current limits".to_string(),
Style::default().fg(colors::success()),
@@ -620,9 +661,10 @@ fn build_legend_lines(show_gauge: bool) -> Vec<Line<'static>> {
}
let mut lines = Vec::new();
let indent = chart_indent();
lines.push(Line::from(vec![
Span::styled(
CHART_INDENT.to_string(),
indent.clone(),
Style::default().fg(colors::dim()),
),
Span::styled(
@@ -638,7 +680,7 @@ fn build_legend_lines(show_gauge: bool) -> Vec<Line<'static>> {
]));
lines.push(Line::from(vec![
Span::styled(
CHART_INDENT.to_string(),
indent.clone(),
Style::default().fg(colors::dim()),
),
Span::styled(
@@ -654,7 +696,7 @@ fn build_legend_lines(show_gauge: bool) -> Vec<Line<'static>> {
]));
lines.push(Line::from(vec![
Span::styled(
CHART_INDENT.to_string(),
indent,
Style::default().fg(colors::dim()),
),
Span::styled(
@@ -672,10 +714,17 @@ fn build_legend_lines(show_gauge: bool) -> Vec<Line<'static>> {
}
fn field_prefix(label: &str) -> String {
let target_width = 7usize;
let padding = 2 + target_width.saturating_sub(label.len());
let padding = INDENTS
.label_gap
.saturating_add(INDENTS.label_target_width.saturating_sub(label.len()));
let spaces = " ".repeat(padding);
format!("{FIELD_INDENT}{label}:{spaces}")
let indent = label_indent();
let mut text = String::with_capacity(indent.len() + label.len() + 1 + spaces.len());
text.push_str(&indent);
text.push_str(label);
text.push(':');
text.push_str(&spaces);
text
}
fn render_percent_bar(percent: f64) -> Vec<Span<'static>> {
@@ -813,7 +862,7 @@ impl GridLayout {
}
let cell_width = WEEKLY_CELL.chars().count();
let indent_width = CHART_INDENT.chars().count() as u16;
let indent_width = chart_indent_width() as u16;
let available_inner = width.saturating_sub(indent_width) as usize;
if available_inner < cell_width {
return None;
@@ -846,9 +895,10 @@ impl GridLayout {
let counts = self.cell_counts(state);
let mut lines = Vec::new();
let mut cell_index = 0isize;
let indent = chart_indent();
for _ in 0..self.size {
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::raw(CHART_INDENT));
spans.push(Span::raw(indent.clone()));
for col in 0..self.size {
if col > 0 {
+12 -8
View File
@@ -1,6 +1,6 @@
# Using Code with Zed via ACP
The Rust MCP server now exposes the Agent Client Protocol (ACP) tools (`acp/new_session`, `acp/prompt`) that Zed expects. Zed still connects over MCP/JSON-RPC, but every conversation is represented through these ACP calls. This section walks through a minimal setup.
The Rust MCP server now exposes the Agent Client Protocol (ACP) primitives (`session/new`, `session/prompt`, plus streaming `session/update` notifications) that Zed expects. Zed still connects over MCP/JSON-RPC, but every conversation is represented through these ACP calls. This section walks through a minimal setup.
## 1. Configure Code's MCP server
@@ -22,19 +22,23 @@ Any existing MCP servers can remain in this table; the exiting overrides simply
## 2. Launch the MCP server under the new name
If you globally installed Code from npm (`npm install -g @just-every/code`), launch the MCP server with the built-in subcommand (`code mcp`; `code acp` is an equivalent alias and works well when `code` already points to VS Code):
If you prefer a one-off launch without installing anything globally, run the MCP server via `npx`:
```bash
code mcp
npx -y @just-every/code acp
# or pin to the latest dist-tag explicitly
npx -y @just-every/code@latest acp
```
Want a globally available binary instead? Install once (`npm install -g @just-every/code`) and then use the subcommand aliases (`code mcp`, `code acp`, or `coder acp`).
Prefer building from source? The previous workflow still works:
```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`.
The server will advertise four tools during the handshake: `codex`, `codex-reply`, `session/new`, and `session/prompt`.
## 3. Point Zed at Code's MCP endpoint
@@ -44,16 +48,16 @@ Add an entry to Zed's `settings.json` under `agent_servers` (see [Zed’s extern
{
"agent_servers": {
"Code": {
"command": "coder",
"args": ["acp"]
"command": "npx",
"args": ["-y", "@just-every/code", "acp"]
}
}
}
```
You can swap `coder` for an absolute path or `code` depending on your PATH preferences. Environment overrides such as `CODEX_HOME` or `RUST_LOG` are optional—set them only if you need a custom config directory or debug logging.
Pinning explicitly to the latest dist-tag works as well: replace "@just-every/code" with "@just-every/code@latest" in the `args` array. If you already have the CLI installed globally, swap in "coder" (or any absolute path) for the command and pass ["acp"] as the arguments. Environment overrides such as `CODEX_HOME` or `RUST_LOG` are optional—set them only if you need a custom config directory or debug logging.
When Zed launches this server it connects over MCP, then issues ACP tool calls (`acp/new_session`, `acp/prompt`) that we expose. Those tool invocations are bridged into full Codex sessions, and we stream ACP `session_update` notifications back so Zed can render reasoning, tool executions, and approvals.
When Zed launches this server it connects over MCP, then issues ACP tool calls (`session/new`, `session/prompt`) that we expose. Those tool invocations are bridged into full Codex sessions, and we stream ACP `session/update` notifications back so Zed can render reasoning, tool executions, and approvals. Zed can also send `session/cancel` to interrupt a running turn, which the server now honors by propagating an interrupt to Codex and replying with `stopReason: "cancelled"`.
## 4. Permission flow summary
+6 -6
View File
@@ -3,8 +3,8 @@
## 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
experimental Agent Client Protocol (ACP) bridge so Zed can drive Codex via the
`session/new` and `session/prompt` tool calls. 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.
@@ -13,9 +13,9 @@ and the TypeScript CLI/TUI to accommodate the new workflow.
- **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
- **MCP Server** – `session/new` and `session/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
`session/update` notifications 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
@@ -43,8 +43,8 @@ capabilities.
- **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.
- `message_processor.rs` extended to expose `session/new` and
`session/prompt`, and to translate Codex events into ACP notifications (`session/update`).
- **CLI/TUI (Node + Rust)**
- TypeScript terminal UI completely replaced with ACP-first experience.
- Rust TUI and associated tests removed.