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
21pub 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
33pub 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 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
335async 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}