ed-acp: serve over any transport
serve_stdio hard-wired the transport to the process's stdin and stdout, so a host could only run an agent as a subprocess. A sandboxed app (the macOS App Store) can't launch one, so add serve(profile, opts, transport), which takes any ConnectTo<Agent>, and make serve_stdio call it with Stdio. A host can now embed its agent: serve it on one end of agent_client_protocol::Channel::duplex and run an ACP client on the other. The new in_process test does exactly that and checks initialize and that the server stops when the client hangs up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seto Elkahfi committed
Oct 9, 2026 at 15:11 UTC
2e5e4cd7f43b299cbc1347c4b9da14833eaf4b5d
3 files changed
+92
-3
crates/ed-acp/src/lib.rs
+10
-1
@@ -16,6 +16,15 @@
16
//! let profile = Arc::new(MyProfile);
17
//! ed_acp::serve_stdio(profile, ServeOptions::from_env(&info)).await?;
18
//! ```
19
+//!
20
+//! To embed the agent in a host process instead (an app sandbox that can't launch a
21
+//! subprocess, say), serve it over an in-memory channel and run an ACP client on the other end:
22
+//!
23
+//! ```ignore
24
+//! let (agent_end, client_end) = agent_client_protocol::Channel::duplex();
25
+//! tokio::spawn(ed_acp::serve(profile, opts, agent_end));
26
+//! agent_client_protocol::Client.builder().connect_with(client_end, |conn| async move { … });
27
+//! ```
28
29
mod agent;
30
pub mod cli;
@@ -38,7 +47,7 @@ pub use agent_client_protocol;
47
pub use content::AudioHints;
48
pub use ed_mcp;
49
pub use llm::{LlmConfig, LlmEnv, Provider};
41
-pub use server::{ServeOptions, serve_stdio};
50
+pub use server::{ServeOptions, serve, serve_stdio};
51
pub use tools::{
52
DescribeCtx, ToolCtx, ToolOutcome, Toolset, absolutize, function_def, normalize, resolve_in,
53
truncate,
crates/ed-acp/src/server.rs
+16
-2
@@ -12,7 +12,7 @@ use agent_client_protocol::schema::v1::{
12
SessionDeleteCapabilities, SessionListCapabilities, SessionResumeCapabilities,
13
SetSessionConfigOptionRequest,
14
};
15
-use agent_client_protocol::{Agent, Stdio};
15
+use agent_client_protocol::{Agent, ConnectTo, Stdio};
16
17
use crate::agent::{self, AcpServer};
18
use crate::{AgentInfo, Profile, cli};
@@ -41,6 +41,20 @@ impl ServeOptions {
41
pub async fn serve_stdio(
42
profile: Arc<dyn Profile>,
43
opts: ServeOptions,
44
+) -> agent_client_protocol::Result<()> {
45
+ serve(profile, opts, Stdio::new()).await
46
+}
47
+
48
+/// Serve ACP over any transport until the client disconnects, then stop every MCP server.
49
+///
50
+/// This is how a host embeds the agent in its own process instead of launching it as a
51
+/// subprocess, for example an app in the macOS App Store sandbox: connect it to
52
+/// [`Channel::duplex`](agent_client_protocol::Channel::duplex) and run an ACP client on the
53
+/// other end.
54
+pub async fn serve(
55
+ profile: Arc<dyn Profile>,
56
+ opts: ServeOptions,
57
+ transport: impl ConnectTo<Agent> + 'static,
58
) -> agent_client_protocol::Result<()> {
59
let info = profile.info();
60
let prompt_caps = profile.prompt_capabilities();
@@ -277,7 +291,7 @@ pub async fn serve_stdio(
291
},
292
agent_client_protocol::on_receive_notification!(),
293
)
280
- .connect_to(Stdio::new())
294
+ .connect_to(transport)
295
.await;
296
// The editor went away: stop MCP servers instead of leaving them to be orphaned.
297
agent.shutdown().await;
crates/ed-acp/tests/in_process.rs
new
+66
@@ -0,0 +1,66 @@
1
+//! `serve` over an in-memory channel: the agent and its client in one process, no stdio.
2
+
3
+use std::sync::Arc;
4
+
5
+use agent_client_protocol::schema::ProtocolVersion;
6
+use agent_client_protocol::schema::v1::InitializeRequest;
7
+use agent_client_protocol::{Agent, Channel, Client, ConnectionTo};
8
+use ed_acp::{AgentInfo, LlmEnv, Profile, PromptCtx, ServeOptions, Toolset};
9
+
10
+struct Embedded;
11
+
12
+impl Profile for Embedded {
13
+ fn info(&self) -> AgentInfo {
14
+ AgentInfo {
15
+ name: "embedded-agent",
16
+ display_name: "Embedded",
17
+ version: "0.0.1",
18
+ env: LlmEnv {
19
+ prefix: "ED_ACP_IN_PROCESS_TEST",
20
+ dir_name: "ed-acp-in-process-test",
21
+ default_model: "test-model",
22
+ },
23
+ }
24
+ }
25
+
26
+ fn system_prompt(&self, _ctx: &PromptCtx<'_>) -> String {
27
+ String::new()
28
+ }
29
+
30
+ fn toolsets(&self) -> Vec<Arc<dyn Toolset>> {
31
+ Vec::new()
32
+ }
33
+}
34
+
35
+#[tokio::test]
36
+async fn initialize_over_an_in_memory_channel() {
37
+ let (agent_end, client_end) = Channel::duplex();
38
+ let opts = ServeOptions {
39
+ yolo: false,
40
+ surface: "app",
41
+ };
42
+ let server = tokio::spawn(ed_acp::serve(Arc::new(Embedded), opts, agent_end));
43
+
44
+ let info = Client
45
+ .builder()
46
+ .connect_with(client_end, |conn: ConnectionTo<Agent>| async move {
47
+ conn.send_request(InitializeRequest::new(ProtocolVersion::V1))
48
+ .block_task()
49
+ .await
50
+ })
51
+ .await
52
+ .expect("initialize");
53
+
54
+ let agent = info.agent_info.expect("agentInfo");
55
+ assert_eq!(agent.name, "embedded-agent");
56
+ assert_eq!(agent.version, "0.0.1");
57
+ let caps = info.agent_capabilities;
58
+ assert!(caps.load_session);
59
+
60
+ // The client hung up when `connect_with` returned, so the server stops on its own.
61
+ tokio::time::timeout(std::time::Duration::from_secs(5), server)
62
+ .await
63
+ .expect("server stops after the client disconnects")
64
+ .expect("server task")
65
+ .ok();
66
+}