Skip to main content

acp_utils/client/
session.rs

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