Skip to main content

acp_utils/client/
session.rs

1use super::error::AcpClientError;
2use super::event::AcpEvent;
3use super::prompt_handle::{AcpPromptHandle, PromptCommand};
4use super::tokio_agent::TokioAcpAgent;
5use crate::notifications::{
6    AuthMethodsUpdatedParams, ContextClearedParams, ContextUsageParams, ElicitationParams, McpNotification, McpRequest,
7    SubAgentProgressParams,
8};
9use agent_client_protocol::schema::{
10    AuthMethod, AuthenticateRequest, CancelNotification, ConfigOptionUpdate, ContentBlock, InitializeRequest,
11    InitializeResponse, ListSessionsRequest, LoadSessionRequest, NewSessionRequest, NewSessionResponse,
12    PermissionOptionId, PermissionOptionKind, PromptCapabilities, PromptRequest, RequestPermissionOutcome,
13    RequestPermissionRequest, RequestPermissionResponse, SelectedPermissionOutcome, SessionCapabilities,
14    SessionConfigOption, SessionId, SessionNotification, SessionUpdate, SetSessionConfigOptionRequest, TextContent,
15};
16use agent_client_protocol::{self as acp, Client, ConnectionTo, JsonRpcRequest};
17use tokio::sync::mpsc;
18use tracing::info;
19
20type InitializeResult = Result<(InitializeResponse, NewSessionResponse), AcpClientError>;
21
22/// ACP session with all handles needed by the caller.
23pub struct AcpSession {
24    pub session_id: SessionId,
25    pub agent_name: String,
26    pub prompt_capabilities: PromptCapabilities,
27    pub session_capabilities: SessionCapabilities,
28    pub config_options: Vec<SessionConfigOption>,
29    pub auth_methods: Vec<AuthMethod>,
30    pub event_rx: mpsc::UnboundedReceiver<AcpEvent>,
31    pub prompt_handle: AcpPromptHandle,
32}
33
34/// Spawn an agent subprocess and establish an ACP session.
35///
36/// The connection auto-approves permissions, forwards session notifications as
37/// [`AcpEvent`]s, and tunnels elicitation requests through the `_aether/elicitation`
38/// extension method.
39pub async fn spawn_acp_session(
40    agent: TokioAcpAgent,
41    init_request: InitializeRequest,
42    new_session_request: NewSessionRequest,
43) -> Result<AcpSession, AcpClientError> {
44    let (event_tx, event_rx) = mpsc::unbounded_channel::<AcpEvent>();
45    let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<PromptCommand>();
46    let (init_tx, mut init_rx) = mpsc::unbounded_channel::<InitializeResult>();
47    tokio::spawn(run_client_connection(agent, event_tx, cmd_rx, init_tx, init_request, new_session_request));
48
49    let (init_resp, session_resp) = init_rx
50        .recv()
51        .await
52        .ok_or_else(|| AcpClientError::AgentCrashed("ACP task died during initialization".to_string()))??;
53
54    let agent_name = init_resp
55        .agent_info
56        .as_ref()
57        .map_or_else(|| "agent".to_string(), |info| info.title.as_deref().unwrap_or(&info.name).to_string());
58
59    Ok(AcpSession {
60        session_id: session_resp.session_id,
61        agent_name,
62        prompt_capabilities: init_resp.agent_capabilities.prompt_capabilities,
63        session_capabilities: init_resp.agent_capabilities.session_capabilities,
64        config_options: session_resp.config_options.unwrap_or_default(),
65        auth_methods: init_resp.auth_methods,
66        event_rx,
67        prompt_handle: AcpPromptHandle { cmd_tx },
68    })
69}
70
71#[allow(clippy::too_many_lines)]
72async fn run_client_connection(
73    agent: TokioAcpAgent,
74    event_tx: mpsc::UnboundedSender<AcpEvent>,
75    cmd_rx: mpsc::UnboundedReceiver<PromptCommand>,
76    init_tx: mpsc::UnboundedSender<InitializeResult>,
77    init_request: InitializeRequest,
78    new_session_request: NewSessionRequest,
79) {
80    let connection_result = Client
81        .builder()
82        .on_receive_request(
83            async move |req: RequestPermissionRequest, responder, _cx| {
84                responder.respond(RequestPermissionResponse::new(RequestPermissionOutcome::Selected(
85                    SelectedPermissionOutcome::new(auto_approve_option(&req)),
86                )))
87            },
88            acp::on_receive_request!(),
89        )
90        .on_receive_request(
91            {
92                let event_tx = event_tx.clone();
93                async move |params: ElicitationParams, responder, _cx| {
94                    if let Err(send_err) = event_tx.send(AcpEvent::ElicitationRequest { params, responder }) {
95                        // Recover the responder and reply with an error so the remote caller doesn't hang.
96                        if let AcpEvent::ElicitationRequest { responder, .. } = send_err.0 {
97                            return responder.respond_with_error(acp::Error::internal_error());
98                        }
99                    }
100                    Ok(())
101                }
102            },
103            acp::on_receive_request!(),
104        )
105        .on_receive_notification(
106            {
107                let event_tx = event_tx.clone();
108                async move |SessionNotification { session_id, update, .. }: SessionNotification, _cx| {
109                    let _ = event_tx.send(AcpEvent::SessionUpdate { session_id, update: Box::new(update) });
110                    Ok(())
111                }
112            },
113            acp::on_receive_notification!(),
114        )
115        .on_receive_notification(
116            {
117                let event_tx = event_tx.clone();
118                async move |p: ContextUsageParams, _cx| {
119                    let _ = event_tx.send(AcpEvent::ContextUsage(p));
120                    Ok(())
121                }
122            },
123            acp::on_receive_notification!(),
124        )
125        .on_receive_notification(
126            {
127                let event_tx = event_tx.clone();
128                async move |p: ContextClearedParams, _cx| {
129                    let _ = event_tx.send(AcpEvent::ContextCleared(p));
130                    Ok(())
131                }
132            },
133            acp::on_receive_notification!(),
134        )
135        .on_receive_notification(
136            {
137                let event_tx = event_tx.clone();
138                async move |p: SubAgentProgressParams, _cx| {
139                    let _ = event_tx.send(AcpEvent::SubAgentProgress(p));
140                    Ok(())
141                }
142            },
143            acp::on_receive_notification!(),
144        )
145        .on_receive_notification(
146            {
147                let event_tx = event_tx.clone();
148                async move |p: AuthMethodsUpdatedParams, _cx| {
149                    let _ = event_tx.send(AcpEvent::AuthMethodsUpdated(p));
150                    Ok(())
151                }
152            },
153            acp::on_receive_notification!(),
154        )
155        .on_receive_notification(
156            {
157                let event_tx = event_tx.clone();
158                async move |n: McpNotification, _cx| {
159                    let _ = event_tx.send(AcpEvent::McpNotification(n));
160                    Ok(())
161                }
162            },
163            acp::on_receive_notification!(),
164        )
165        .connect_with(agent, {
166            let event_tx = event_tx.clone();
167            let init_tx = init_tx.clone();
168            async move |cx: ConnectionTo<acp::Agent>| {
169                run_main(cx, event_tx, cmd_rx, init_tx, init_request, new_session_request).await;
170                Ok(())
171            }
172        })
173        .await;
174
175    if let Err(e) = connection_result {
176        tracing::warn!("ACP connection exited with error: {e:?}");
177        let _ = init_tx.send(Err(AcpClientError::ConnectFailed(e)));
178    }
179    let _ = event_tx.send(AcpEvent::ConnectionClosed);
180}
181
182#[allow(clippy::too_many_lines)]
183async fn run_main(
184    cx: ConnectionTo<acp::Agent>,
185    event_tx: mpsc::UnboundedSender<AcpEvent>,
186    mut cmd_rx: mpsc::UnboundedReceiver<PromptCommand>,
187    init_tx: mpsc::UnboundedSender<InitializeResult>,
188    init_request: InitializeRequest,
189    new_session_request: NewSessionRequest,
190) {
191    let init_resp = match cx.send_request(init_request).block_task().await {
192        Ok(r) => r,
193        Err(e) => {
194            let _ = init_tx.send(Err(AcpClientError::Protocol(e)));
195            return;
196        }
197    };
198    info!("ACP initialized: protocol={:?}, agent_info={:?}", init_resp.protocol_version, init_resp.agent_info);
199
200    let session_resp = match cx.send_request(new_session_request).block_task().await {
201        Ok(r) => r,
202        Err(e) => {
203            let _ = init_tx.send(Err(AcpClientError::Protocol(e)));
204            return;
205        }
206    };
207    info!("ACP session created: {}", session_resp.session_id);
208
209    let _ = init_tx.send(Ok((init_resp, session_resp)));
210
211    while let Some(cmd) = cmd_rx.recv().await {
212        match cmd {
213            PromptCommand::Prompt { session_id, text, content } => {
214                let mut prompt = vec![ContentBlock::Text(TextContent::new(text))];
215                if let Some(extra_content) = content {
216                    prompt.extend(extra_content);
217                }
218                let prompt_fut = cx.send_request(PromptRequest::new(session_id, prompt)).block_task();
219                tokio::pin!(prompt_fut);
220
221                loop {
222                    tokio::select! {
223                        result = &mut prompt_fut => {
224                            let event = match result {
225                                Ok(resp) => AcpEvent::PromptDone(resp.stop_reason),
226                                Err(e) => AcpEvent::PromptError(e),
227                            };
228                            let _ = event_tx.send(event);
229                            break;
230                        }
231                        Some(cmd) = cmd_rx.recv() => {
232                            handle_side_command(&cx, &event_tx, cmd).await;
233                        }
234                    }
235                }
236            }
237            PromptCommand::ListSessions => {
238                request_to_event(
239                    &cx,
240                    &event_tx,
241                    ListSessionsRequest::new(),
242                    |resp| AcpEvent::SessionsListed { sessions: resp.sessions },
243                    AcpEvent::PromptError,
244                )
245                .await;
246            }
247            PromptCommand::LoadSession { session_id, cwd } => {
248                request_to_event(
249                    &cx,
250                    &event_tx,
251                    LoadSessionRequest::new(session_id.clone(), cwd),
252                    |resp| AcpEvent::SessionLoaded {
253                        session_id,
254                        config_options: resp.config_options.unwrap_or_default(),
255                    },
256                    AcpEvent::PromptError,
257                )
258                .await;
259            }
260            PromptCommand::NewSession { cwd } => {
261                request_to_event(
262                    &cx,
263                    &event_tx,
264                    NewSessionRequest::new(cwd),
265                    |resp| AcpEvent::NewSessionCreated {
266                        session_id: resp.session_id,
267                        config_options: resp.config_options.unwrap_or_default(),
268                    },
269                    AcpEvent::PromptError,
270                )
271                .await;
272            }
273            PromptCommand::MoveWorkspace(params) => {
274                request_to_event(&cx, &event_tx, params, AcpEvent::WorkspaceMoved, |e| AcpEvent::WorkspaceMoveFailed {
275                    error: format!("{e}"),
276                })
277                .await;
278            }
279            cmd => handle_side_command(&cx, &event_tx, cmd).await,
280        }
281    }
282}
283
284async fn handle_side_command(
285    cx: &ConnectionTo<acp::Agent>,
286    event_tx: &mpsc::UnboundedSender<AcpEvent>,
287    cmd: PromptCommand,
288) {
289    match cmd {
290        PromptCommand::Cancel { session_id } => {
291            let _ = cx.send_notification(CancelNotification::new(session_id));
292        }
293        PromptCommand::SetConfigOption { session_id, config_id, value } => {
294            let req = SetSessionConfigOptionRequest::new(session_id.clone(), config_id, value);
295            match cx.send_request(req).block_task().await {
296                Ok(resp) => {
297                    let update = ConfigOptionUpdate::new(resp.config_options);
298                    let _ = event_tx.send(AcpEvent::SessionUpdate {
299                        session_id,
300                        update: Box::new(SessionUpdate::ConfigOptionUpdate(update)),
301                    });
302                }
303                Err(e) => {
304                    tracing::warn!("set_session_config_option failed: {e:?}");
305                }
306            }
307        }
308        PromptCommand::Prompt { .. } => {
309            tracing::warn!("ignoring duplicate Prompt while one is in-flight");
310        }
311        PromptCommand::ListSessions => {
312            tracing::warn!("ignoring ListSessions while prompt is in-flight");
313        }
314        PromptCommand::LoadSession { .. } => {
315            tracing::warn!("ignoring LoadSession while prompt is in-flight");
316        }
317        PromptCommand::NewSession { .. } => {
318            tracing::warn!("ignoring NewSession while prompt is in-flight");
319        }
320        PromptCommand::AuthenticateMcpServer { session_id, server_name } => {
321            let msg = McpRequest::Authenticate { session_id: session_id.0.to_string(), server_name };
322            if let Err(e) = cx.send_notification(msg) {
323                tracing::warn!("authenticate_mcp_server notification failed: {e:?}");
324            }
325        }
326        PromptCommand::Authenticate { method_id } => {
327            match cx.send_request(AuthenticateRequest::new(method_id.clone())).block_task().await {
328                Ok(_) => {
329                    let _ = event_tx.send(AcpEvent::AuthenticateComplete { method_id });
330                }
331                Err(e) => {
332                    tracing::warn!("authenticate failed: {e:?}");
333                    let _ = event_tx.send(AcpEvent::AuthenticateFailed { method_id, error: format!("{e:?}") });
334                }
335            }
336        }
337        PromptCommand::SearchPrompts(params) => {
338            let query = params.query.clone();
339            request_to_event(cx, event_tx, params, AcpEvent::PromptSearchResults, |e| AcpEvent::PromptSearchFailed {
340                query,
341                error: format!("{e}"),
342            })
343            .await;
344        }
345        PromptCommand::SessionPreview(params) => {
346            let session_id = params.session_id.clone();
347            request_to_event(cx, event_tx, params, AcpEvent::SessionPreviewLoaded, |e| {
348                AcpEvent::SessionPreviewFailed { session_id, error: format!("{e}") }
349            })
350            .await;
351        }
352        PromptCommand::ListWorkspaces(params) => {
353            request_to_event(cx, event_tx, params, AcpEvent::WorkspacesListed, |e| AcpEvent::WorkspaceListFailed {
354                error: format!("{e}"),
355            })
356            .await;
357        }
358        PromptCommand::MoveWorkspace(_) => {
359            tracing::warn!("ignoring MoveWorkspace while prompt is in-flight");
360            let _ = event_tx.send(AcpEvent::WorkspaceMoveFailed { error: "a prompt is in flight".to_string() });
361        }
362    }
363}
364
365async fn request_to_event<T: JsonRpcRequest>(
366    cx: &ConnectionTo<acp::Agent>,
367    event_tx: &mpsc::UnboundedSender<AcpEvent>,
368    params: T,
369    ok: impl FnOnce(T::Response) -> AcpEvent,
370    err: impl FnOnce(acp::Error) -> AcpEvent,
371) {
372    let event = match cx.send_request(params).block_task().await {
373        Ok(resp) => ok(resp),
374        Err(e) => err(e),
375    };
376    let _ = event_tx.send(event);
377}
378
379fn auto_approve_option(req: &RequestPermissionRequest) -> PermissionOptionId {
380    debug_assert!(!req.options.is_empty(), "ACP guarantees at least one permission option");
381    req.options
382        .iter()
383        .find(|o| matches!(o.kind, PermissionOptionKind::AllowOnce | PermissionOptionKind::AllowAlways))
384        .map_or_else(|| req.options[0].option_id.clone(), |o| o.option_id.clone())
385}