@ondeinference / ed / commits / 2e5e4cd

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 +}